Initial commit: Browser Tab Manager Flutter app
45
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
30
.metadata
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "9f455d2486bcb28cad87b062475f42edc959f636"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
|
||||
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
|
||||
- platform: web
|
||||
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
|
||||
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
63
Dockerfile
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
FROM debian:bullseye-slim AS builder
|
||||
|
||||
# Install dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
git \
|
||||
unzip \
|
||||
xz-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create flutter user
|
||||
RUN useradd -m -u 1000 flutter
|
||||
|
||||
# Switch to flutter user
|
||||
USER flutter
|
||||
|
||||
# Install Flutter
|
||||
RUN git clone https://github.com/flutter/flutter.git /home/flutter/flutter -b stable --depth 1
|
||||
ENV PATH="/home/flutter/flutter/bin:${PATH}"
|
||||
|
||||
# Configure Flutter
|
||||
RUN flutter config --no-analytics && \
|
||||
flutter config --enable-web && \
|
||||
flutter precache --web
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /home/flutter/app
|
||||
|
||||
# Copy project files
|
||||
COPY --chown=flutter:flutter . .
|
||||
|
||||
# Enable web for this project
|
||||
RUN flutter create . --platforms web
|
||||
|
||||
# Get dependencies
|
||||
RUN flutter pub get
|
||||
|
||||
# Build web app
|
||||
RUN flutter build web --release
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built web app
|
||||
COPY --from=builder /home/flutter/app/build/web /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
|
||||
# This script packages the app for deployment to a server.
|
||||
|
||||
# It copies all necessary files into a dist folder.
|
||||
|
||||
# Creates a deploy.sh script that will run on the server.
|
||||
|
||||
# Compresses everything into a tar.gz file for easy transfer.
|
||||
|
||||
# Shows instructions to copy the package to the server and deploy it.
|
||||
421
README.md
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
# browser_tab_manager
|
||||
## Getting Started
|
||||
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
||||
- [online documentation](https://docs.flutter.dev/)
|
||||
# 🧠 Browser Tab Manager - Complete Code Study Guide
|
||||
|
||||
## 📋 Table of Contents
|
||||
1. [App Architecture Overview](#architecture)
|
||||
2. [Entry Point - main.dart](#main-dart)
|
||||
3. [Data Layer - Models](#models)
|
||||
4. [Service Layer](#services)
|
||||
5. [UI Layer - Screens](#screens)
|
||||
6. [Component Layer - Widgets](#widgets)
|
||||
7. [Utilities & Constants](#utilities)
|
||||
8. [Data Flow & State Management](#data-flow)
|
||||
9. [Browser API Integration](#browser-api)
|
||||
10. [Extension Communication](#extension-comm)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ App Architecture Overview {#architecture}
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ main.dart │
|
||||
│ (App Entry Point) │
|
||||
└─────────────────┬───────────────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────────────┐
|
||||
│ TabManagerHome │
|
||||
│ (Main Screen State) │
|
||||
└─────┬─────────────────────────────┬─────┘
|
||||
│ │
|
||||
┌─────▼─────┐ ┌─────▼─────┐
|
||||
│ Services │ │ Widgets │
|
||||
│ Layer │ │ Layer │
|
||||
└─────┬─────┘ └─────┬─────┘
|
||||
│ │
|
||||
┌─────▼─────┐ ┌─────▼─────┐
|
||||
│ Models │ │ Utils & │
|
||||
│ Layer │ │ Constants │
|
||||
└───────────┘ └───────────┘
|
||||
```
|
||||
|
||||
### Key Concepts:
|
||||
- **Separation of Concerns**: Each layer has specific responsibilities
|
||||
- **Unidirectional Data Flow**: Data flows down, events flow up
|
||||
- **State Management**: Centralized in TabManagerHome using setState()
|
||||
- **Service Communication**: Browser APIs and Extension messaging
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Entry Point - main.dart {#main-dart}
|
||||
|
||||
```dart
|
||||
void main() {
|
||||
runApp(const BrowserTabManagerApp());
|
||||
}
|
||||
```
|
||||
|
||||
**WHAT HAPPENS HERE:**
|
||||
1. `main()` is the entry point of every Dart/Flutter app
|
||||
2. `runApp()` tells Flutter to start the app with our root widget
|
||||
3. Creates the widget tree and starts the rendering engine
|
||||
|
||||
```dart
|
||||
class BrowserTabManagerApp extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Browser Tab Manager',
|
||||
theme: ThemeData(...),
|
||||
home: const TabManagerHome(),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**BREAKDOWN:**
|
||||
- `StatelessWidget`: Never changes its appearance based on internal state
|
||||
- `MaterialApp`: Root widget that provides Material Design theming
|
||||
- `theme` & `darkTheme`: Define app-wide visual styling
|
||||
- `home`: The first screen users see (TabManagerHome)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Data Layer - Models {#models}
|
||||
|
||||
### TabData Model (`models/tab_data.dart`)
|
||||
|
||||
```dart
|
||||
class TabData {
|
||||
String id; // Unique identifier
|
||||
String title; // Display name
|
||||
String url; // Web address
|
||||
String favicon; // Icon URL
|
||||
DateTime lastAccessed; // When last used
|
||||
bool isPinned; // Pinned status
|
||||
String type; // 'tab', 'bookmark', or 'history'
|
||||
int? visitCount; // Number of visits (nullable)
|
||||
String? folder; // Bookmark folder (nullable)
|
||||
}
|
||||
```
|
||||
|
||||
**PURPOSE:**
|
||||
- **Data Structure**: Represents all types of browser items uniformly
|
||||
- **Type Safety**: Dart ensures correct data types
|
||||
- **Nullable Fields**: `?` means the field can be null
|
||||
|
||||
### Factory Constructor:
|
||||
```dart
|
||||
factory TabData.fromJson(Map<String, dynamic> json) => TabData(
|
||||
id: json['id'].toString(),
|
||||
title: json['title'] ?? 'Untitled', // ?? means "if null, use default"
|
||||
// ... more fields
|
||||
);
|
||||
```
|
||||
|
||||
**WHAT IT DOES:**
|
||||
- Converts JSON data from browser APIs into TabData objects
|
||||
- Handles missing/null values gracefully with defaults
|
||||
- Standardizes different API response formats
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Service Layer {#services}
|
||||
|
||||
### BrowserApiService (`services/browser_api_service.dart`)
|
||||
|
||||
```dart
|
||||
class BrowserApiService {
|
||||
Future<List<TabData>> getTabs() async {
|
||||
final result = await _callBrowserAPI('getTabs');
|
||||
// Convert raw data to TabData objects
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**KEY CONCEPTS:**
|
||||
- `async/await`: Handles asynchronous operations
|
||||
- `Future<T>`: Represents a value that will be available later
|
||||
- **Abstraction**: Hides complex browser API details
|
||||
|
||||
### ExtensionService (`services/extension_service.dart`)
|
||||
|
||||
```dart
|
||||
void setupListener() {
|
||||
html.window.onMessage.listen((event) => {
|
||||
// Handle messages from browser extension
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**PURPOSE:**
|
||||
- **Communication Bridge**: Between web app and browser extension
|
||||
- **Event-Driven**: Responds to messages from extension
|
||||
- **Callback Pattern**: Uses function pointers for responses
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI Layer - Screens {#screens}
|
||||
|
||||
### TabManagerHome (`screens/tab_manager_home.dart`)
|
||||
|
||||
This is the **BRAIN** of the application - it manages all state and coordinates everything.
|
||||
|
||||
#### State Variables:
|
||||
```dart
|
||||
class _TabManagerHomeState extends State<TabManagerHome> {
|
||||
List<TabData> allItems = []; // ALL data from browser
|
||||
List<TabData> filteredItems = []; // DISPLAYED data (after search/filter)
|
||||
bool isGridView = true; // View mode toggle
|
||||
String sortBy = 'recent'; // Current sort method
|
||||
String filterType = 'all'; // Current filter
|
||||
bool isLoading = true; // Loading spinner state
|
||||
bool extensionMode = false; // Extension tracking mode
|
||||
}
|
||||
```
|
||||
|
||||
#### Key Methods:
|
||||
|
||||
**initState()** - Runs when widget is created:
|
||||
```dart
|
||||
@override
|
||||
void initState() {
|
||||
super.initState(); // Call parent setup
|
||||
_setupExtensionService(); // Start listening for extension
|
||||
_loadAllData(); // Load browser data
|
||||
searchController.addListener(_filterItems); // Watch search input
|
||||
}
|
||||
```
|
||||
|
||||
**setState()** - The magic that updates UI:
|
||||
```dart
|
||||
setState(() {
|
||||
allItems = newData; // Change state
|
||||
_filterItems(); // Update filtered view
|
||||
});
|
||||
// Flutter automatically rebuilds UI after setState finishes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Component Layer - Widgets {#widgets}
|
||||
|
||||
### ItemCard (`widgets/item_card.dart`)
|
||||
|
||||
**STATELESS WIDGET** - Displays data, doesn't manage state:
|
||||
|
||||
```dart
|
||||
class ItemCard extends StatelessWidget {
|
||||
final TabData item; // Data from parent
|
||||
final VoidCallback onTap; // Function to call when tapped
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: onTap, // Call parent's function
|
||||
child: // ... UI layout
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**DATA FLOW:**
|
||||
1. Parent passes data and callback functions
|
||||
2. Widget displays the data
|
||||
3. User interaction calls parent's functions
|
||||
4. Parent updates state and rebuilds widget with new data
|
||||
|
||||
### SearchBar (`widgets/search_bar.dart`)
|
||||
|
||||
```dart
|
||||
TextField(
|
||||
controller: controller, // Links to parent's TextEditingController
|
||||
onChanged: (text) => { // Calls parent when text changes
|
||||
// Parent handles the search logic
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Utilities & Constants {#utilities}
|
||||
|
||||
### Helpers (`utils/helpers.dart`)
|
||||
|
||||
```dart
|
||||
static List<TabData> filterItems(
|
||||
List<TabData> items,
|
||||
String query,
|
||||
String filterType,
|
||||
) {
|
||||
return items.where((item) => {
|
||||
// Filter logic here
|
||||
}).toList();
|
||||
}
|
||||
```
|
||||
|
||||
**PURPOSE:**
|
||||
- **Pure Functions**: Same input always produces same output
|
||||
- **Reusable Logic**: Can be used anywhere in the app
|
||||
- **Testing**: Easy to unit test
|
||||
|
||||
### Constants (`constants/app_constants.dart`)
|
||||
|
||||
```dart
|
||||
class AppConstants {
|
||||
static const Color primaryColor = Color(0xFF0175C2);
|
||||
static const String extensionSource = 'tab-tracker-extension';
|
||||
}
|
||||
```
|
||||
|
||||
**BENEFITS:**
|
||||
- **Single Source of Truth**: Change value in one place
|
||||
- **Type Safety**: Compile-time checking
|
||||
- **Maintainability**: Easy to update app-wide settings
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Data Flow & State Management {#data-flow}
|
||||
|
||||
### Complete Data Flow Cycle:
|
||||
|
||||
```
|
||||
1. User Action (tap, type, click)
|
||||
↓
|
||||
2. Widget calls parent function
|
||||
↓
|
||||
3. Parent updates state with setState()
|
||||
↓
|
||||
4. Flutter rebuilds widget tree
|
||||
↓
|
||||
5. UI reflects new state
|
||||
```
|
||||
|
||||
### Example: Search Flow
|
||||
|
||||
```dart
|
||||
// 1. User types in search bar
|
||||
SearchBar(onChanged: (text) => {
|
||||
// 2. SearchBar calls parent's function
|
||||
_filterItems();
|
||||
});
|
||||
|
||||
// 3. Parent filters data and updates state
|
||||
void _filterItems() {
|
||||
setState(() => {
|
||||
filteredItems = // ... filter logic
|
||||
});
|
||||
// 4. Flutter rebuilds UI with new filteredItems
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Browser API Integration {#browser-api}
|
||||
|
||||
### How Browser APIs Work:
|
||||
|
||||
```dart
|
||||
Future<String?> _callBrowserAPI(String method, [List<dynamic>? args]) async {
|
||||
// 1. Check if browser API exists
|
||||
if (!js_util.hasProperty(html.window, 'BrowserAPI')) {
|
||||
return null; // Development mode
|
||||
}
|
||||
|
||||
// 2. Get the API object from browser
|
||||
final browserAPI = js_util.getProperty(html.window, 'BrowserAPI');
|
||||
|
||||
// 3. Call the specific method
|
||||
final result = await js_util.promiseToFuture(
|
||||
js_util.callMethod(function, 'call', [browserAPI, ...args])
|
||||
);
|
||||
|
||||
return json.encode(result); // 4. Return as JSON string
|
||||
}
|
||||
```
|
||||
|
||||
**STEPS:**
|
||||
1. **Check Availability**: Is the browser API injected?
|
||||
2. **Get Reference**: Access the API object
|
||||
3. **Call Method**: Execute with parameters
|
||||
4. **Handle Response**: Convert to usable format
|
||||
|
||||
---
|
||||
|
||||
## 📡 Extension Communication {#extension-comm}
|
||||
|
||||
### Message Passing System:
|
||||
|
||||
```dart
|
||||
// SENDING to extension:
|
||||
html.window.postMessage({
|
||||
'source': 'tab-tracker-webapp',
|
||||
'action': 'startTracking'
|
||||
}, '*');
|
||||
|
||||
// RECEIVING from extension:
|
||||
html.window.onMessage.listen((event) => {
|
||||
final data = event.data;
|
||||
if (data['source'] == 'tab-tracker-extension') {
|
||||
_handleExtensionMessage(data);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**COMMUNICATION FLOW:**
|
||||
```
|
||||
Web App ←→ Browser Window ←→ Extension
|
||||
```
|
||||
|
||||
### Message Types:
|
||||
- `startTracking`: Begin tab monitoring
|
||||
- `stopTracking`: Stop tab monitoring
|
||||
- `updateTabs`: Extension sends current tabs
|
||||
- `getStatus`: Request current state
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Key Learning Points
|
||||
|
||||
### 1. **State Management Pattern**
|
||||
- State lives in parent components
|
||||
- Children receive data and callback functions
|
||||
- setState() triggers UI rebuilds
|
||||
|
||||
### 2. **Async Programming**
|
||||
- `async/await` for non-blocking operations
|
||||
- `Future<T>` represents eventual values
|
||||
- Error handling with try/catch
|
||||
|
||||
### 3. **Widget Communication**
|
||||
- Parent-to-child: Pass data via constructor
|
||||
- Child-to-parent: Pass callback functions
|
||||
- Sibling-to-sibling: Through common parent
|
||||
|
||||
### 4. **Service Layer Benefits**
|
||||
- Separates business logic from UI
|
||||
- Makes testing easier
|
||||
- Provides clean abstractions
|
||||
|
||||
### 5. **Browser Integration**
|
||||
- JavaScript interop for browser APIs
|
||||
- Message passing for extension communication
|
||||
- Graceful degradation for development mode
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Next Steps for Deep Learning
|
||||
|
||||
1. **Trace a complete user action** from UI tap to state update
|
||||
2. **Follow data transformation** from browser API to UI display
|
||||
3. **Understand lifecycle methods** and when they're called
|
||||
4. **Practice modifying** one small feature at a time
|
||||
5. **Add logging** to see the flow in action
|
||||
|
||||
This architecture provides a solid foundation for building complex, maintainable Flutter web applications! 🚀
|
||||
1207
_backup/main.dart
Normal file
28
analysis_options.yaml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
11
app/manifest.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "Browser Tab Manager",
|
||||
"short_name": "TabManager",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "Manage browser tabs in a grid view",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
BIN
dist/browser-tab-manager.tar.gz
vendored
Normal file
52
dist/browser-tab-manager/Dockerfile
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
FROM debian:bullseye-slim AS builder
|
||||
|
||||
# Install dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
git \
|
||||
unzip \
|
||||
xz-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create flutter user
|
||||
RUN useradd -m -u 1000 flutter
|
||||
|
||||
# Switch to flutter user
|
||||
USER flutter
|
||||
|
||||
# Install Flutter
|
||||
RUN git clone https://github.com/flutter/flutter.git /home/flutter/flutter -b stable --depth 1
|
||||
ENV PATH="/home/flutter/flutter/bin:${PATH}"
|
||||
|
||||
# Configure Flutter
|
||||
RUN flutter config --no-analytics && \
|
||||
flutter config --enable-web && \
|
||||
flutter precache --web
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /home/flutter/app
|
||||
|
||||
# Copy project files
|
||||
COPY --chown=flutter:flutter . .
|
||||
|
||||
# Enable web for this project
|
||||
RUN flutter create . --platforms web
|
||||
|
||||
# Get dependencies
|
||||
RUN flutter pub get
|
||||
|
||||
# Build web app
|
||||
RUN flutter build web --release
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built web app
|
||||
COPY --from=builder /home/flutter/app/build/web /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
60
dist/browser-tab-manager/deploy.sh
vendored
Executable file
|
|
@ -0,0 +1,60 @@
|
|||
#!/bin/bash
|
||||
# Server Deployment Script
|
||||
set -e
|
||||
|
||||
echo "🚀 Deploying Browser Tab Manager on Server..."
|
||||
|
||||
# Ensure we're in the right directory
|
||||
if [ ! -f "Dockerfile" ]; then
|
||||
echo "❌ Error: Dockerfile not found. Are you in the right directory?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create web/manifest.json if missing
|
||||
mkdir -p web
|
||||
if [ ! -f "web/manifest.json" ]; then
|
||||
cat > web/manifest.json << 'EOF'
|
||||
{
|
||||
"name": "Browser Tab Manager",
|
||||
"short_name": "TabManager",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "Manage browser tabs in a grid view",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "✅ Project structure ready"
|
||||
|
||||
# Build the container
|
||||
echo "🔨 Building Podman container..."
|
||||
podman build -t browser-tab-manager .
|
||||
|
||||
# Stop and remove existing container if running
|
||||
podman stop browser-tab-manager 2>/dev/null || true
|
||||
podman rm browser-tab-manager 2>/dev/null || true
|
||||
|
||||
# Run the container on port 8081
|
||||
echo "🚢 Starting container..."
|
||||
podman run -d \
|
||||
--name browser-tab-manager \
|
||||
-p 8081:80 \
|
||||
--restart unless-stopped \
|
||||
browser-tab-manager
|
||||
|
||||
echo "✅ Container started successfully!"
|
||||
echo ""
|
||||
echo "🌐 Your Browser Tab Manager is now running at:"
|
||||
SERVER_IP=$(hostname -I | awk '{print $1}' 2>/dev/null || echo "your-server-ip")
|
||||
echo " http://${SERVER_IP}:8081"
|
||||
echo ""
|
||||
echo "📝 Useful commands:"
|
||||
echo " View logs: podman logs -f browser-tab-manager"
|
||||
echo " Stop: podman stop browser-tab-manager"
|
||||
echo " Start: podman start browser-tab-manager"
|
||||
echo " Restart: podman restart browser-tab-manager"
|
||||
echo " Remove: podman rm -f browser-tab-manager"
|
||||
890
dist/browser-tab-manager/lib/main.dart
vendored
Normal file
|
|
@ -0,0 +1,890 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'dart:html' as html;
|
||||
import 'dart:convert';
|
||||
import 'dart:js_util' as js_util;
|
||||
|
||||
void main() {
|
||||
runApp(const BrowserTabManagerApp());
|
||||
}
|
||||
|
||||
class BrowserTabManagerApp extends StatelessWidget {
|
||||
const BrowserTabManagerApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Browser Tab Manager',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF0175C2),
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF0175C2),
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const TabManagerHome(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TabData {
|
||||
String id;
|
||||
String title;
|
||||
String url;
|
||||
String favicon;
|
||||
DateTime lastAccessed;
|
||||
bool isPinned;
|
||||
String type; // 'tab', 'bookmark', 'history'
|
||||
int? visitCount;
|
||||
String? folder;
|
||||
|
||||
TabData({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.url,
|
||||
this.favicon = '',
|
||||
DateTime? lastAccessed,
|
||||
this.isPinned = false,
|
||||
this.type = 'tab',
|
||||
this.visitCount,
|
||||
this.folder,
|
||||
}) : lastAccessed = lastAccessed ?? DateTime.now();
|
||||
|
||||
factory TabData.fromJson(Map<String, dynamic> json) => TabData(
|
||||
id: json['id'].toString(),
|
||||
title: json['title'] ?? 'Untitled',
|
||||
url: json['url'] ?? '',
|
||||
favicon: json['favicon'] ?? json['favIconUrl'] ?? '',
|
||||
lastAccessed: json['lastAccessed'] != null
|
||||
? DateTime.parse(json['lastAccessed'])
|
||||
: (json['lastVisitTime'] != null
|
||||
? DateTime.parse(json['lastVisitTime'])
|
||||
: (json['dateAdded'] != null
|
||||
? DateTime.parse(json['dateAdded'])
|
||||
: (json['timestamp'] != null
|
||||
? DateTime.parse(json['timestamp'])
|
||||
: DateTime.now()))),
|
||||
isPinned: json['isPinned'] ?? false,
|
||||
type: json['type'] ?? 'tab',
|
||||
visitCount: json['visitCount'],
|
||||
folder: json['folder'],
|
||||
);
|
||||
}
|
||||
|
||||
class TabManagerHome extends StatefulWidget {
|
||||
const TabManagerHome({super.key});
|
||||
|
||||
@override
|
||||
State<TabManagerHome> createState() => _TabManagerHomeState();
|
||||
}
|
||||
|
||||
class _TabManagerHomeState extends State<TabManagerHome> {
|
||||
List<TabData> allItems = [];
|
||||
List<TabData> filteredItems = [];
|
||||
final TextEditingController searchController = TextEditingController();
|
||||
bool isGridView = true;
|
||||
String sortBy = 'recent';
|
||||
String filterType = 'all'; // 'all', 'tabs', 'bookmarks', 'history'
|
||||
bool isLoading = true;
|
||||
bool extensionConnected = false;
|
||||
bool extensionMode = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupExtensionListener();
|
||||
_loadAllData();
|
||||
searchController.addListener(_filterItems);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Setup extension listener
|
||||
void _setupExtensionListener() {
|
||||
html.window.onMessage.listen((event) {
|
||||
final data = event.data;
|
||||
|
||||
if (data is Map && data['source'] == 'tab-tracker-extension') {
|
||||
_handleExtensionMessage(Map<String, dynamic>.from(data));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle messages from extension
|
||||
void _handleExtensionMessage(Map<String, dynamic> data) {
|
||||
print('Received from extension: $data');
|
||||
|
||||
if (data['action'] == 'updateTabs') {
|
||||
setState(() {
|
||||
final extensionTabs = (data['tabs'] as List).map((tab) {
|
||||
tab['type'] = 'tab';
|
||||
return TabData.fromJson(tab);
|
||||
}).toList();
|
||||
|
||||
allItems = extensionTabs;
|
||||
extensionConnected = true;
|
||||
extensionMode = true;
|
||||
_filterItems();
|
||||
});
|
||||
} else if (data['action'] == 'clear') {
|
||||
if (extensionMode) {
|
||||
setState(() {
|
||||
extensionMode = false;
|
||||
_loadAllData();
|
||||
});
|
||||
}
|
||||
} else if (data['response'] != null) {
|
||||
final response = data['response'];
|
||||
if (response['status'] == 'started') {
|
||||
setState(() {
|
||||
extensionMode = true;
|
||||
extensionConnected = true;
|
||||
});
|
||||
} else if (response['status'] == 'stopped') {
|
||||
setState(() {
|
||||
extensionMode = false;
|
||||
_loadAllData();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send message to extension
|
||||
void _sendToExtension(Map<String, dynamic> message) {
|
||||
html.window.postMessage({
|
||||
'source': 'tab-tracker-webapp',
|
||||
...message
|
||||
}, '*');
|
||||
}
|
||||
|
||||
// Start extension tracking
|
||||
void _startExtensionTracking() {
|
||||
_sendToExtension({'action': 'startTracking'});
|
||||
setState(() {
|
||||
extensionMode = true;
|
||||
allItems.clear();
|
||||
});
|
||||
}
|
||||
|
||||
// Stop extension tracking
|
||||
void _stopExtensionTracking() {
|
||||
_sendToExtension({'action': 'stopTracking'});
|
||||
setState(() {
|
||||
extensionMode = false;
|
||||
});
|
||||
_loadAllData();
|
||||
}
|
||||
|
||||
Future<void> _loadAllData() async {
|
||||
if (extensionMode) return; // Don't load if in extension mode
|
||||
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final tabs = await _getTabs();
|
||||
final bookmarks = await _getBookmarks();
|
||||
final history = await _getHistory();
|
||||
|
||||
setState(() {
|
||||
allItems = [...tabs, ...bookmarks, ...history];
|
||||
_filterItems();
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
// Check if extension is available
|
||||
_checkExtensionConnection();
|
||||
} catch (e) {
|
||||
print('Error loading data: $e');
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _checkExtensionConnection() {
|
||||
_sendToExtension({'action': 'getStatus'});
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
// extensionConnected will be set by message handler if extension responds
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<TabData>> _getTabs() async {
|
||||
try {
|
||||
final result = await _callBrowserAPI('getTabs');
|
||||
if (result != null) {
|
||||
final List<dynamic> data = json.decode(result);
|
||||
return data.map((item) {
|
||||
item['type'] = 'tab';
|
||||
return TabData.fromJson(item);
|
||||
}).toList();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error getting tabs: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<List<TabData>> _getBookmarks() async {
|
||||
try {
|
||||
final result = await _callBrowserAPI('getBookmarks');
|
||||
if (result != null) {
|
||||
final List<dynamic> data = json.decode(result);
|
||||
return data.map((item) {
|
||||
item['type'] = 'bookmark';
|
||||
return TabData.fromJson(item);
|
||||
}).toList();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error getting bookmarks: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<List<TabData>> _getHistory() async {
|
||||
try {
|
||||
final result = await _callBrowserAPI('getHistory', [100]);
|
||||
if (result != null) {
|
||||
final List<dynamic> data = json.decode(result);
|
||||
return data.map((item) {
|
||||
item['type'] = 'history';
|
||||
return TabData.fromJson(item);
|
||||
}).toList();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error getting history: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<String?> _callBrowserAPI(String method, [List<dynamic>? args]) async {
|
||||
try {
|
||||
// Check if BrowserAPI exists
|
||||
if (!js_util.hasProperty(html.window, 'BrowserAPI')) {
|
||||
print('BrowserAPI not found - running in development mode');
|
||||
return null;
|
||||
}
|
||||
|
||||
final browserAPI = js_util.getProperty(html.window, 'BrowserAPI');
|
||||
final function = js_util.getProperty(browserAPI, method);
|
||||
|
||||
final result = args == null
|
||||
? await js_util.promiseToFuture(js_util.callMethod(function, 'call', [browserAPI]))
|
||||
: await js_util.promiseToFuture(js_util.callMethod(function, 'call', [browserAPI, ...args]));
|
||||
|
||||
return json.encode(result);
|
||||
} catch (e) {
|
||||
print('Error calling $method: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _filterItems() {
|
||||
final query = searchController.text.toLowerCase();
|
||||
setState(() {
|
||||
filteredItems = allItems.where((item) {
|
||||
// Filter by type
|
||||
if (filterType != 'all' && item.type != filterType.replaceAll('s', '')) {
|
||||
return false;
|
||||
}
|
||||
// Filter by search query
|
||||
if (query.isNotEmpty) {
|
||||
return item.title.toLowerCase().contains(query) ||
|
||||
item.url.toLowerCase().contains(query);
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
_sortItems();
|
||||
});
|
||||
}
|
||||
|
||||
void _sortItems() {
|
||||
switch (sortBy) {
|
||||
case 'recent':
|
||||
filteredItems.sort((a, b) => b.lastAccessed.compareTo(a.lastAccessed));
|
||||
break;
|
||||
case 'title':
|
||||
filteredItems.sort((a, b) => a.title.compareTo(b.title));
|
||||
break;
|
||||
case 'url':
|
||||
filteredItems.sort((a, b) => a.url.compareTo(b.url));
|
||||
break;
|
||||
case 'visits':
|
||||
filteredItems.sort((a, b) =>
|
||||
(b.visitCount ?? 0).compareTo(a.visitCount ?? 0));
|
||||
break;
|
||||
}
|
||||
// Keep pinned tabs at the top
|
||||
filteredItems.sort((a, b) => b.isPinned ? 1 : (a.isPinned ? -1 : 0));
|
||||
}
|
||||
|
||||
Future<void> _openItem(TabData item) async {
|
||||
if (extensionMode) {
|
||||
// In extension mode, just open URL in new tab
|
||||
html.window.open(item.url, '_blank');
|
||||
} else {
|
||||
if (item.type == 'tab') {
|
||||
await _callBrowserAPI('switchToTab', [item.id]);
|
||||
} else {
|
||||
await _callBrowserAPI('openTab', [item.url]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteItem(TabData item) async {
|
||||
if (extensionMode) return; // Can't delete in extension mode
|
||||
|
||||
if (item.type == 'tab') {
|
||||
await _callBrowserAPI('closeTab', [item.id]);
|
||||
} else if (item.type == 'bookmark') {
|
||||
await _callBrowserAPI('removeBookmark', [item.id]);
|
||||
}
|
||||
await _loadAllData();
|
||||
}
|
||||
|
||||
Future<void> _togglePin(TabData item) async {
|
||||
if (extensionMode) return; // Can't pin in extension mode
|
||||
|
||||
if (item.type == 'tab') {
|
||||
await _callBrowserAPI('togglePinTab', [item.id, !item.isPinned]);
|
||||
await _loadAllData();
|
||||
}
|
||||
}
|
||||
|
||||
String _getIcon(TabData item) {
|
||||
if (item.favicon.isNotEmpty && !item.favicon.contains('data:')) {
|
||||
return '🌐';
|
||||
}
|
||||
|
||||
switch (item.type) {
|
||||
case 'tab':
|
||||
return '📑';
|
||||
case 'bookmark':
|
||||
return '⭐';
|
||||
case 'history':
|
||||
return '🕐';
|
||||
default:
|
||||
return '🌐';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stats = {
|
||||
'tabs': allItems.where((i) => i.type == 'tab').length,
|
||||
'bookmarks': allItems.where((i) => i.type == 'bookmark').length,
|
||||
'history': allItems.where((i) => i.type == 'history').length,
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Row(
|
||||
children: [
|
||||
const Text('Browser Tab Manager'),
|
||||
if (extensionMode) ...[
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'TRACKING',
|
||||
style: TextStyle(fontSize: 10, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
// Extension control button
|
||||
if (extensionMode)
|
||||
TextButton.icon(
|
||||
onPressed: _stopExtensionTracking,
|
||||
icon: const Icon(Icons.stop, color: Colors.white),
|
||||
label: const Text('Stop', style: TextStyle(color: Colors.white)),
|
||||
)
|
||||
else
|
||||
TextButton.icon(
|
||||
onPressed: extensionConnected ? _startExtensionTracking : null,
|
||||
icon: const Icon(Icons.play_arrow, color: Colors.white),
|
||||
label: const Text('Track Tabs', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: extensionMode ? null : _loadAllData,
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(isGridView ? Icons.view_list : Icons.grid_view),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
isGridView = !isGridView;
|
||||
});
|
||||
},
|
||||
tooltip: isGridView ? 'List View' : 'Grid View',
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.sort),
|
||||
tooltip: 'Sort by',
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
sortBy = value;
|
||||
_filterItems();
|
||||
});
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(value: 'recent', child: Text('Recent')),
|
||||
const PopupMenuItem(value: 'title', child: Text('Title')),
|
||||
const PopupMenuItem(value: 'url', child: Text('URL')),
|
||||
const PopupMenuItem(value: 'visits', child: Text('Most Visited')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: extensionMode
|
||||
? 'Search tracked tabs...'
|
||||
: 'Search tabs, bookmarks, and history...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (!extensionMode)
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
FilterChip(
|
||||
label: Text('All (${allItems.length})'),
|
||||
selected: filterType == 'all',
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
filterType = 'all';
|
||||
_filterItems();
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
label: Text('Tabs (${stats['tabs']})'),
|
||||
selected: filterType == 'tabs',
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
filterType = 'tabs';
|
||||
_filterItems();
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
label: Text('Bookmarks (${stats['bookmarks']})'),
|
||||
selected: filterType == 'bookmarks',
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
filterType = 'bookmarks';
|
||||
_filterItems();
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
label: Text('History (${stats['history']})'),
|
||||
selected: filterType == 'history',
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
filterType = 'history';
|
||||
_filterItems();
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: filteredItems.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
extensionMode ? Icons.track_changes : Icons.search_off,
|
||||
size: 80,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
extensionMode
|
||||
? 'Waiting for tabs...'
|
||||
: 'No items found',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
extensionMode
|
||||
? 'Open some tabs to see them here'
|
||||
: 'Try a different search or filter',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: isGridView
|
||||
? GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 300,
|
||||
childAspectRatio: 1.3,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
),
|
||||
itemCount: filteredItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
return ItemCard(
|
||||
item: filteredItems[index],
|
||||
icon: _getIcon(filteredItems[index]),
|
||||
onTap: () => _openItem(filteredItems[index]),
|
||||
onDelete: () =>
|
||||
_deleteItem(filteredItems[index]),
|
||||
onTogglePin: () =>
|
||||
_togglePin(filteredItems[index]),
|
||||
extensionMode: extensionMode,
|
||||
);
|
||||
},
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: filteredItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
return ItemListTile(
|
||||
item: filteredItems[index],
|
||||
icon: _getIcon(filteredItems[index]),
|
||||
onTap: () => _openItem(filteredItems[index]),
|
||||
onDelete: () =>
|
||||
_deleteItem(filteredItems[index]),
|
||||
onTogglePin: () =>
|
||||
_togglePin(filteredItems[index]),
|
||||
extensionMode: extensionMode,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ItemCard extends StatelessWidget {
|
||||
final TabData item;
|
||||
final String icon;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onTogglePin;
|
||||
final bool extensionMode;
|
||||
|
||||
const ItemCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
required this.onDelete,
|
||||
required this.onTogglePin,
|
||||
this.extensionMode = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 60,
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Center(
|
||||
child: item.favicon.isNotEmpty && item.favicon.startsWith('http')
|
||||
? Image.network(
|
||||
item.favicon,
|
||||
width: 32,
|
||||
height: 32,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Text(icon, style: const TextStyle(fontSize: 32));
|
||||
},
|
||||
)
|
||||
: Text(icon, style: const TextStyle(fontSize: 32)),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (item.isPinned)
|
||||
Icon(
|
||||
Icons.push_pin,
|
||||
size: 14,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
if (item.isPinned) const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getTypeColor(context),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
item.type.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.url,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (item.visitCount != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${item.visitCount} visits',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!extensionMode)
|
||||
ButtonBar(
|
||||
alignment: MainAxisAlignment.end,
|
||||
buttonPadding: EdgeInsets.zero,
|
||||
children: [
|
||||
if (item.type == 'tab')
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
item.isPinned ? Icons.push_pin : Icons.push_pin_outlined,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: onTogglePin,
|
||||
tooltip: item.isPinned ? 'Unpin' : 'Pin',
|
||||
),
|
||||
if (item.type != 'history')
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
onPressed: onDelete,
|
||||
tooltip: 'Delete',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getTypeColor(BuildContext context) {
|
||||
switch (item.type) {
|
||||
case 'tab':
|
||||
return Colors.blue;
|
||||
case 'bookmark':
|
||||
return Colors.orange;
|
||||
case 'history':
|
||||
return Colors.purple;
|
||||
default:
|
||||
return Theme.of(context).colorScheme.primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ItemListTile extends StatelessWidget {
|
||||
final TabData item;
|
||||
final String icon;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onTogglePin;
|
||||
final bool extensionMode;
|
||||
|
||||
const ItemListTile({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
required this.onDelete,
|
||||
required this.onTogglePin,
|
||||
this.extensionMode = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
child: item.favicon.isNotEmpty && item.favicon.startsWith('http')
|
||||
? Image.network(
|
||||
item.favicon,
|
||||
width: 20,
|
||||
height: 20,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Text(icon, style: const TextStyle(fontSize: 20));
|
||||
},
|
||||
)
|
||||
: Text(icon, style: const TextStyle(fontSize: 20)),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
if (item.isPinned) ...[
|
||||
Icon(
|
||||
Icons.push_pin,
|
||||
size: 14,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _getTypeColor(context),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
item.type.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.url,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (item.visitCount != null)
|
||||
Text('${item.visitCount} visits',
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
trailing: extensionMode ? null : Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (item.type == 'tab')
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
item.isPinned ? Icons.push_pin : Icons.push_pin_outlined,
|
||||
),
|
||||
onPressed: onTogglePin,
|
||||
tooltip: item.isPinned ? 'Unpin' : 'Pin',
|
||||
),
|
||||
if (item.type != 'history')
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: onDelete,
|
||||
tooltip: 'Delete',
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getTypeColor(BuildContext context) {
|
||||
switch (item.type) {
|
||||
case 'tab':
|
||||
return Colors.blue;
|
||||
case 'bookmark':
|
||||
return Colors.orange;
|
||||
case 'history':
|
||||
return Colors.purple;
|
||||
default:
|
||||
return Theme.of(context).colorScheme.primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
dist/browser-tab-manager/nginx.conf
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Enable gzip
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
20
dist/browser-tab-manager/pubspec.yaml
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
name: browser_tab_manager
|
||||
description: A Flutter web app for managing browser tabs, bookmarks, and history
|
||||
publish_to: 'none'
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
web: ^1.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
11
dist/browser-tab-manager/web/manifest.json
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "Browser Tab Manager",
|
||||
"short_name": "TabManager",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "Manage browser tabs in a grid view",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
67
lib/constants/app_constants.dart
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppConstants {
|
||||
// App Theme
|
||||
static const Color primaryColor = Color(0xFF0175C2);
|
||||
|
||||
// Extension Communication
|
||||
static const String extensionSource = 'tab-tracker-extension';
|
||||
static const String webappSource = 'tab-tracker-webapp';
|
||||
|
||||
// Default Values
|
||||
static const int defaultHistoryLimit = 100;
|
||||
static const Duration extensionCheckDelay = Duration(milliseconds: 500);
|
||||
|
||||
// Grid Configuration
|
||||
static const double maxCrossAxisExtent = 300.0;
|
||||
static const double childAspectRatio = 1.3;
|
||||
static const double crossAxisSpacing = 16.0;
|
||||
static const double mainAxisSpacing = 16.0;
|
||||
|
||||
// Padding and Spacing
|
||||
static const EdgeInsets defaultPadding = EdgeInsets.all(16.0);
|
||||
static const EdgeInsets cardPadding = EdgeInsets.all(12.0);
|
||||
static const EdgeInsets buttonPadding = EdgeInsets.symmetric(horizontal: 8, vertical: 4);
|
||||
|
||||
// Icon Sizes
|
||||
static const double smallIconSize = 14.0;
|
||||
static const double mediumIconSize = 18.0;
|
||||
static const double largeIconSize = 32.0;
|
||||
static const double emptyStateIconSize = 80.0;
|
||||
|
||||
// Text Styles
|
||||
static const double smallFontSize = 10.0;
|
||||
static const double mediumFontSize = 20.0;
|
||||
|
||||
// Sort Options
|
||||
static const List<String> sortOptions = ['recent', 'title', 'url', 'visits'];
|
||||
static const List<String> filterOptions = ['all', 'tabs', 'bookmarks', 'history'];
|
||||
|
||||
// Messages
|
||||
static const String trackingMessage = 'TRACKING';
|
||||
static const String waitingMessage = 'Waiting for tabs...';
|
||||
static const String noItemsMessage = 'No items found';
|
||||
static const String openTabsHint = 'Open some tabs to see them here';
|
||||
static const String tryDifferentSearchHint = 'Try a different search or filter';
|
||||
|
||||
// Search Hints
|
||||
static const String extensionSearchHint = 'Search tracked tabs...';
|
||||
static const String normalSearchHint = 'Search tabs, bookmarks, and history...';
|
||||
}
|
||||
|
||||
|
||||
// This is our centralized configuration file where all fixed values are stored in one place.
|
||||
//
|
||||
// It contains settings used throughout the app.
|
||||
//
|
||||
// Instead of hardcoding values
|
||||
//
|
||||
// This makes it easy to update the entire app by changing values in just one location.
|
||||
//
|
||||
// For example, changing primaryColor here updates every button, header, and highlight in the app.
|
||||
//
|
||||
// All user-facing messages are stored here making translation and text updates simple.
|
||||
//
|
||||
// Grid layout values are defined here so we can adjust the card view globally.
|
||||
//
|
||||
// This approach keeps our code clean, consistent, and easy to maintain.
|
||||
55
lib/main.dart
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'screens/tab_manager_home.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const BrowserTabManagerApp());
|
||||
}
|
||||
|
||||
class BrowserTabManagerApp extends StatelessWidget {
|
||||
const BrowserTabManagerApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Browser Tab Manager',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF0175C2),
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF0175C2),
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const TabManagerHome(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// This is the entry point of our Flutter application.
|
||||
//
|
||||
// The main function is the first thing that runs when the app starts.
|
||||
//
|
||||
// It calls runApp which takes our root widget and displays it on screen.
|
||||
//
|
||||
// BrowserTabManagerApp is a stateless widget that creates the MaterialApp.
|
||||
//
|
||||
// MaterialApp provides the overall app structure including theme, routing, and title.
|
||||
//
|
||||
// We define both a light theme and dark theme that automatically switch based on system settings.
|
||||
//
|
||||
// Both themes use the same blue seed color to generate a consistent color scheme.
|
||||
//
|
||||
// Material 3 design is enabled for modern UI components and styling.
|
||||
//
|
||||
// The home property sets TabManagerHome as the first screen users see.
|
||||
//
|
||||
// This file is kept simple and clean since it only sets up the app foundation.
|
||||
//
|
||||
// All the actual functionality lives in the screens, services, and widgets folders.
|
||||
63
lib/models/tab_data.dart
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
class TabData {
|
||||
String id;
|
||||
String title;
|
||||
String url;
|
||||
String favicon;
|
||||
DateTime lastAccessed;
|
||||
bool isPinned;
|
||||
String type;
|
||||
int? visitCount;
|
||||
String? folder;
|
||||
|
||||
TabData({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.url,
|
||||
this.favicon = '',
|
||||
DateTime? lastAccessed,
|
||||
this.isPinned = false,
|
||||
this.type = 'tab',
|
||||
this.visitCount,
|
||||
this.folder,
|
||||
}) : lastAccessed = lastAccessed ?? DateTime.now();
|
||||
|
||||
factory TabData.fromJson(Map<String, dynamic> json) => TabData(
|
||||
id: json['id'].toString(),
|
||||
title: json['title'] ?? 'Untitled',
|
||||
url: json['url'] ?? '',
|
||||
favicon: json['favicon'] ?? json['favIconUrl'] ?? '',
|
||||
lastAccessed: json['lastAccessed'] != null
|
||||
? DateTime.parse(json['lastAccessed'])
|
||||
: (json['lastVisitTime'] != null
|
||||
? DateTime.parse(json['lastVisitTime'])
|
||||
: (json['dateAdded'] != null
|
||||
? DateTime.parse(json['dateAdded'])
|
||||
: (json['timestamp'] != null
|
||||
? DateTime.parse(json['timestamp'])
|
||||
: DateTime.now()))),
|
||||
isPinned: json['isPinned'] ?? false,
|
||||
type: json['type'] ?? 'tab',
|
||||
visitCount: json['visitCount'],
|
||||
folder: json['folder'],
|
||||
);
|
||||
}
|
||||
|
||||
// This is our data model that represents a single tab, bookmark, or history item.
|
||||
//
|
||||
// It holds all the information we need about each item: title, URL, favicon, when it was accessed, etc.
|
||||
//
|
||||
// Think of it as a blueprint or template for storing browser item information.
|
||||
//
|
||||
// Every tab, bookmark, or history entry in our app is stored as a TabData object.
|
||||
//
|
||||
// The constructor creates new TabData objects with required fields like id, title, and url.
|
||||
//
|
||||
// Optional fields have default values, like favicon defaults to empty string and isPinned defaults to false.
|
||||
//
|
||||
// The fromJson factory method converts JSON data from the browser API into a TabData object.
|
||||
//
|
||||
// It handles different date field names from different sources like lastAccessed, lastVisitTime, dateAdded, or timestamp.
|
||||
//
|
||||
// It also handles missing data gracefully by using default values with the ?? operator.
|
||||
//
|
||||
// This class is the foundation of our app since everything revolves around displaying and managing these items.
|
||||
393
lib/screens/tab_manager_home.dart
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'dart:html' as html;
|
||||
import '../models/tab_data.dart';
|
||||
import '../services/browser_api_service.dart';
|
||||
import '../services/extension_service.dart';
|
||||
import '../widgets/item_card.dart';
|
||||
import '../widgets/item_list_tile.dart';
|
||||
import '../widgets/search_bar.dart' as custom;
|
||||
import '../widgets/filter_chips.dart';
|
||||
import '../widgets/app_bar_actions.dart';
|
||||
|
||||
class TabManagerHome extends StatefulWidget {
|
||||
const TabManagerHome({super.key});
|
||||
|
||||
@override
|
||||
State<TabManagerHome> createState() => _TabManagerHomeState();
|
||||
}
|
||||
|
||||
class _TabManagerHomeState extends State<TabManagerHome> {
|
||||
// State variables
|
||||
List<TabData> allItems = [];
|
||||
List<TabData> filteredItems = [];
|
||||
final TextEditingController searchController = TextEditingController();
|
||||
bool isGridView = true;
|
||||
String sortBy = 'recent';
|
||||
String filterType = 'all';
|
||||
bool isLoading = true;
|
||||
bool extensionConnected = false;
|
||||
bool extensionMode = false;
|
||||
|
||||
// Services
|
||||
final BrowserApiService _browserApi = BrowserApiService();
|
||||
final ExtensionService _extensionService = ExtensionService();
|
||||
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupExtensionService();
|
||||
_loadAllData();
|
||||
searchController.addListener(_filterItems);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _setupExtensionService() {
|
||||
_extensionService.onTabsUpdate = (tabs) {
|
||||
setState(() {
|
||||
allItems = tabs;
|
||||
extensionConnected = true;
|
||||
extensionMode = true;
|
||||
_filterItems();
|
||||
});
|
||||
};
|
||||
|
||||
_extensionService.onTrackingStart = () {
|
||||
setState(() {
|
||||
extensionMode = true;
|
||||
extensionConnected = true;
|
||||
});
|
||||
};
|
||||
|
||||
_extensionService.onTrackingStop = () {
|
||||
setState(() {
|
||||
extensionMode = false;
|
||||
_loadAllData();
|
||||
});
|
||||
};
|
||||
|
||||
_extensionService.setupListener();
|
||||
}
|
||||
|
||||
void _startExtensionTracking() {
|
||||
_extensionService.startTracking();
|
||||
setState(() {
|
||||
extensionMode = true;
|
||||
allItems.clear();
|
||||
});
|
||||
}
|
||||
|
||||
void _stopExtensionTracking() {
|
||||
_extensionService.stopTracking();
|
||||
setState(() {
|
||||
extensionMode = false;
|
||||
});
|
||||
_loadAllData();
|
||||
}
|
||||
|
||||
Future<void> _loadAllData() async {
|
||||
if (extensionMode) return;
|
||||
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final tabs = await _browserApi.getTabs();
|
||||
final bookmarks = await _browserApi.getBookmarks();
|
||||
final history = await _browserApi.getHistory();
|
||||
|
||||
setState(() {
|
||||
allItems = [...tabs, ...bookmarks, ...history];
|
||||
_filterItems();
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
_checkExtensionConnection();
|
||||
} catch (e) {
|
||||
print('Error loading data: $e');
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _checkExtensionConnection() {
|
||||
_extensionService.getStatus();
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _filterItems() {
|
||||
final query = searchController.text.toLowerCase();
|
||||
setState(() {
|
||||
filteredItems = allItems.where((item) {
|
||||
if (filterType != 'all' && item.type != filterType.replaceAll('s', '')) {
|
||||
return false;
|
||||
}
|
||||
if (query.isNotEmpty) {
|
||||
return item.title.toLowerCase().contains(query) ||
|
||||
item.url.toLowerCase().contains(query);
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
_sortItems();
|
||||
});
|
||||
}
|
||||
|
||||
void _sortItems() {
|
||||
switch (sortBy) {
|
||||
case 'recent':
|
||||
filteredItems.sort((a, b) => b.lastAccessed.compareTo(a.lastAccessed));
|
||||
break;
|
||||
case 'title':
|
||||
filteredItems.sort((a, b) => a.title.compareTo(b.title));
|
||||
break;
|
||||
case 'url':
|
||||
filteredItems.sort((a, b) => a.url.compareTo(b.url));
|
||||
break;
|
||||
case 'visits':
|
||||
filteredItems.sort((a, b) =>
|
||||
(b.visitCount ?? 0).compareTo(a.visitCount ?? 0));
|
||||
break;
|
||||
}
|
||||
filteredItems.sort((a, b) => b.isPinned ? 1 : (a.isPinned ? -1 : 0));
|
||||
}
|
||||
|
||||
|
||||
Future<void> _openItem(TabData item) async {
|
||||
print('🔥 DEBUG: _openItem called with item: ${item.title}');
|
||||
print('🔥 DEBUG: Extension mode: $extensionMode');
|
||||
print('🔥 DEBUG: Item type: ${item.type}');
|
||||
print('🔥 DEBUG: Item URL: ${item.url}');
|
||||
|
||||
if (extensionMode) {
|
||||
print('🔥 DEBUG: Opening in new tab via html.window.open');
|
||||
html.window.open(item.url, '_blank');
|
||||
} else {
|
||||
if (item.type == 'tab') {
|
||||
print('🔥 DEBUG: Switching to existing tab with ID: ${item.id}');
|
||||
await _browserApi.switchToTab(item.id);
|
||||
} else {
|
||||
print('🔥 DEBUG: Opening new tab for bookmark/history');
|
||||
await _browserApi.openTab(item.url);
|
||||
}
|
||||
}
|
||||
print('🔥 DEBUG: _openItem completed');
|
||||
}
|
||||
|
||||
Future<void> _deleteItem(TabData item) async {
|
||||
if (extensionMode) return;
|
||||
|
||||
if (item.type == 'tab') {
|
||||
await _browserApi.closeTab(item.id);
|
||||
} else if (item.type == 'bookmark') {
|
||||
await _browserApi.removeBookmark(item.id);
|
||||
}
|
||||
|
||||
await _loadAllData();
|
||||
}
|
||||
|
||||
Future<void> _togglePin(TabData item) async {
|
||||
if (extensionMode) return;
|
||||
|
||||
if (item.type == 'tab') {
|
||||
await _browserApi.togglePinTab(item.id, !item.isPinned);
|
||||
await _loadAllData();
|
||||
}
|
||||
}
|
||||
|
||||
String _getIcon(TabData item) {
|
||||
if (item.favicon.isNotEmpty && !item.favicon.contains('data:')) {
|
||||
return '🌐';
|
||||
}
|
||||
|
||||
switch (item.type) {
|
||||
case 'tab':
|
||||
return '🔖';
|
||||
case 'bookmark':
|
||||
return '⭐';
|
||||
case 'history':
|
||||
return '🕒';
|
||||
default:
|
||||
return '🌐';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Row(
|
||||
children: [
|
||||
const Text('Browser Tab Manager'),
|
||||
if (extensionMode) ...[
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'TRACKING',
|
||||
style: TextStyle(fontSize: 10, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
AppBarActions(
|
||||
extensionMode: extensionMode,
|
||||
extensionConnected: extensionConnected,
|
||||
isGridView: isGridView,
|
||||
sortBy: sortBy,
|
||||
onStartTracking: _startExtensionTracking,
|
||||
onStopTracking: _stopExtensionTracking,
|
||||
onRefresh: _loadAllData,
|
||||
onToggleView: () {
|
||||
setState(() {
|
||||
isGridView = !isGridView;
|
||||
});
|
||||
},
|
||||
onSortChanged: (value) {
|
||||
setState(() {
|
||||
sortBy = value;
|
||||
_filterItems();
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
custom.SearchBar(
|
||||
controller: searchController,
|
||||
extensionMode: extensionMode,
|
||||
onClear: () {
|
||||
searchController.clear();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (!extensionMode)
|
||||
FilterChips(
|
||||
allItems: allItems,
|
||||
filterType: filterType,
|
||||
onFilterChanged: (type) {
|
||||
setState(() {
|
||||
filterType = type;
|
||||
_filterItems();
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: filteredItems.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
extensionMode ? Icons.track_changes : Icons.search_off,
|
||||
size: 80,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
extensionMode
|
||||
? 'Waiting for tabs...'
|
||||
: 'No items found',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
extensionMode
|
||||
? 'Open some tabs to see them here'
|
||||
: 'Try a different search or filter',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: isGridView
|
||||
? GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 300,
|
||||
childAspectRatio: 1.3,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
),
|
||||
itemCount: filteredItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
return ItemCard(
|
||||
item: filteredItems[index],
|
||||
icon: _getIcon(filteredItems[index]),
|
||||
onTap: () => _openItem(filteredItems[index]),
|
||||
onDelete: () => _deleteItem(filteredItems[index]),
|
||||
onTogglePin: () => _togglePin(filteredItems[index]),
|
||||
extensionMode: extensionMode,
|
||||
);
|
||||
},
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: filteredItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
return ItemListTile(
|
||||
item: filteredItems[index],
|
||||
icon: _getIcon(filteredItems[index]),
|
||||
onTap: () => _openItem(filteredItems[index]),
|
||||
onDelete: () => _deleteItem(filteredItems[index]),
|
||||
onTogglePin: () => _togglePin(filteredItems[index]),
|
||||
extensionMode: extensionMode,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// This is the main screen of our app where users see and manage their browser tabs.
|
||||
//
|
||||
// It is a StatefulWidget which means it holds data that can change over time.
|
||||
//
|
||||
// The state includes lists of tabs, search text, view preferences, and loading status.
|
||||
//
|
||||
// It communicates with two services: BrowserApiService for browser data and ExtensionService for real-time tracking.
|
||||
//
|
||||
// When the screen loads, it fetches all tabs, bookmarks, and history from the browser.
|
||||
//
|
||||
// Users can search items, filter by type, sort by different criteria, and switch between grid and list views.
|
||||
//
|
||||
// Extension mode allows real-time tracking of browser tabs as they open and close.
|
||||
//
|
||||
// The build method creates the UI with an app bar, search box, filters, and either a grid or list of items.
|
||||
//
|
||||
// User actions like opening, deleting, or pinning items trigger methods that update the browser and refresh the display.
|
||||
//
|
||||
// This is the central hub that coordinates all the app functionality and user interactions.
|
||||
113
lib/services/browser_api_service.dart
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import 'dart:html' as html;
|
||||
import 'dart:convert';
|
||||
import 'dart:js_util' as js_util;
|
||||
import '../models/tab_data.dart';
|
||||
|
||||
class BrowserApiService {
|
||||
Future<List<TabData>> getTabs() async {
|
||||
try {
|
||||
final result = await _callBrowserAPI('getTabs');
|
||||
if (result != null) {
|
||||
final List<dynamic> data = json.decode(result);
|
||||
return data.map((item) {
|
||||
item['type'] = 'tab';
|
||||
return TabData.fromJson(item);
|
||||
}).toList();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error getting tabs: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<List<TabData>> getBookmarks() async {
|
||||
try {
|
||||
final result = await _callBrowserAPI('getBookmarks');
|
||||
if (result != null) {
|
||||
final List<dynamic> data = json.decode(result);
|
||||
return data.map((item) {
|
||||
item['type'] = 'bookmark';
|
||||
return TabData.fromJson(item);
|
||||
}).toList();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error getting bookmarks: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<List<TabData>> getHistory() async {
|
||||
try {
|
||||
final result = await _callBrowserAPI('getHistory', [100]);
|
||||
if (result != null) {
|
||||
final List<dynamic> data = json.decode(result);
|
||||
return data.map((item) {
|
||||
item['type'] = 'history';
|
||||
return TabData.fromJson(item);
|
||||
}).toList();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error getting history: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<void> switchToTab(String tabId) async {
|
||||
await _callBrowserAPI('switchToTab', [tabId]);
|
||||
}
|
||||
|
||||
Future<void> openTab(String url) async {
|
||||
await _callBrowserAPI('openTab', [url]);
|
||||
}
|
||||
|
||||
Future<void> closeTab(String tabId) async {
|
||||
await _callBrowserAPI('closeTab', [tabId]);
|
||||
}
|
||||
|
||||
Future<void> removeBookmark(String bookmarkId) async {
|
||||
await _callBrowserAPI('removeBookmark', [bookmarkId]);
|
||||
}
|
||||
|
||||
Future<void> togglePinTab(String tabId, bool pin) async {
|
||||
await _callBrowserAPI('togglePinTab', [tabId, pin]);
|
||||
}
|
||||
|
||||
Future<String?> _callBrowserAPI(String method, [List<dynamic>? args]) async {
|
||||
try {
|
||||
if (!js_util.hasProperty(html.window, 'BrowserAPI')) {
|
||||
print('BrowserAPI not found - running in development mode');
|
||||
return null;
|
||||
}
|
||||
|
||||
final browserAPI = js_util.getProperty(html.window, 'BrowserAPI');
|
||||
final function = js_util.getProperty(browserAPI, method);
|
||||
|
||||
final result = args == null
|
||||
? await js_util.promiseToFuture(js_util.callMethod(function, 'call', [browserAPI]))
|
||||
: await js_util.promiseToFuture(js_util.callMethod(function, 'call', [browserAPI, ...args]));
|
||||
|
||||
return json.encode(result);
|
||||
} catch (e) {
|
||||
print('Error calling $method: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This service acts as a bridge between our Flutter app and the browser extension.
|
||||
//
|
||||
// It provides clean methods to interact with browser APIs for tabs, bookmarks, and history.
|
||||
//
|
||||
// The getTabs, getBookmarks, and getHistory methods fetch data from the browser and convert it to TabData objects.
|
||||
//
|
||||
// Action methods like switchToTab, openTab, closeTab allow us to control browser tabs programmatically.
|
||||
//
|
||||
// The private _callBrowserAPI method handles the low-level JavaScript communication.
|
||||
//
|
||||
// It uses dart:js_util to call JavaScript functions exposed by the browser extension through window.BrowserAPI.
|
||||
//
|
||||
// All methods handle errors gracefully and return empty results if the API is unavailable.
|
||||
//
|
||||
// This abstraction keeps browser-specific code separate from our UI logic making the app easier to maintain.
|
||||
//
|
||||
// When running in development without the extension, it prints debug messages instead of crashing.
|
||||
80
lib/services/extension_service.dart
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import 'dart:html' as html;
|
||||
import '../models/tab_data.dart';
|
||||
|
||||
class ExtensionService {
|
||||
Function(List<TabData>)? onTabsUpdate;
|
||||
Function()? onTrackingStart;
|
||||
Function()? onTrackingStop;
|
||||
|
||||
void setupListener() {
|
||||
html.window.onMessage.listen((event) {
|
||||
final data = event.data;
|
||||
if (data is Map && data['source'] == 'tab-tracker-extension') {
|
||||
_handleExtensionMessage(Map<String, dynamic>.from(data));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleExtensionMessage(Map<String, dynamic> data) {
|
||||
print('Received from extension: $data');
|
||||
|
||||
if (data['action'] == 'updateTabs') {
|
||||
final extensionTabs = (data['tabs'] as List).map((tab) {
|
||||
tab['type'] = 'tab';
|
||||
return TabData.fromJson(tab);
|
||||
}).toList();
|
||||
|
||||
onTabsUpdate?.call(extensionTabs);
|
||||
|
||||
} else if (data['action'] == 'clear') {
|
||||
onTrackingStop?.call();
|
||||
|
||||
} else if (data['response'] != null) {
|
||||
final response = data['response'];
|
||||
if (response['status'] == 'started') {
|
||||
onTrackingStart?.call();
|
||||
} else if (response['status'] == 'stopped') {
|
||||
onTrackingStop?.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void sendMessage(Map<String, dynamic> message) {
|
||||
html.window.postMessage({
|
||||
'source': 'tab-tracker-webapp',
|
||||
...message
|
||||
}, '*');
|
||||
}
|
||||
|
||||
void startTracking() {
|
||||
sendMessage({'action': 'startTracking'});
|
||||
}
|
||||
|
||||
void stopTracking() {
|
||||
sendMessage({'action': 'stopTracking'});
|
||||
}
|
||||
|
||||
void getStatus() {
|
||||
sendMessage({'action': 'getStatus'});
|
||||
}
|
||||
}
|
||||
|
||||
// This service handles real-time communication with the browser extension for live tab tracking.
|
||||
//
|
||||
// It uses callback functions that get triggered when the extension sends updates.
|
||||
//
|
||||
// The setupListener method listens for messages from the browser extension through window.postMessage.
|
||||
//
|
||||
// When messages arrive, it filters them to only process ones from our specific extension.
|
||||
//
|
||||
// The _handleExtensionMessage method processes different types of messages like tab updates and tracking status changes.
|
||||
//
|
||||
// It converts raw tab data from the extension into our TabData objects.
|
||||
//
|
||||
// The sendMessage method sends commands back to the extension with a source identifier.
|
||||
//
|
||||
// Methods like startTracking, stopTracking, and getStatus provide a clean interface to control the extension.
|
||||
//
|
||||
// This enables our app to show live updates as users open and close tabs in their browser.
|
||||
//
|
||||
// The callback pattern allows the UI to react immediately when extension data arrives.
|
||||
96
lib/utils/helpers.dart
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../models/tab_data.dart';
|
||||
|
||||
class Helpers {
|
||||
static String getItemIcon(TabData item) {
|
||||
if (item.favicon.isNotEmpty && !item.favicon.contains('data:')) {
|
||||
return '🌐';
|
||||
}
|
||||
|
||||
switch (item.type) {
|
||||
case 'tab':
|
||||
return '🔖';
|
||||
case 'bookmark':
|
||||
return '⭐';
|
||||
case 'history':
|
||||
return '🕒';
|
||||
default:
|
||||
return '🌐';
|
||||
}
|
||||
}
|
||||
|
||||
static Color getTypeColor(BuildContext context, String type) {
|
||||
switch (type) {
|
||||
case 'tab':
|
||||
return Colors.blue;
|
||||
case 'bookmark':
|
||||
return Colors.orange;
|
||||
case 'history':
|
||||
return Colors.purple;
|
||||
default:
|
||||
return Theme.of(context).colorScheme.primary;
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, int> getItemStats(List<TabData> items) {
|
||||
return {
|
||||
'tabs': items.where((i) => i.type == 'tab').length,
|
||||
'bookmarks': items.where((i) => i.type == 'bookmark').length,
|
||||
'history': items.where((i) => i.type == 'history').length,
|
||||
};
|
||||
}
|
||||
|
||||
static List<TabData> filterItems(
|
||||
List<TabData> items,
|
||||
String query,
|
||||
String filterType,
|
||||
) {
|
||||
return items.where((item) {
|
||||
if (filterType != 'all' && item.type != filterType.replaceAll('s', '')) {
|
||||
return false;
|
||||
}
|
||||
if (query.isNotEmpty) {
|
||||
return item.title.toLowerCase().contains(query.toLowerCase()) ||
|
||||
item.url.toLowerCase().contains(query.toLowerCase());
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
static void sortItems(List<TabData> items, String sortBy) {
|
||||
switch (sortBy) {
|
||||
case 'recent':
|
||||
items.sort((a, b) => b.lastAccessed.compareTo(a.lastAccessed));
|
||||
break;
|
||||
case 'title':
|
||||
items.sort((a, b) => a.title.compareTo(b.title));
|
||||
break;
|
||||
case 'url':
|
||||
items.sort((a, b) => a.url.compareTo(b.url));
|
||||
break;
|
||||
case 'visits':
|
||||
items.sort((a, b) => (b.visitCount ?? 0).compareTo(a.visitCount ?? 0));
|
||||
break;
|
||||
}
|
||||
// Keep pinned items at top
|
||||
items.sort((a, b) => b.isPinned ? 1 : (a.isPinned ? -1 : 0));
|
||||
}
|
||||
}
|
||||
|
||||
// This is a utility class containing reusable helper functions used throughout the app.
|
||||
//
|
||||
// The getItemIcon method returns the appropriate emoji icon for each item type.
|
||||
//
|
||||
// The getTypeColor method assigns consistent colors to tabs, bookmarks, and history items.
|
||||
//
|
||||
// The getItemStats method calculates how many items of each type exist in a list.
|
||||
//
|
||||
// The filterItems method filters a list based on search query and filter type selection.
|
||||
//
|
||||
// The sortItems method sorts items by different criteria like recent, title, url, or visit count.
|
||||
//
|
||||
// All methods are static so they can be called without creating an instance of the class.
|
||||
//
|
||||
// This keeps common logic in one place rather than duplicating it across multiple files.
|
||||
//
|
||||
// Usin
|
||||
90
lib/widgets/app_bar_actions.dart
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppBarActions extends StatelessWidget {
|
||||
final bool extensionMode;
|
||||
final bool extensionConnected;
|
||||
final bool isGridView;
|
||||
final String sortBy;
|
||||
final VoidCallback onStartTracking;
|
||||
final VoidCallback onStopTracking;
|
||||
final VoidCallback onRefresh;
|
||||
final VoidCallback onToggleView;
|
||||
final Function(String) onSortChanged;
|
||||
|
||||
const AppBarActions({
|
||||
super.key,
|
||||
required this.extensionMode,
|
||||
required this.extensionConnected,
|
||||
required this.isGridView,
|
||||
required this.sortBy,
|
||||
required this.onStartTracking,
|
||||
required this.onStopTracking,
|
||||
required this.onRefresh,
|
||||
required this.onToggleView,
|
||||
required this.onSortChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (extensionMode)
|
||||
TextButton.icon(
|
||||
onPressed: onStopTracking,
|
||||
icon: const Icon(Icons.stop, color: Colors.white),
|
||||
label: const Text('Stop', style: TextStyle(color: Colors.white)),
|
||||
)
|
||||
else
|
||||
TextButton.icon(
|
||||
onPressed: extensionConnected ? onStartTracking : null,
|
||||
icon: const Icon(Icons.play_arrow, color: Colors.white),
|
||||
label: const Text('Track Tabs', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: extensionMode ? null : onRefresh,
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(isGridView ? Icons.view_list : Icons.grid_view),
|
||||
onPressed: onToggleView,
|
||||
tooltip: isGridView ? 'List View' : 'Grid View',
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.sort),
|
||||
tooltip: 'Sort by',
|
||||
onSelected: onSortChanged,
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(value: 'recent', child: Text('Recent')),
|
||||
const PopupMenuItem(value: 'title', child: Text('Title')),
|
||||
const PopupMenuItem(value: 'url', child: Text('URL')),
|
||||
const PopupMenuItem(value: 'visits', child: Text('Most Visited')),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// This widget displays all the action buttons in the top app bar.
|
||||
//
|
||||
// It is a stateless widget that receives all its data and callbacks from the parent.
|
||||
//
|
||||
// The Track Tabs button starts or stops extension tracking mode.
|
||||
//
|
||||
// The button changes from Track Tabs to Stop depending on whether extension mode is active.
|
||||
//
|
||||
// The refresh button reloads all data from the browser and is disabled during extension mode.
|
||||
//
|
||||
// The view toggle button switches between grid and list display layouts.
|
||||
//
|
||||
// The sort menu button opens a dropdown with sorting options like Recent, Title, URL, and Most Visited.
|
||||
//
|
||||
// All buttons trigger callbacks passed from the parent rather than handling logic themselves.
|
||||
//
|
||||
// This separation keeps the UI code clean and maintains a single source of truth in the parent widget.
|
||||
//
|
||||
// Extracting these actions into a separate widget makes the main screen code easier to read and maintain.
|
||||
74
lib/widgets/filter_chips.dart
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../models/tab_data.dart';
|
||||
|
||||
class FilterChips extends StatelessWidget {
|
||||
final List<TabData> allItems;
|
||||
final String filterType;
|
||||
final Function(String) onFilterChanged;
|
||||
|
||||
const FilterChips({
|
||||
super.key,
|
||||
required this.allItems,
|
||||
required this.filterType,
|
||||
required this.onFilterChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stats = {
|
||||
'tabs': allItems.where((i) => i.type == 'tab').length,
|
||||
'bookmarks': allItems.where((i) => i.type == 'bookmark').length,
|
||||
'history': allItems.where((i) => i.type == 'history').length,
|
||||
};
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
FilterChip(
|
||||
label: Text('All (${allItems.length})'),
|
||||
selected: filterType == 'all',
|
||||
onSelected: (selected) => onFilterChanged('all'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
label: Text('Tabs (${stats['tabs']})'),
|
||||
selected: filterType == 'tabs',
|
||||
onSelected: (selected) => onFilterChanged('tabs'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
label: Text('Bookmarks (${stats['bookmarks']})'),
|
||||
selected: filterType == 'bookmarks',
|
||||
onSelected: (selected) => onFilterChanged('bookmarks'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
label: Text('History (${stats['history']})'),
|
||||
selected: filterType == 'history',
|
||||
onSelected: (selected) => onFilterChanged('history'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// This widget displays a row of filter chips that let users filter items by type.
|
||||
//
|
||||
// It shows four chips: All, Tabs, Bookmarks, and History with their respective counts.
|
||||
//
|
||||
// The counts are calculated dynamically from the allItems list passed from the parent.
|
||||
//
|
||||
// Each chip shows how many items of that type exist in parentheses.
|
||||
//
|
||||
// The selected chip is highlighted to show which filter is currently active.
|
||||
//
|
||||
// Clicking a chip triggers the onFilterChanged callback to update the parent's filter state.
|
||||
//
|
||||
// The chips are wrapped in a horizontal scroll view so they don't overflow on small screens.
|
||||
//
|
||||
// This component is only shown when not in extension mode since extension mode only tracks tabs.
|
||||
//
|
||||
// Extracting the filter chips into a separate widget keeps the main screen cleaner and more modular.
|
||||
173
lib/widgets/item_card.dart
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../models/tab_data.dart';
|
||||
|
||||
class ItemCard extends StatelessWidget {
|
||||
final TabData item;
|
||||
final String icon;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onTogglePin;
|
||||
final bool extensionMode;
|
||||
|
||||
const ItemCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
required this.onDelete,
|
||||
required this.onTogglePin,
|
||||
this.extensionMode = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 60,
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Center(
|
||||
child: item.favicon.isNotEmpty && item.favicon.startsWith('http')
|
||||
? Image.network(
|
||||
item.favicon,
|
||||
width: 32,
|
||||
height: 32,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Text(icon, style: const TextStyle(fontSize: 32));
|
||||
},
|
||||
)
|
||||
: Text(icon, style: const TextStyle(fontSize: 32)),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (item.isPinned)
|
||||
Icon(
|
||||
Icons.push_pin,
|
||||
size: 14,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
if (item.isPinned) const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getTypeColor(context),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
item.type.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.url,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (item.visitCount != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${item.visitCount} visits',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!extensionMode)
|
||||
ButtonBar(
|
||||
alignment: MainAxisAlignment.end,
|
||||
buttonPadding: EdgeInsets.zero,
|
||||
children: [
|
||||
if (item.type == 'tab')
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
item.isPinned ? Icons.push_pin : Icons.push_pin_outlined,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: onTogglePin,
|
||||
tooltip: item.isPinned ? 'Unpin' : 'Pin',
|
||||
),
|
||||
if (item.type != 'history')
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
onPressed: onDelete,
|
||||
tooltip: 'Delete',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getTypeColor(BuildContext context) {
|
||||
switch (item.type) {
|
||||
case 'tab':
|
||||
return Colors.blue;
|
||||
case 'bookmark':
|
||||
return Colors.orange;
|
||||
case 'history':
|
||||
return Colors.purple;
|
||||
default:
|
||||
return Theme.of(context).colorScheme.primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// This widget displays a single tab, bookmark, or history item as a card in the grid view.
|
||||
//
|
||||
// It is a stateless widget that receives all its data from the parent component.
|
||||
//
|
||||
// The card shows a favicon or emoji icon at the top in a colored header section.
|
||||
//
|
||||
// Below that it displays a type badge, the item title, URL, and optional visit count.
|
||||
//
|
||||
// If the item is pinned, a pin icon appears next to the type badge.
|
||||
//
|
||||
// The card is tappable and triggers the onTap callback to open the item.
|
||||
//
|
||||
// Action buttons at the bottom allow pinning tabs and deleting tabs or bookmarks.
|
||||
//
|
||||
// These action buttons are hidden in extension mode since you cannot modify tracked tabs.
|
||||
//
|
||||
// History items do not show a delete button since browser history cannot be removed this way.
|
||||
//
|
||||
// The getTypeColor method assigns different colors to the type badge based on item type.
|
||||
//
|
||||
// This card design provides a clean visual representation of browser items in grid layout.
|
||||
145
lib/widgets/item_list_tile.dart
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../models/tab_data.dart';
|
||||
|
||||
class ItemListTile extends StatelessWidget {
|
||||
final TabData item;
|
||||
final String icon;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onTogglePin;
|
||||
final bool extensionMode;
|
||||
|
||||
const ItemListTile({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
required this.onDelete,
|
||||
required this.onTogglePin,
|
||||
this.extensionMode = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
child: item.favicon.isNotEmpty && item.favicon.startsWith('http')
|
||||
? Image.network(
|
||||
item.favicon,
|
||||
width: 20,
|
||||
height: 20,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Text(icon, style: const TextStyle(fontSize: 20));
|
||||
},
|
||||
)
|
||||
: Text(icon, style: const TextStyle(fontSize: 20)),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
if (item.isPinned) ...[
|
||||
Icon(
|
||||
Icons.push_pin,
|
||||
size: 14,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _getTypeColor(context),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
item.type.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.url,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (item.visitCount != null)
|
||||
Text('${item.visitCount} visits',
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
trailing: extensionMode ? null : Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (item.type == 'tab')
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
item.isPinned ? Icons.push_pin : Icons.push_pin_outlined,
|
||||
),
|
||||
onPressed: onTogglePin,
|
||||
tooltip: item.isPinned ? 'Unpin' : 'Pin',
|
||||
),
|
||||
if (item.type != 'history')
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: onDelete,
|
||||
tooltip: 'Delete',
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getTypeColor(BuildContext context) {
|
||||
switch (item.type) {
|
||||
case 'tab':
|
||||
return Colors.blue;
|
||||
case 'bookmark':
|
||||
return Colors.orange;
|
||||
case 'history':
|
||||
return Colors.purple;
|
||||
default:
|
||||
return Theme.of(context).colorScheme.primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// This widget displays a single tab, bookmark, or history item as a list tile in the list view.
|
||||
//
|
||||
// It is the list view alternative to ItemCard and shows the same information in a horizontal layout.
|
||||
//
|
||||
// A circular avatar on the left shows the favicon or emoji icon for the item.
|
||||
//
|
||||
// The title row displays an optional pin icon, type badge, and the item title.
|
||||
//
|
||||
// The subtitle shows the URL and optional visit count below the title.
|
||||
//
|
||||
// Action buttons on the right allow pinning tabs and deleting tabs or bookmarks.
|
||||
//
|
||||
// These buttons are hidden in extension mode and history items do not show delete buttons.
|
||||
//
|
||||
// Tapping anywhere on the tile triggers the onTap callback to open the item.
|
||||
//
|
||||
// The list tile format is more compact and shows more items on screen compared to grid cards.
|
||||
//
|
||||
// Users can toggle between this list view and the grid card view using the view toggle button.
|
||||
//
|
||||
// This provides flexibility in how users prefer to browse their browser items.
|
||||
57
lib/widgets/search_bar.dart
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class SearchBar extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final bool extensionMode;
|
||||
final VoidCallback onClear;
|
||||
|
||||
const SearchBar({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.extensionMode,
|
||||
required this.onClear,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: extensionMode
|
||||
? 'Search tracked tabs...'
|
||||
: 'Search tabs, bookmarks, and history...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: onClear,
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// This widget provides the search input field for filtering tabs, bookmarks, and history.
|
||||
//
|
||||
// It is a stateless widget that receives a TextEditingController from the parent to manage the text input.
|
||||
//
|
||||
// The hint text changes based on whether extension mode is active or not.
|
||||
//
|
||||
// In extension mode it says Search tracked tabs and in normal mode it says Search tabs, bookmarks, and history.
|
||||
//
|
||||
// A search icon appears on the left side of the input field.
|
||||
//
|
||||
// When the user types text, a clear button appears on the right side to quickly empty the search.
|
||||
//
|
||||
// Clicking the clear button triggers the onClear callback which clears the controller text.
|
||||
//
|
||||
// The text field has rounded corners for a modern appearance.
|
||||
//
|
||||
// As the user types, the parent widget filters the displayed items in real-time.
|
||||
//
|
||||
// This provides a fast and intuitive way to find specific tabs or bookmarks in large lists.
|
||||
46
nginx.conf
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
server {
|
||||
listen 80;
|
||||
# Listen for incoming connections on port 80
|
||||
|
||||
server_name _;
|
||||
# Accept requests from any domain name
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
# Set the directory where our app files are located
|
||||
|
||||
index index.html;
|
||||
# Serve index.html as the default page
|
||||
|
||||
# Enable gzip
|
||||
gzip on;
|
||||
# Turn on compression for files
|
||||
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
# Specify which file types to compress
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
# Try to find the requested file, if not found serve index.html
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
# Match files with these extensions
|
||||
|
||||
expires 1y;
|
||||
# Set files to expire in 1 year
|
||||
|
||||
add_header Cache-Control "public, immutable";
|
||||
# Tell browsers these files never change, cache them permanently
|
||||
}
|
||||
}
|
||||
|
||||
# It compresses files to save bandwidth and caches assets.
|
||||
|
||||
# The try_files rule ensures Flutters client-side routing works correctly.
|
||||
|
||||
# Finds the right files and sends them to the browser
|
||||
|
||||
# Compresses files to make them download faster
|
||||
|
||||
# Tells browsers to cache files so they load instantly on repeat visits
|
||||
111
package.sh
Executable file
|
|
@ -0,0 +1,111 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(pwd)"
|
||||
|
||||
echo "📦 Packaging Browser Tab Manager..."
|
||||
echo "📂 Working from: $(pwd)"
|
||||
|
||||
if [ ! -f "Dockerfile" ] || [ ! -f "pubspec.yaml" ]; then
|
||||
echo "❌ Error: Not in project root directory!"
|
||||
echo " Expected files: Dockerfile, pubspec.yaml"
|
||||
echo " Current directory: $(pwd)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf ./dist
|
||||
mkdir -p ./dist/browser-tab-manager
|
||||
|
||||
echo "📋 Copying files..."
|
||||
cp ./Dockerfile ./dist/browser-tab-manager/
|
||||
cp ./nginx.conf ./dist/browser-tab-manager/
|
||||
cp ./pubspec.yaml ./dist/browser-tab-manager/
|
||||
cp ./pubspec.lock ./dist/browser-tab-manager/ 2>/dev/null || true
|
||||
cp -r ./lib ./dist/browser-tab-manager/
|
||||
mkdir -p ./dist/browser-tab-manager/web
|
||||
cp ./web/manifest.json ./dist/browser-tab-manager/web/ 2>/dev/null || true
|
||||
|
||||
cat > dist/browser-tab-manager/deploy.sh << 'DEPLOY_EOF'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Deploying Browser Tab Manager on Server..."
|
||||
|
||||
if [ ! -f "Dockerfile" ]; then
|
||||
echo "❌ Error: Dockerfile not found. Are you in the right directory?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p web
|
||||
if [ ! -f "web/manifest.json" ]; then
|
||||
cat > web/manifest.json << 'EOF'
|
||||
{
|
||||
"name": "Browser Tab Manager",
|
||||
"short_name": "TabManager",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "Manage browser tabs in a grid view",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "✅ Project structure ready"
|
||||
|
||||
echo "🔨 Building Podman container..."
|
||||
podman build -t browser-tab-manager .
|
||||
|
||||
podman stop browser-tab-manager 2>/dev/null || true
|
||||
podman rm browser-tab-manager 2>/dev/null || true
|
||||
|
||||
echo "🚢 Starting container..."
|
||||
podman run -d \
|
||||
--name browser-tab-manager \
|
||||
-p 8081:80 \
|
||||
--restart unless-stopped \
|
||||
browser-tab-manager
|
||||
|
||||
echo "✅ Container started successfully!"
|
||||
echo ""
|
||||
|
||||
echo "🌐 Your Browser Tab Manager is now running at:"
|
||||
SERVER_IP=$(hostname -I | awk '{print $1}' 2>/dev/null || echo "your-server-ip")
|
||||
echo " http://${SERVER_IP}:8081"
|
||||
echo ""
|
||||
echo "📋 Useful commands:"
|
||||
echo " View logs: podman logs -f browser-tab-manager"
|
||||
echo " Stop: podman stop browser-tab-manager"
|
||||
echo " Start: podman start browser-tab-manager"
|
||||
echo " Restart: podman restart browser-tab-manager"
|
||||
echo " Remove: podman rm -f browser-tab-manager"
|
||||
DEPLOY_EOF
|
||||
|
||||
chmod +x dist/browser-tab-manager/deploy.sh
|
||||
|
||||
echo "🗜️ Creating tarball..."
|
||||
cd dist
|
||||
tar -czf browser-tab-manager.tar.gz browser-tab-manager/
|
||||
cd ..
|
||||
|
||||
echo "✅ Package created: dist/browser-tab-manager.tar.gz"
|
||||
echo ""
|
||||
echo "📤 To deploy on server:"
|
||||
echo " 1. scp dist/browser-tab-manager.tar.gz root@188.245.68.196:/root/"
|
||||
echo " 2. ssh root@188.245.68.196"
|
||||
echo " 3. cd /root && tar -xzf browser-tab-manager.tar.gz"
|
||||
echo " 4. cd browser-tab-manager && ./deploy.sh"
|
||||
echo ""
|
||||
echo "🌐 available at: https://tab.caesargaming.org"
|
||||
|
||||
|
||||
# This script packages the app for server deployment.
|
||||
|
||||
# Copies all necessary files to a dist folder.
|
||||
|
||||
# Creates a deploy.sh script that will run on the server.
|
||||
|
||||
# Compresses everything into a tar.gz file for easy transfer.
|
||||
213
pubspec.lock
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
web:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
sdks:
|
||||
dart: ">=3.8.0-0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
31
pubspec.yaml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
name: browser_tab_manager
|
||||
|
||||
description: A Flutter web app for managing browser tabs, bookmarks, and history
|
||||
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
web: ^1.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
flutter_lints: ^3.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
# Lists dependencies the app needs to run.
|
||||
|
||||
# Lists dev dependencies for development tools.
|
||||
|
||||
# Enables Material Design icons and widgets for the UI.
|
||||
0
requirements.txt
Normal file
468
setup-extension.sh
Executable file
|
|
@ -0,0 +1,468 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
# Exit if any command fails
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
# Get the directory where this script is located and store it in SCRIPT_DIR
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
# Change to that directory
|
||||
|
||||
echo "🚀 Building Browser Tab Manager Extension..."
|
||||
|
||||
# Build using the existing Dockerfile which already has Flutter set up properly
|
||||
echo "🔨 Building Flutter app using existing container..."
|
||||
|
||||
# Build the image if it doesn't exist or force rebuild
|
||||
podman build -t browser-tab-manager-builder -f - . << 'EOF'
|
||||
# "podman build" creates a container image
|
||||
# "-t browser-tab-manager-builder" names the image
|
||||
# "-f -" means read the Dockerfile from stdin (the following text)
|
||||
# "." means use current directory as build context
|
||||
# "<< 'EOF'" starts a here document - everything until 'EOF' is the Dockerfile
|
||||
|
||||
FROM debian:bullseye-slim
|
||||
# Start from a base image: Debian Linux (bullseye version, slim variant)
|
||||
# "FROM" is always the first instruction in a Dockerfile
|
||||
# "debian:bullseye-slim" is a minimal Debian 11 operating system image
|
||||
|
||||
# Install dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
# "RUN" executes commands inside the container during build
|
||||
# "apt-get update" refreshes the list of available software packages
|
||||
# "apt-get install -y" installs packages (-y means yes to all prompts)
|
||||
# "\" continues the command on the next line
|
||||
|
||||
curl \
|
||||
# Tool for downloading files from the internet
|
||||
|
||||
git \
|
||||
# Version control system, needed to download Flutter
|
||||
|
||||
unzip \
|
||||
# Tool to extract zip files
|
||||
|
||||
xz-utils \
|
||||
# Tool to extract .xz compressed files
|
||||
|
||||
zip \
|
||||
# Tool to create zip files
|
||||
|
||||
libglu1-mesa \
|
||||
# Graphics library needed by Flutter
|
||||
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Clean up package lists to make image smaller
|
||||
# "/var/lib/apt/lists/*" contains downloaded package info we no longer need
|
||||
|
||||
# Create a non-root user
|
||||
RUN useradd -m -u 1000 -s /bin/bash flutter && \
|
||||
# "useradd" creates a new user account
|
||||
# "-m" creates a home directory for the user
|
||||
# "-u 1000" sets user ID to 1000 (matches most host system users)
|
||||
# "-s /bin/bash" sets bash as the default shell
|
||||
# "flutter" is the username
|
||||
# "&&" means run next command if this succeeds
|
||||
|
||||
mkdir -p /opt/flutter && \
|
||||
# Create directory to install Flutter
|
||||
# "/opt" is conventional location for optional software
|
||||
|
||||
chown -R flutter:flutter /opt/flutter
|
||||
# Change ownership of /opt/flutter to the flutter user
|
||||
# "-R" means recursive (all files and subfolders)
|
||||
# "flutter:flutter" means user:group
|
||||
|
||||
# Switch to non-root user for Flutter installation
|
||||
USER flutter
|
||||
# All subsequent commands run as the "flutter" user instead of "root"
|
||||
# This is a security best practice
|
||||
|
||||
# Install Flutter as non-root user
|
||||
RUN git clone https://github.com/flutter/flutter.git /opt/flutter -b stable --depth 1
|
||||
# "git clone" downloads the Flutter repository
|
||||
# URL: https://github.com/flutter/flutter.git
|
||||
# Destination: /opt/flutter
|
||||
# "-b stable" checks out the stable branch (most reliable version)
|
||||
# "--depth 1" only downloads latest commit (saves space and time)
|
||||
|
||||
ENV PATH="/opt/flutter/bin:${PATH}"
|
||||
# "ENV" sets environment variables
|
||||
# Add Flutter's bin directory to PATH so Flutter commands can be run from anywhere
|
||||
# "${PATH}" includes the existing PATH value
|
||||
|
||||
# Pre-download Flutter web tools
|
||||
RUN flutter precache --web
|
||||
# Download the necessary files for Flutter web development
|
||||
# "--web" limits download to just web platform tools
|
||||
# This speeds up future builds
|
||||
|
||||
# Configure Flutter
|
||||
RUN flutter config --no-analytics && \
|
||||
# Configure Flutter settings:
|
||||
# "--no-analytics" disables anonymous usage statistics
|
||||
# "&&" run next command
|
||||
|
||||
flutter config --enable-web
|
||||
# Enable web platform support in Flutter
|
||||
|
||||
WORKDIR /home/flutter/app
|
||||
# "WORKDIR" sets the working directory for subsequent commands
|
||||
# Any files added or commands run will be relative to this directory
|
||||
EOF
|
||||
# End of the Dockerfile content
|
||||
|
||||
echo " Building web app..."
|
||||
|
||||
# Clean up any permission issues first
|
||||
echo " Checking permissions..."
|
||||
|
||||
if [ -f "pubspec.lock" ]; then
|
||||
# Check if pubspec.lock file exists
|
||||
|
||||
if [ ! -w "pubspec.lock" ]; then
|
||||
# Check if file is NOT writable by current user
|
||||
|
||||
echo " Fixing pubspec.lock permissions..."
|
||||
sudo chown $(id -u):$(id -g) pubspec.lock 2>/dev/null || rm -f pubspec.lock
|
||||
# Try to change ownership to current user
|
||||
# "$(id -u)" gets your user ID
|
||||
# "$(id -g)" gets your group ID
|
||||
# If that fails (|| means or), just delete the file
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run the build in the container with proper user mapping
|
||||
podman run --rm \
|
||||
# "podman run" starts a container
|
||||
# "--rm" removes container when it exits (clean up automatically)
|
||||
|
||||
-v "$SCRIPT_DIR:/home/flutter/app:Z" \
|
||||
# "-v" mounts a volume (shares files between host and container)
|
||||
# "$SCRIPT_DIR" on host maps to "/home/flutter/app" in container
|
||||
# ":Z" adjusts SELinux labels for shared access (needed on some Linux systems)
|
||||
|
||||
-w /home/flutter/app \
|
||||
# "-w" sets working directory inside container
|
||||
|
||||
--user $(id -u):$(id -g) \
|
||||
# Run container as current host user (not root)
|
||||
# Prevents file permission issues
|
||||
|
||||
--userns=keep-id \
|
||||
# Keep user namespace mapping consistent
|
||||
# Makes user IDs match between host and container
|
||||
|
||||
browser-tab-manager-builder \
|
||||
# Name of the container image to run
|
||||
|
||||
bash -c '
|
||||
# Run a bash command inside the container
|
||||
# Everything between single quotes is the command
|
||||
|
||||
# Set HOME to writable location
|
||||
export HOME=/tmp/flutter-home
|
||||
# Set HOME environment variable to temporary directory
|
||||
# "export" makes variable available to child processes
|
||||
# Flutter needs a writable home directory
|
||||
|
||||
export PUB_CACHE=/tmp/pub-cache
|
||||
# Set where Flutter stores downloaded packages
|
||||
# Use temp location since we're running as non-root
|
||||
|
||||
# Create web platform if needed
|
||||
if [ ! -d "web" ]; then
|
||||
# Check if web directory doesn't exist
|
||||
|
||||
flutter create . --platforms web > /dev/null 2>&1
|
||||
# Create Flutter web project structure
|
||||
# "." means in current directory
|
||||
# "> /dev/null 2>&1" hides all output messages
|
||||
fi
|
||||
|
||||
# Get dependencies
|
||||
flutter pub get
|
||||
# Download all packages specified in pubspec.yaml
|
||||
# "pub get" is Flutter's package manager command
|
||||
|
||||
# Build for web
|
||||
flutter build web --release
|
||||
# Compile Flutter app to optimized JavaScript/HTML/CSS for web browsers
|
||||
# "--release" creates production-optimized build (smaller, faster)
|
||||
'
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
# Check if previous command failed
|
||||
# "$?" contains exit code of last command
|
||||
# "-ne 0" means "not equal to 0" (0 means success)
|
||||
|
||||
echo "❌ Build failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fix permissions (files might be owned by container user)
|
||||
if [ -d "build/web" ]; then
|
||||
# Check if build output directory exists
|
||||
|
||||
sudo chown -R $(id -u):$(id -g) build/ .dart_tool/ pubspec.lock 2>/dev/null || true
|
||||
# Change ownership of build files to current user
|
||||
# Needed because container may have created them as different user
|
||||
# "|| true" means ignore if this fails
|
||||
fi
|
||||
|
||||
# Create extension directory
|
||||
echo "📦 Packaging extension..."
|
||||
|
||||
rm -rf extension
|
||||
# Delete old extension folder if it exists
|
||||
|
||||
mkdir -p extension
|
||||
# Create new extension folder
|
||||
|
||||
# Copy Flutter build files
|
||||
cp -r build/web/* extension/
|
||||
# Copy all files from Flutter's web build output to extension folder
|
||||
# These are the compiled HTML/JS/CSS files
|
||||
|
||||
# Create manifest.json
|
||||
echo "📋 Adding extension files..."
|
||||
|
||||
cat > extension/manifest.json << 'MANIFEST'
|
||||
# Create manifest.json file for browser extension
|
||||
# This file tells the browser how to load and run the extension
|
||||
|
||||
{
|
||||
"manifest_version": 3,
|
||||
# Version of manifest format (3 is current standard)
|
||||
|
||||
"name": "Browser Tab Manager",
|
||||
# Extension name shown in browser
|
||||
|
||||
"version": "1.0.0",
|
||||
# Extension version number
|
||||
|
||||
"description": "Manage your browser tabs, bookmarks, and history in a beautiful grid view",
|
||||
# Short description of what extension does
|
||||
|
||||
"permissions": [
|
||||
# List of browser APIs this extension needs access to
|
||||
|
||||
"tabs",
|
||||
# Access to browser tabs (view, create, close tabs)
|
||||
|
||||
"bookmarks",
|
||||
# Access to bookmarks (read, create, delete)
|
||||
|
||||
"history",
|
||||
# Access to browsing history
|
||||
|
||||
"storage"
|
||||
# Access to browser's storage API for saving data
|
||||
],
|
||||
|
||||
"action": {
|
||||
# Defines what happens when user clicks extension icon
|
||||
|
||||
"default_popup": "index.html",
|
||||
# Open this HTML file in a popup window
|
||||
|
||||
"default_icon": {
|
||||
# Icon sizes for different display contexts
|
||||
"16": "icons/Icon-192.png",
|
||||
"48": "icons/Icon-192.png",
|
||||
"128": "icons/Icon-512.png"
|
||||
}
|
||||
},
|
||||
|
||||
"background": {
|
||||
# Background script runs continuously, even when popup is closed
|
||||
|
||||
"service_worker": "background.js"
|
||||
# JavaScript file that runs in background
|
||||
},
|
||||
|
||||
"content_security_policy": {
|
||||
# Security rules for what code can run in extension
|
||||
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||
# Allow scripts from extension itself and WebAssembly (needed for Flutter)
|
||||
# "self" means only scripts packaged with the extension
|
||||
# "wasm-unsafe-eval" allows WebAssembly execution
|
||||
},
|
||||
|
||||
"icons": {
|
||||
# Icons for extension in browser's extension list
|
||||
"16": "icons/Icon-192.png",
|
||||
"48": "icons/Icon-192.png",
|
||||
"128": "icons/Icon-512.png"
|
||||
}
|
||||
}
|
||||
MANIFEST
|
||||
# End of manifest.json content
|
||||
|
||||
# Create background.js
|
||||
cat > extension/background.js << 'BACKGROUND'
|
||||
# Create background service worker script
|
||||
# This runs continuously and handles events
|
||||
|
||||
// Background service worker for Browser Tab Manager
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
// Event listener: runs when extension is first installed or updated
|
||||
// "chrome.runtime" is the extension API
|
||||
// "onInstalled.addListener" registers a function to run on install
|
||||
|
||||
console.log('Browser Tab Manager installed');
|
||||
// Log message to browser's developer console
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
// Listen for messages from other parts of the extension
|
||||
// "request" contains the message data
|
||||
// "sender" identifies who sent the message
|
||||
// "sendResponse" is function to send response back
|
||||
|
||||
if (request.type === 'keepAlive') {
|
||||
// If message type is 'keepAlive' (heartbeat to keep service worker active)
|
||||
|
||||
sendResponse({ status: 'alive' });
|
||||
// Respond confirming we're running
|
||||
}
|
||||
return true;
|
||||
// Return true to indicate we'll send response asynchronously
|
||||
});
|
||||
|
||||
chrome.tabs.onCreated.addListener(() => {
|
||||
// Event listener: runs whenever a new tab is created
|
||||
|
||||
notifyPopup('tabCreated');
|
||||
// Call function to notify popup of the change
|
||||
});
|
||||
|
||||
chrome.tabs.onRemoved.addListener(() => {
|
||||
// Event listener: runs when a tab is closed
|
||||
|
||||
notifyPopup('tabRemoved');
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener(() => {
|
||||
// Event listener: runs when tab is updated (URL change, title change, etc.)
|
||||
|
||||
notifyPopup('tabUpdated');
|
||||
});
|
||||
|
||||
function notifyPopup(event) {
|
||||
// Function to send messages to the popup window
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'tabsChanged', event }, () => {
|
||||
// Send message with type and event info
|
||||
// Callback function (empty arrow function) handles response
|
||||
|
||||
if (chrome.runtime.lastError) {
|
||||
// Check if there was an error sending message
|
||||
|
||||
return;
|
||||
// If popup isn't open, this will error - just ignore it
|
||||
}
|
||||
});
|
||||
}
|
||||
BACKGROUND
|
||||
# End of background.js content
|
||||
|
||||
# Create browser-bridge.js
|
||||
cat > extension/browser-bridge.js << 'BRIDGE'
|
||||
# Create bridge script that connects Flutter app to browser APIs
|
||||
# This makes browser extension APIs available to the Flutter web app
|
||||
|
||||
window.BrowserAPI = {
|
||||
// Create global object that Flutter can call
|
||||
// "window" is global JavaScript object in browsers
|
||||
|
||||
async getTabs() {
|
||||
// Async function to get all open tabs
|
||||
// "async" means function returns a Promise (for asynchronous operations)
|
||||
|
||||
if (typeof chrome !== 'undefined' && chrome.tabs) {
|
||||
// Check if Chrome extension API exists
|
||||
// "typeof" checks variable type
|
||||
// "!== 'undefined'" means "is defined"
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Create Promise for async operation
|
||||
// "resolve" is function to call when operation completes
|
||||
|
||||
chrome.tabs.query({}, (tabs) => {
|
||||
// Query all tabs (empty object {} means no filters)
|
||||
// Callback receives array of tab objects
|
||||
|
||||
resolve(tabs.map(tab => ({
|
||||
// Transform browser tab objects to our format
|
||||
// "map" creates new array by transforming each item
|
||||
// "=>" is arrow function syntax
|
||||
|
||||
id: tab.id.toString(),
|
||||
// Convert tab ID to string
|
||||
|
||||
title: tab.title,
|
||||
// Tab's page title
|
||||
|
||||
url: tab.url,
|
||||
// Tab's current URL
|
||||
|
||||
favicon: tab.favIconUrl || '',
|
||||
// Tab's favicon (small icon), or empty string if none
|
||||
// "||" means "or" - use right side if left is falsy
|
||||
|
||||
isPinned: tab.pinned,
|
||||
// Whether tab is pinned
|
||||
|
||||
isActive: tab.active,
|
||||
// Whether this is the currently selected tab
|
||||
|
||||
windowId: tab.windowId,
|
||||
// ID of browser window containing this tab
|
||||
|
||||
lastAccessed: new Date().toISOString()
|
||||
// Current timestamp in ISO format
|
||||
// "new Date()" creates current date/time
|
||||
// "toISOString()" converts to standard format
|
||||
})));
|
||||
});
|
||||
});
|
||||
}
|
||||
return [];
|
||||
// If Chrome API not available, return empty array
|
||||
},
|
||||
|
||||
async getBookmarks() {
|
||||
// Function to get all bookmarks
|
||||
|
||||
if (typeof chrome !== 'undefined' && chrome.bookmarks) {
|
||||
return new Promise((resolve) => {
|
||||
chrome.bookmarks.getTree((bookmarkTreeNodes) => {
|
||||
// Get entire bookmark tree structure
|
||||
|
||||
const bookmarks = [];
|
||||
// Array to collect all bookmarks
|
||||
|
||||
function traverse(nodes, folder = 'Root') {
|
||||
// Recursive function to walk through bookmark tree
|
||||
// "folder = 'Root'" sets default parameter value
|
||||
|
||||
for (const node of nodes) {
|
||||
// Loop through each bookmark node
|
||||
// "for...of" iterates
|
||||
|
||||
|
||||
# This script builds a BROWSER EXTENSION version of the app.
|
||||
# Uses a temporary container to compile Flutter to JavaScript.
|
||||
# Creates an extension/ folder with all files needed for Chrome/Firefox extension.
|
||||
# Adds manifest.json for browser extension configuration.
|
||||
# Adds background.js for extension service worker.
|
||||
# Adds browser-bridge.js to connect Flutter with browser APIs.
|
||||
# You can then load the extension/ folder in your browser's developer mode.
|
||||
|
||||
# DIFFERENCE FROM setup.sh:
|
||||
# - setup.sh: Builds for WEB (runs in browser or server with nginx)
|
||||
# - setup-extension.sh: Builds for BROWSER EXTENSION (installable add-on)
|
||||
65
setup.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "🚀 Setting up Browser Tab Manager (Local)..."
|
||||
echo "📂 Working directory: $SCRIPT_DIR"
|
||||
|
||||
mkdir -p app
|
||||
|
||||
cat > app/manifest.json << 'EOF'
|
||||
{
|
||||
"name": "Browser Tab Manager",
|
||||
"short_name": "TabManager",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "Manage browser tabs in a grid view",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "✅ Project structure ready"
|
||||
|
||||
echo "🔨 Building Podman container..."
|
||||
podman build -t browser-tab-manager .
|
||||
|
||||
podman stop browser-tab-manager 2>/dev/null || true
|
||||
podman rm browser-tab-manager 2>/dev/null || true
|
||||
|
||||
echo "🚢 Starting container..."
|
||||
podman run -d \
|
||||
--name browser-tab-manager \
|
||||
-p 8080:80 \
|
||||
--restart unless-stopped \
|
||||
browser-tab-manager
|
||||
|
||||
echo "✅ Container started successfully!"
|
||||
echo ""
|
||||
echo "🌐 Your Browser Tab Manager is now running at:"
|
||||
echo " http://localhost:8080"
|
||||
echo ""
|
||||
echo "📋 Useful commands:"
|
||||
echo " View logs: podman logs -f browser-tab-manager"
|
||||
echo " Stop: podman stop browser-tab-manager"
|
||||
echo " Start: podman start browser-tab-manager"
|
||||
echo " Restart: podman restart browser-tab-manager"
|
||||
echo " Remove: podman rm -f browser-tab-manager"
|
||||
|
||||
# This script sets up the app for local development.
|
||||
|
||||
|
||||
# Creates the app directory and manifest.json file.
|
||||
|
||||
# Builds a Podman container from the Dockerfile.
|
||||
|
||||
# Stops and removes any existing container to start fresh.
|
||||
|
||||
# Runs the container on port 8080 so you can access it at localhost:8080.
|
||||
|
||||
# Shows useful commands for managing the container.
|
||||
108
tab-tracker-extension/background.js
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Track if monitoring is active
|
||||
let isTracking = false;
|
||||
let trackedTabs = new Map();
|
||||
|
||||
// Listen for messages from popup or content script
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.action === 'startTracking') {
|
||||
startTracking();
|
||||
sendResponse({ status: 'started' });
|
||||
} else if (message.action === 'stopTracking') {
|
||||
stopTracking();
|
||||
sendResponse({ status: 'stopped' });
|
||||
} else if (message.action === 'getStatus') {
|
||||
sendResponse({ isTracking, tabCount: trackedTabs.size });
|
||||
} else if (message.action === 'getAllTabs') {
|
||||
getAllTabs().then(tabs => {
|
||||
sendResponse({ tabs });
|
||||
});
|
||||
return true; // Keep channel open for async response
|
||||
}
|
||||
});
|
||||
|
||||
// Start tracking tabs
|
||||
async function startTracking() {
|
||||
isTracking = true;
|
||||
console.log('Started tracking tabs');
|
||||
|
||||
// Get all current tabs
|
||||
const tabs = await chrome.tabs.query({});
|
||||
tabs.forEach(tab => {
|
||||
if (tab.url && !tab.url.startsWith('chrome://') && !tab.url.startsWith('edge://')) {
|
||||
addTab(tab);
|
||||
}
|
||||
});
|
||||
|
||||
// Send initial tabs to web app
|
||||
sendTabsToWebApp();
|
||||
}
|
||||
|
||||
// Stop tracking
|
||||
function stopTracking() {
|
||||
isTracking = false;
|
||||
trackedTabs.clear();
|
||||
console.log('Stopped tracking tabs');
|
||||
|
||||
// Notify web app to clear data
|
||||
sendToWebApp({ action: 'clear' });
|
||||
}
|
||||
|
||||
// Add or update a tab
|
||||
function addTab(tab) {
|
||||
trackedTabs.set(tab.id, {
|
||||
id: tab.id,
|
||||
url: tab.url,
|
||||
title: tab.title || tab.url,
|
||||
favIconUrl: tab.favIconUrl,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Get all tabs
|
||||
async function getAllTabs() {
|
||||
return Array.from(trackedTabs.values());
|
||||
}
|
||||
|
||||
// Send tabs to web app
|
||||
async function sendTabsToWebApp() {
|
||||
const tabs = await getAllTabs();
|
||||
sendToWebApp({
|
||||
action: 'updateTabs',
|
||||
tabs: tabs
|
||||
});
|
||||
}
|
||||
|
||||
// Send message to web app via content script
|
||||
function sendToWebApp(data) {
|
||||
chrome.tabs.query({ url: ['https://tab.caesargaming.org/*', 'http://localhost:8080/*'] }, (tabs) => {
|
||||
tabs.forEach(tab => {
|
||||
chrome.tabs.sendMessage(tab.id, data).catch(() => {
|
||||
// Ignore errors if content script not loaded
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for new tabs
|
||||
chrome.tabs.onCreated.addListener((tab) => {
|
||||
if (isTracking && tab.url && !tab.url.startsWith('chrome://') && !tab.url.startsWith('edge://')) {
|
||||
addTab(tab);
|
||||
sendTabsToWebApp();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for tab updates
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||
if (isTracking && changeInfo.status === 'complete' && tab.url && !tab.url.startsWith('chrome://') && !tab.url.startsWith('edge://')) {
|
||||
addTab(tab);
|
||||
sendTabsToWebApp();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for closed tabs
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
if (isTracking && trackedTabs.has(tabId)) {
|
||||
trackedTabs.delete(tabId);
|
||||
sendTabsToWebApp();
|
||||
}
|
||||
});
|
||||
27
tab-tracker-extension/content.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Listen for messages from background script
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
// Forward message to web app
|
||||
window.postMessage({
|
||||
source: 'tab-tracker-extension',
|
||||
...message
|
||||
}, '*');
|
||||
});
|
||||
|
||||
// Listen for messages from web app
|
||||
window.addEventListener('message', (event) => {
|
||||
// Only accept messages from same origin
|
||||
if (event.source !== window) return;
|
||||
|
||||
if (event.data.source === 'tab-tracker-webapp') {
|
||||
// Forward to background script
|
||||
chrome.runtime.sendMessage(event.data, (response) => {
|
||||
// Send response back to web app
|
||||
window.postMessage({
|
||||
source: 'tab-tracker-extension',
|
||||
response: response
|
||||
}, '*');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Tab Tracker extension content script loaded');
|
||||
BIN
tab-tracker-extension/icons/icon128.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
tab-tracker-extension/icons/icon16.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
tab-tracker-extension/icons/icon48.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
40
tab-tracker-extension/manifest.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Tab Tracker",
|
||||
"version": "1.0",
|
||||
"description": "Track open tabs and send to tab.caesargaming.org",
|
||||
"permissions": [
|
||||
"tabs",
|
||||
"storage",
|
||||
"activeTab"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://tab.caesargaming.org/*",
|
||||
"http://localhost:8080/*"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://tab.caesargaming.org/*",
|
||||
"http://localhost:8080/*"
|
||||
],
|
||||
"js": ["content.js"]
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
}
|
||||
79
tab-tracker-extension/popup.html
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {
|
||||
width: 300px;
|
||||
padding: 20px;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
color: #0175C2;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
#startBtn {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
#startBtn:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
#stopBtn {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
}
|
||||
#stopBtn:hover {
|
||||
background-color: #da190b;
|
||||
}
|
||||
#status {
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
.tracking {
|
||||
background-color: #dff0d8;
|
||||
color: #3c763d;
|
||||
}
|
||||
.not-tracking {
|
||||
background-color: #f2dede;
|
||||
color: #a94442;
|
||||
}
|
||||
#info {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Tab Tracker</h2>
|
||||
|
||||
<div id="status" class="not-tracking">
|
||||
Not Tracking
|
||||
</div>
|
||||
|
||||
<button id="startBtn">Start Tracking</button>
|
||||
<button id="stopBtn" style="display: none;">Stop Tracking</button>
|
||||
|
||||
<div id="info">
|
||||
<p>Tracked tabs: <span id="tabCount">0</span></p>
|
||||
<p>Visit <a href="https://tab.caesargaming.org" target="_blank">tab.caesargaming.org</a> to view your tabs.</p>
|
||||
</div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
50
tab-tracker-extension/popup.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
const startBtn = document.getElementById('startBtn');
|
||||
const stopBtn = document.getElementById('stopBtn');
|
||||
const statusDiv = document.getElementById('status');
|
||||
const tabCountSpan = document.getElementById('tabCount');
|
||||
|
||||
// Update UI based on tracking status
|
||||
function updateUI(isTracking, tabCount = 0) {
|
||||
if (isTracking) {
|
||||
statusDiv.textContent = 'Tracking Active';
|
||||
statusDiv.className = 'tracking';
|
||||
startBtn.style.display = 'none';
|
||||
stopBtn.style.display = 'block';
|
||||
tabCountSpan.textContent = tabCount;
|
||||
} else {
|
||||
statusDiv.textContent = 'Not Tracking';
|
||||
statusDiv.className = 'not-tracking';
|
||||
startBtn.style.display = 'block';
|
||||
stopBtn.style.display = 'none';
|
||||
tabCountSpan.textContent = '0';
|
||||
}
|
||||
}
|
||||
|
||||
// Start tracking
|
||||
startBtn.addEventListener('click', () => {
|
||||
chrome.runtime.sendMessage({ action: 'startTracking' }, (response) => {
|
||||
console.log('Started tracking:', response);
|
||||
checkStatus();
|
||||
});
|
||||
});
|
||||
|
||||
// Stop tracking
|
||||
stopBtn.addEventListener('click', () => {
|
||||
chrome.runtime.sendMessage({ action: 'stopTracking' }, (response) => {
|
||||
console.log('Stopped tracking:', response);
|
||||
checkStatus();
|
||||
});
|
||||
});
|
||||
|
||||
// Check current status
|
||||
function checkStatus() {
|
||||
chrome.runtime.sendMessage({ action: 'getStatus' }, (response) => {
|
||||
updateUI(response.isTracking, response.tabCount);
|
||||
});
|
||||
}
|
||||
|
||||
// Initial status check
|
||||
checkStatus();
|
||||
|
||||
// Update status every 2 seconds
|
||||
setInterval(checkStatus, 2000);
|
||||
30
test/widget_test.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:browser_tab_manager/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
BIN
web/favicon.png
Normal file
|
After Width: | Height: | Size: 917 B |
BIN
web/icons/Icon-192.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
web/icons/Icon-512.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
web/icons/Icon-maskable-192.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
web/icons/Icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
38
web/index.html
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="browser_tab_manager">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>browser_tab_manager</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
11
web/manifest.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "Browser Tab Manager",
|
||||
"short_name": "TabManager",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "Manage browser tabs in a grid view",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
36
web/manifest.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
|
||||
{
|
||||
"name": "Browser Tab Manager",
|
||||
"short_name": "TabManager",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "Manage browser tabs in a grid view",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
|
||||
// This is the web app manifest file that defines how the app appears when installed on a device.
|
||||
|
||||
// The name field is the full application name shown to users.
|
||||
|
||||
// The short_name is used when there is limited space like on a home screen icon.
|
||||
|
||||
// The start_url tells the browser which page to load when the app launches.
|
||||
|
||||
// Display standalone makes the app look like a native app without browser UI.
|
||||
|
||||
// The background_color sets the splash screen color while the app loads.
|
||||
|
||||
// The theme_color defines the color of the browser toolbar and app header.
|
||||
|
||||
// Both colors use our app's primary blue color for consistent branding.
|
||||
|
||||
// The description explains what the app does for app stores and search results.
|
||||
|
||||
// Orientation is set to portrait-primary which works best for mobile devices.
|
||||
|
||||
// The prefer_related_applications flag set to false means we want users to install this web app.
|
||||
|
||||
// This file enables Progressive Web App features like installation and offline capabilities.
|
||||