From 58d9d1f27b981457024046d9184e9c572af6cf66 Mon Sep 17 00:00:00 2001 From: jsnk Date: Sun, 19 Oct 2025 21:02:06 +0200 Subject: [PATCH] Initial commit: Browser Tab Manager Flutter app --- .gitignore | 45 + .metadata | 30 + Dockerfile | 63 + README.md | 421 +++++++ _backup/main.dart | 1207 ++++++++++++++++++++ analysis_options.yaml | 28 + app/manifest.json | 11 + dist/browser-tab-manager.tar.gz | Bin 0 -> 7567 bytes dist/browser-tab-manager/Dockerfile | 52 + dist/browser-tab-manager/deploy.sh | 60 + dist/browser-tab-manager/lib/main.dart | 890 +++++++++++++++ dist/browser-tab-manager/nginx.conf | 20 + dist/browser-tab-manager/pubspec.yaml | 20 + dist/browser-tab-manager/web/manifest.json | 11 + lib/constants/app_constants.dart | 67 ++ lib/main.dart | 55 + lib/models/tab_data.dart | 63 + lib/screens/tab_manager_home.dart | 393 +++++++ lib/services/browser_api_service.dart | 113 ++ lib/services/extension_service.dart | 80 ++ lib/utils/helpers.dart | 96 ++ lib/widgets/app_bar_actions.dart | 90 ++ lib/widgets/filter_chips.dart | 74 ++ lib/widgets/item_card.dart | 173 +++ lib/widgets/item_list_tile.dart | 145 +++ lib/widgets/search_bar.dart | 57 + nginx.conf | 46 + package.sh | 111 ++ pubspec.lock | 213 ++++ pubspec.yaml | 31 + requirements.txt | 0 setup-extension.sh | 468 ++++++++ setup.sh | 65 ++ tab-tracker-extension/background.js | 108 ++ tab-tracker-extension/content.js | 27 + tab-tracker-extension/icons/icon128.png | Bin 0 -> 2425 bytes tab-tracker-extension/icons/icon16.png | Bin 0 -> 1525 bytes tab-tracker-extension/icons/icon48.png | Bin 0 -> 1525 bytes tab-tracker-extension/manifest.json | 40 + tab-tracker-extension/popup.html | 79 ++ tab-tracker-extension/popup.js | 50 + test/widget_test.dart | 30 + web/favicon.png | Bin 0 -> 917 bytes web/icons/Icon-192.png | Bin 0 -> 5292 bytes web/icons/Icon-512.png | Bin 0 -> 8252 bytes web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes web/index.html | 38 + web/manifest.json | 11 + web/manifest.md | 36 + 50 files changed, 5617 insertions(+) create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 _backup/main.dart create mode 100644 analysis_options.yaml create mode 100644 app/manifest.json create mode 100644 dist/browser-tab-manager.tar.gz create mode 100644 dist/browser-tab-manager/Dockerfile create mode 100755 dist/browser-tab-manager/deploy.sh create mode 100644 dist/browser-tab-manager/lib/main.dart create mode 100644 dist/browser-tab-manager/nginx.conf create mode 100644 dist/browser-tab-manager/pubspec.yaml create mode 100644 dist/browser-tab-manager/web/manifest.json create mode 100644 lib/constants/app_constants.dart create mode 100644 lib/main.dart create mode 100644 lib/models/tab_data.dart create mode 100644 lib/screens/tab_manager_home.dart create mode 100644 lib/services/browser_api_service.dart create mode 100644 lib/services/extension_service.dart create mode 100644 lib/utils/helpers.dart create mode 100644 lib/widgets/app_bar_actions.dart create mode 100644 lib/widgets/filter_chips.dart create mode 100644 lib/widgets/item_card.dart create mode 100644 lib/widgets/item_list_tile.dart create mode 100644 lib/widgets/search_bar.dart create mode 100644 nginx.conf create mode 100755 package.sh create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 requirements.txt create mode 100755 setup-extension.sh create mode 100755 setup.sh create mode 100644 tab-tracker-extension/background.js create mode 100644 tab-tracker-extension/content.js create mode 100644 tab-tracker-extension/icons/icon128.png create mode 100644 tab-tracker-extension/icons/icon16.png create mode 100644 tab-tracker-extension/icons/icon48.png create mode 100644 tab-tracker-extension/manifest.json create mode 100644 tab-tracker-extension/popup.html create mode 100644 tab-tracker-extension/popup.js create mode 100644 test/widget_test.dart create mode 100644 web/favicon.png create mode 100644 web/icons/Icon-192.png create mode 100644 web/icons/Icon-512.png create mode 100644 web/icons/Icon-maskable-192.png create mode 100644 web/icons/Icon-maskable-512.png create mode 100644 web/index.html create mode 100644 web/manifest.json create mode 100644 web/manifest.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..5722f85 --- /dev/null +++ b/.metadata @@ -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' diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2ebed8c --- /dev/null +++ b/Dockerfile @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..daa34ce --- /dev/null +++ b/README.md @@ -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 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> getTabs() async { + final result = await _callBrowserAPI('getTabs'); + // Convert raw data to TabData objects + } +} +``` + +**KEY CONCEPTS:** +- `async/await`: Handles asynchronous operations +- `Future`: 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 { + List allItems = []; // ALL data from browser + List 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 filterItems( + List 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 _callBrowserAPI(String method, [List? 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` 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! 🚀 \ No newline at end of file diff --git a/_backup/main.dart b/_backup/main.dart new file mode 100644 index 0000000..b64126b --- /dev/null +++ b/_backup/main.dart @@ -0,0 +1,1207 @@ +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()); + // ☝️ STATEMENT: Command that starts the app +} + +class BrowserTabManagerApp extends StatelessWidget { + // This is a STATELESS widget - no changing data + + const BrowserTabManagerApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + // STATEMENT: Return/create a MaterialApp widget + + 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 { + // This is a DATA MODEL - holds information about a tab + + String id; // STATEMENT: Declare a variable + String title; // STATEMENT: Declare a variable + String url; // STATEMENT: Declare a variable + 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(); + // ☝️ STATEMENT: Constructor that creates TabData objects + + factory TabData.fromJson(Map json) => TabData( + // ☝️ STATEMENT: Factory method to create TabData from JSON + 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 { + // This is a STATEFUL widget - has changing data (STATE) + + const TabManagerHome({super.key}); + + @override + State createState() => _TabManagerHomeState(); + // STATEMENT: Create the state object +} + +class _TabManagerHomeState extends State { + // This class holds all the STATE (changing data) + + List allItems = []; + // 🔴 STATE: List of all tabs/bookmarks/history + // When this changes, UI rebuilds to show new data + + List filteredItems = []; + // 🔴 STATE: Filtered/searched items currently displayed + + final TextEditingController searchController = TextEditingController(); + // 🔴 STATE: Controls search text field + + bool isGridView = true; + // 🔴 STATE: Whether showing grid or list view + + String sortBy = 'recent'; + // 🔴 STATE: Current sort method + + String filterType = 'all'; + // 🔴 STATE: Current filter (all, tabs, bookmarks, history) + + bool isLoading = true; + // 🔴 STATE: Whether data is loading + + bool extensionConnected = false; + // 🔴 STATE: Whether browser extension is connected + + bool extensionMode = false; + // 🔴 STATE: Whether in extension tracking mode + + @override + void initState() { + // STATEMENT: Method that runs when widget is created + + super.initState(); + // STATEMENT: Call parent's initState + + _setupExtensionListener(); + // STATEMENT: Call function to setup listener + + _loadAllData(); + // STATEMENT: Call function to load data + + searchController.addListener(_filterItems); + // STATEMENT: Add listener to search field + } + + @override + void dispose() { + // STATEMENT: Method that runs when widget is destroyed + + searchController.dispose(); + // STATEMENT: Clean up resources + + super.dispose(); + // STATEMENT: Call parent's dispose + } + + void _setupExtensionListener() { + // STATEMENT: Function declaration + + html.window.onMessage.listen((event) { + // STATEMENT: Start listening for messages + + final data = event.data; + // STATEMENT: Get data from event + + if (data is Map && data['source'] == 'tab-tracker-extension') { + // STATEMENT: Conditional check + + _handleExtensionMessage(Map.from(data)); + // STATEMENT: Call function with data + } + }); + } + + void _handleExtensionMessage(Map data) { + print('Received from extension: $data'); + // STATEMENT: Print to console + + if (data['action'] == 'updateTabs') { + // STATEMENT: Check if action is updateTabs + + setState(() { + // 🔥 SPECIAL STATEMENT: Updates STATE and rebuilds UI + // Everything inside this block changes STATE + + final extensionTabs = (data['tabs'] as List).map((tab) { + tab['type'] = 'tab'; + return TabData.fromJson(tab); + }).toList(); + // STATEMENT: Transform data + + allItems = extensionTabs; + // 🔴 STATE CHANGE: Update the list + + extensionConnected = true; + // 🔴 STATE CHANGE: Mark extension as connected + + extensionMode = true; + // 🔴 STATE CHANGE: Enable extension mode + + _filterItems(); + // STATEMENT: Call filter function + }); + // After setState finishes, Flutter rebuilds the UI automatically + + } else if (data['action'] == 'clear') { + // STATEMENT: Check another condition + + if (extensionMode) { + setState(() { + extensionMode = false; + // 🔴 STATE CHANGE: Disable extension mode + + _loadAllData(); + // STATEMENT: Reload data + }); + } + } 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(); + }); + } + } + } + + void _sendToExtension(Map message) { + // STATEMENT: Function to send message + + html.window.postMessage({ + 'source': 'tab-tracker-webapp', + ...message + }, '*'); + // STATEMENT: Post message to window + } + + void _startExtensionTracking() { + _sendToExtension({'action': 'startTracking'}); + // STATEMENT: Send message to extension + + setState(() { + // 🔥 UPDATE STATE + + extensionMode = true; + // 🔴 STATE CHANGE + + allItems.clear(); + // 🔴 STATE CHANGE: Clear the list + }); + } + + void _stopExtensionTracking() { + _sendToExtension({'action': 'stopTracking'}); + // STATEMENT: Send stop message + + setState(() { + extensionMode = false; + // 🔴 STATE CHANGE + }); + + _loadAllData(); + // STATEMENT: Reload data + } + + Future _loadAllData() async { + // STATEMENT: Async function declaration + + if (extensionMode) return; + // STATEMENT: Early return if in extension mode + + setState(() { + isLoading = true; + // 🔴 STATE CHANGE: Show loading spinner + }); + + try { + // STATEMENT: Try block for error handling + + final tabs = await _getTabs(); + // STATEMENT: Wait for tabs to load + + final bookmarks = await _getBookmarks(); + // STATEMENT: Wait for bookmarks to load + + final history = await _getHistory(); + // STATEMENT: Wait for history to load + + setState(() { + // 🔥 UPDATE STATE + + allItems = [...tabs, ...bookmarks, ...history]; + // 🔴 STATE CHANGE: Combine all items + + _filterItems(); + // STATEMENT: Filter items + + isLoading = false; + // 🔴 STATE CHANGE: Hide loading spinner + }); + + _checkExtensionConnection(); + // STATEMENT: Check extension + + } catch (e) { + // STATEMENT: Catch errors + + print('Error loading data: $e'); + // STATEMENT: Print error + + setState(() { + isLoading = false; + // 🔴 STATE CHANGE: Hide loading spinner + }); + } + } + + void _checkExtensionConnection() { + _sendToExtension({'action': 'getStatus'}); + // STATEMENT: Request status + + Future.delayed(const Duration(milliseconds: 500), () { + // STATEMENT: Wait 500ms + + if (mounted) { + // STATEMENT: Check if widget still exists + + setState(() { + // 🔥 UPDATE STATE (even if empty, triggers rebuild) + }); + } + }); + } + + Future> _getTabs() async { + // STATEMENT: Function that returns Future + + try { + final result = await _callBrowserAPI('getTabs'); + // STATEMENT: Call browser API + + if (result != null) { + // STATEMENT: Check if got result + + final List data = json.decode(result); + // STATEMENT: Parse JSON + + return data.map((item) { + // STATEMENT: Transform each item + + item['type'] = 'tab'; + // STATEMENT: Set type + + return TabData.fromJson(item); + // STATEMENT: Create TabData object + }).toList(); + } + } catch (e) { + print('Error getting tabs: $e'); + // STATEMENT: Print error + } + + return []; + // STATEMENT: Return empty list + } + + Future> _getBookmarks() async { + try { + final result = await _callBrowserAPI('getBookmarks'); + if (result != null) { + final List 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> _getHistory() async { + try { + final result = await _callBrowserAPI('getHistory', [100]); + if (result != null) { + final List 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 _callBrowserAPI(String method, [List? args]) async { + try { + if (!js_util.hasProperty(html.window, 'BrowserAPI')) { + // STATEMENT: Check if API exists + + print('BrowserAPI not found - running in development mode'); + return null; + // STATEMENT: Return null + } + + final browserAPI = js_util.getProperty(html.window, 'BrowserAPI'); + // STATEMENT: Get API object + + final function = js_util.getProperty(browserAPI, method); + // STATEMENT: Get function + + 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])); + // STATEMENT: Call function and wait for result + + return json.encode(result); + // STATEMENT: Convert to JSON string + + } catch (e) { + print('Error calling $method: $e'); + return null; + } + } + + void _filterItems() { + final query = searchController.text.toLowerCase(); + // STATEMENT: Get search text + + setState(() { + // 🔥 UPDATE STATE + + filteredItems = allItems.where((item) { + // STATEMENT: Filter list + + if (filterType != 'all' && item.type != filterType.replaceAll('s', '')) { + return false; + // STATEMENT: Exclude item + } + + if (query.isNotEmpty) { + return item.title.toLowerCase().contains(query) || + item.url.toLowerCase().contains(query); + // STATEMENT: Check if matches search + } + + return true; + // STATEMENT: Include item + }).toList(); + + _sortItems(); + // STATEMENT: Sort the filtered items + }); + } + + void _sortItems() { + switch (sortBy) { + // STATEMENT: Switch based on sortBy value + + case 'recent': + filteredItems.sort((a, b) => b.lastAccessed.compareTo(a.lastAccessed)); + // STATEMENT: Sort by date + break; + + case 'title': + filteredItems.sort((a, b) => a.title.compareTo(b.title)); + // STATEMENT: Sort alphabetically + 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)); + // STATEMENT: Keep pinned items at top + } + + Future _openItem(TabData item) async { + if (extensionMode) { + html.window.open(item.url, '_blank'); + // STATEMENT: Open in new tab + } else { + if (item.type == 'tab') { + await _callBrowserAPI('switchToTab', [item.id]); + // STATEMENT: Switch to tab + } else { + await _callBrowserAPI('openTab', [item.url]); + // STATEMENT: Open new tab + } + } + } + + Future _deleteItem(TabData item) async { + if (extensionMode) return; + // STATEMENT: Can't delete in extension mode + + if (item.type == 'tab') { + await _callBrowserAPI('closeTab', [item.id]); + // STATEMENT: Close tab + } else if (item.type == 'bookmark') { + await _callBrowserAPI('removeBookmark', [item.id]); + // STATEMENT: Delete bookmark + } + + await _loadAllData(); + // STATEMENT: Reload data + } + + Future _togglePin(TabData item) async { + if (extensionMode) return; + + if (item.type == 'tab') { + await _callBrowserAPI('togglePinTab', [item.id, !item.isPinned]); + // STATEMENT: Toggle pin status + + await _loadAllData(); + // STATEMENT: Reload data + } + } + + String _getIcon(TabData item) { + // STATEMENT: Function that returns icon emoji + + if (item.favicon.isNotEmpty && !item.favicon.contains('data:')) { + return '🌐'; + // STATEMENT: Return globe icon + } + + switch (item.type) { + case 'tab': + return '📑'; + case 'bookmark': + return '⭐'; + case 'history': + return '🕐'; + default: + return '🌐'; + } + } + + @override + Widget build(BuildContext context) { + // STATEMENT: Build method that creates UI + // This method runs every time setState() is called + + 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, + }; + // STATEMENT: Calculate statistics + // This uses current STATE (allItems) to calculate counts + + return Scaffold( + // STATEMENT: Return Scaffold widget + + appBar: AppBar( + title: Row( + children: [ + const Text('Browser Tab Manager'), + + if (extensionMode) ...[ + // STATEMENT: Conditionally show badge based on STATE + + 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: [ + if (extensionMode) + // STATEMENT: Show stop button if in extension mode (based on STATE) + + TextButton.icon( + onPressed: _stopExtensionTracking, + // STATEMENT: When pressed, call function that changes STATE + + icon: const Icon(Icons.stop, color: Colors.white), + label: const Text('Stop', style: TextStyle(color: Colors.white)), + ) + else + TextButton.icon( + onPressed: extensionConnected ? _startExtensionTracking : null, + // STATEMENT: Enable button based on STATE + + 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, + // STATEMENT: Disable button based on STATE + + tooltip: 'Refresh', + ), + + IconButton( + icon: Icon(isGridView ? Icons.view_list : Icons.grid_view), + // STATEMENT: Show different icon based on STATE + + onPressed: () { + setState(() { + // 🔥 UPDATE STATE + + isGridView = !isGridView; + // 🔴 STATE CHANGE: Toggle view mode + }); + }, + tooltip: isGridView ? 'List View' : 'Grid View', + ), + + PopupMenuButton( + icon: const Icon(Icons.sort), + tooltip: 'Sort by', + onSelected: (value) { + setState(() { + // 🔥 UPDATE STATE + + sortBy = value; + // 🔴 STATE CHANGE: Change sort method + + _filterItems(); + // STATEMENT: Re-filter with new sort + }); + }, + 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, + // Uses STATE (searchController) + + decoration: InputDecoration( + hintText: extensionMode + ? 'Search tracked tabs...' + : 'Search tabs, bookmarks, and history...', + // STATEMENT: Show different hint based on STATE + + prefixIcon: const Icon(Icons.search), + suffixIcon: searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + searchController.clear(); + // STATEMENT: Clear search (changes STATE) + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + + const SizedBox(height: 12), + + if (!extensionMode) + // STATEMENT: Show filters only if not in extension mode (based on STATE) + + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + FilterChip( + label: Text('All (${allItems.length})'), + // Uses STATE to show count + + selected: filterType == 'all', + // Uses STATE to show selection + + onSelected: (selected) { + setState(() { + // 🔥 UPDATE STATE + + filterType = 'all'; + // 🔴 STATE CHANGE + + _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 + // STATEMENT: Show different UI based on STATE + + ? const Center(child: CircularProgressIndicator()) + // If isLoading is true, show spinner + + : filteredItems.isEmpty + // If filteredItems is empty, show message + + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + extensionMode ? Icons.track_changes : Icons.search_off, + // Different icon based on STATE + + size: 80, + color: Theme.of(context) + .colorScheme + .primary + .withOpacity(0.3), + ), + const SizedBox(height: 16), + Text( + extensionMode + ? 'Waiting for tabs...' + : 'No items found', + // Different message based on STATE + + 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 + // If isGridView is true, show grid + + ? GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 300, + childAspectRatio: 1.3, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: filteredItems.length, + // Uses STATE to determine how many items + + itemBuilder: (context, index) { + return ItemCard( + item: filteredItems[index], + // Passes STATE data to child widget + + icon: _getIcon(filteredItems[index]), + onTap: () => _openItem(filteredItems[index]), + onDelete: () => _deleteItem(filteredItems[index]), + onTogglePin: () => _togglePin(filteredItems[index]), + extensionMode: extensionMode, + // Passes STATE to child + ); + }, + ) + + : ListView.builder( + // Otherwise show list + + 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, + ); + }, + ), + ), + ], + ), + ); + } +} + +// ItemCard and ItemListTile are STATELESS widgets +// They receive data (STATE) from parent and display it +// They don't manage their own STATE + +class ItemCard extends StatelessWidget { + // This widget receives STATE from parent via constructor parameters + + final TabData item; // STATE from parent + final String icon; // STATE from parent + final VoidCallback onTap; // Function from parent + final VoidCallback onDelete; // Function from parent + final VoidCallback onTogglePin; // Function from parent + final bool extensionMode; // STATE from parent + + 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) { + // STATEMENT: Build method creates UI + // This widget doesn't have setState because it's StatelessWidget + // It just displays the data it receives + + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + // STATEMENT: When tapped, call function from parent + + 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) + // STATEMENT: Show pin icon if item is pinned + + 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(), + // STATEMENT: Display item type from STATE + + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Expanded( + child: Text( + item.title, + // STATEMENT: Display title from STATE + + style: Theme.of(context).textTheme.titleSmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(height: 4), + Text( + item.url, + // STATEMENT: Display URL from STATE + + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.secondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (item.visitCount != null) ...[ + // STATEMENT: Show visit count if it exists + + const SizedBox(height: 4), + Text( + '${item.visitCount} visits', + // STATEMENT: Display visit count from STATE + + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ), + ), + ), + if (!extensionMode) + // STATEMENT: Show buttons only if not in extension mode (based on STATE) + + ButtonBar( + alignment: MainAxisAlignment.end, + buttonPadding: EdgeInsets.zero, + children: [ + if (item.type == 'tab') + // STATEMENT: Show pin button only for tabs + + IconButton( + icon: Icon( + item.isPinned ? Icons.push_pin : Icons.push_pin_outlined, + // STATEMENT: Different icon based on pin STATE + + size: 18, + ), + onPressed: onTogglePin, + // STATEMENT: When pressed, call parent's function + + tooltip: item.isPinned ? 'Unpin' : 'Pin', + ), + if (item.type != 'history') + // STATEMENT: Show delete button for tabs and bookmarks (not history) + + IconButton( + icon: const Icon(Icons.delete_outline, size: 18), + onPressed: onDelete, + // STATEMENT: When pressed, call parent's delete function + + tooltip: 'Delete', + ), + ], + ), + ], + ), + ), + ); + } + + Color _getTypeColor(BuildContext context) { + // STATEMENT: Function that returns color based on item type + + switch (item.type) { + // STATEMENT: Check item type (from STATE) + + case 'tab': + return Colors.blue; + // STATEMENT: Return blue for tabs + + case 'bookmark': + return Colors.orange; + // STATEMENT: Return orange for bookmarks + + case 'history': + return Colors.purple; + // STATEMENT: Return purple for history + + default: + return Theme.of(context).colorScheme.primary; + // STATEMENT: Return theme color as fallback + } + } +} + +class ItemListTile extends StatelessWidget { + // Another STATELESS widget - displays data from parent + + final TabData item; // STATE from parent + final String icon; // STATE from parent + final VoidCallback onTap; // Function from parent + final VoidCallback onDelete; // Function from parent + final VoidCallback onTogglePin; // Function from parent + final bool extensionMode; // STATE from parent + + 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) { + // STATEMENT: Build method creates UI + + 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) ...[ + // STATEMENT: Show pin icon if pinned + + 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(), + // STATEMENT: Display type from STATE + + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + item.title, + // STATEMENT: Display title from STATE + + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.url, + // STATEMENT: Display URL from STATE + + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (item.visitCount != null) + // STATEMENT: Show visit count if exists + + Text('${item.visitCount} visits', + style: Theme.of(context).textTheme.bodySmall), + ], + ), + trailing: extensionMode ? null : Row( + // STATEMENT: Show buttons based on extensionMode STATE + + mainAxisSize: MainAxisSize.min, + children: [ + if (item.type == 'tab') + // STATEMENT: Pin button only for tabs + + IconButton( + icon: Icon( + item.isPinned ? Icons.push_pin : Icons.push_pin_outlined, + ), + onPressed: onTogglePin, + // STATEMENT: Call parent's function + + tooltip: item.isPinned ? 'Unpin' : 'Pin', + ), + if (item.type != 'history') + // STATEMENT: Delete button for tabs and bookmarks + + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: onDelete, + // STATEMENT: Call parent's function + + tooltip: 'Delete', + ), + ], + ), + onTap: onTap, + // STATEMENT: When tapped, call parent's function + ), + ); + } + + Color _getTypeColor(BuildContext context) { + // STATEMENT: Function to get color based on type + + 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; + } + } +} \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -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 diff --git a/app/manifest.json b/app/manifest.json new file mode 100644 index 0000000..8059dd7 --- /dev/null +++ b/app/manifest.json @@ -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 +} diff --git a/dist/browser-tab-manager.tar.gz b/dist/browser-tab-manager.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..09032116974d490bad64a32aac224a6c13b196b5 GIT binary patch literal 7567 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 lrnG6y{Fu1XRWEtlM`>UB(wDyUr7!=R@-MG; 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" diff --git a/dist/browser-tab-manager/lib/main.dart b/dist/browser-tab-manager/lib/main.dart new file mode 100644 index 0000000..13cbe4f --- /dev/null +++ b/dist/browser-tab-manager/lib/main.dart @@ -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 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 createState() => _TabManagerHomeState(); +} + +class _TabManagerHomeState extends State { + List allItems = []; + List 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.from(data)); +} + }); + } + + // Handle messages from extension + void _handleExtensionMessage(Map 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 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 _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> _getTabs() async { + try { + final result = await _callBrowserAPI('getTabs'); + if (result != null) { + final List 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> _getBookmarks() async { + try { + final result = await _callBrowserAPI('getBookmarks'); + if (result != null) { + final List 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> _getHistory() async { + try { + final result = await _callBrowserAPI('getHistory', [100]); + if (result != null) { + final List 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 _callBrowserAPI(String method, [List? 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 _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 _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 _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( + 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; + } + } +} \ No newline at end of file diff --git a/dist/browser-tab-manager/nginx.conf b/dist/browser-tab-manager/nginx.conf new file mode 100644 index 0000000..f3cd537 --- /dev/null +++ b/dist/browser-tab-manager/nginx.conf @@ -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"; + } +} \ No newline at end of file diff --git a/dist/browser-tab-manager/pubspec.yaml b/dist/browser-tab-manager/pubspec.yaml new file mode 100644 index 0000000..de94260 --- /dev/null +++ b/dist/browser-tab-manager/pubspec.yaml @@ -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 \ No newline at end of file diff --git a/dist/browser-tab-manager/web/manifest.json b/dist/browser-tab-manager/web/manifest.json new file mode 100644 index 0000000..8059dd7 --- /dev/null +++ b/dist/browser-tab-manager/web/manifest.json @@ -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 +} diff --git a/lib/constants/app_constants.dart b/lib/constants/app_constants.dart new file mode 100644 index 0000000..07806d7 --- /dev/null +++ b/lib/constants/app_constants.dart @@ -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 sortOptions = ['recent', 'title', 'url', 'visits']; + static const List 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. \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..84b8dab --- /dev/null +++ b/lib/main.dart @@ -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. \ No newline at end of file diff --git a/lib/models/tab_data.dart b/lib/models/tab_data.dart new file mode 100644 index 0000000..7506376 --- /dev/null +++ b/lib/models/tab_data.dart @@ -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 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. \ No newline at end of file diff --git a/lib/screens/tab_manager_home.dart b/lib/screens/tab_manager_home.dart new file mode 100644 index 0000000..9ee4aeb --- /dev/null +++ b/lib/screens/tab_manager_home.dart @@ -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 createState() => _TabManagerHomeState(); +} + +class _TabManagerHomeState extends State { + // State variables + List allItems = []; + List 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 _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 _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 _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 _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. \ No newline at end of file diff --git a/lib/services/browser_api_service.dart b/lib/services/browser_api_service.dart new file mode 100644 index 0000000..5eed489 --- /dev/null +++ b/lib/services/browser_api_service.dart @@ -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> getTabs() async { + try { + final result = await _callBrowserAPI('getTabs'); + if (result != null) { + final List 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> getBookmarks() async { + try { + final result = await _callBrowserAPI('getBookmarks'); + if (result != null) { + final List 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> getHistory() async { + try { + final result = await _callBrowserAPI('getHistory', [100]); + if (result != null) { + final List 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 switchToTab(String tabId) async { + await _callBrowserAPI('switchToTab', [tabId]); + } + + Future openTab(String url) async { + await _callBrowserAPI('openTab', [url]); + } + + Future closeTab(String tabId) async { + await _callBrowserAPI('closeTab', [tabId]); + } + + Future removeBookmark(String bookmarkId) async { + await _callBrowserAPI('removeBookmark', [bookmarkId]); + } + + Future togglePinTab(String tabId, bool pin) async { + await _callBrowserAPI('togglePinTab', [tabId, pin]); + } + + Future _callBrowserAPI(String method, [List? 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. \ No newline at end of file diff --git a/lib/services/extension_service.dart b/lib/services/extension_service.dart new file mode 100644 index 0000000..1d80146 --- /dev/null +++ b/lib/services/extension_service.dart @@ -0,0 +1,80 @@ +import 'dart:html' as html; +import '../models/tab_data.dart'; + +class ExtensionService { + Function(List)? 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.from(data)); + } + }); + } + + void _handleExtensionMessage(Map 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 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. \ No newline at end of file diff --git a/lib/utils/helpers.dart b/lib/utils/helpers.dart new file mode 100644 index 0000000..9e83568 --- /dev/null +++ b/lib/utils/helpers.dart @@ -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 getItemStats(List 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 filterItems( + List 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 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 \ No newline at end of file diff --git a/lib/widgets/app_bar_actions.dart b/lib/widgets/app_bar_actions.dart new file mode 100644 index 0000000..a028536 --- /dev/null +++ b/lib/widgets/app_bar_actions.dart @@ -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( + 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. \ No newline at end of file diff --git a/lib/widgets/filter_chips.dart b/lib/widgets/filter_chips.dart new file mode 100644 index 0000000..0f80c8a --- /dev/null +++ b/lib/widgets/filter_chips.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import '../models/tab_data.dart'; + +class FilterChips extends StatelessWidget { + final List 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. \ No newline at end of file diff --git a/lib/widgets/item_card.dart b/lib/widgets/item_card.dart new file mode 100644 index 0000000..5e831f7 --- /dev/null +++ b/lib/widgets/item_card.dart @@ -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. \ No newline at end of file diff --git a/lib/widgets/item_list_tile.dart b/lib/widgets/item_list_tile.dart new file mode 100644 index 0000000..c49f110 --- /dev/null +++ b/lib/widgets/item_list_tile.dart @@ -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. \ No newline at end of file diff --git a/lib/widgets/search_bar.dart b/lib/widgets/search_bar.dart new file mode 100644 index 0000000..290553d --- /dev/null +++ b/lib/widgets/search_bar.dart @@ -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. \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..9c4c44e --- /dev/null +++ b/nginx.conf @@ -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 \ No newline at end of file diff --git a/package.sh b/package.sh new file mode 100755 index 0000000..0cf0d55 --- /dev/null +++ b/package.sh @@ -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. diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..04b9be2 --- /dev/null +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..7f18030 --- /dev/null +++ b/pubspec.yaml @@ -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. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/setup-extension.sh b/setup-extension.sh new file mode 100755 index 0000000..a1cba15 --- /dev/null +++ b/setup-extension.sh @@ -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) \ No newline at end of file diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..76f052e --- /dev/null +++ b/setup.sh @@ -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. \ No newline at end of file diff --git a/tab-tracker-extension/background.js b/tab-tracker-extension/background.js new file mode 100644 index 0000000..1dba8db --- /dev/null +++ b/tab-tracker-extension/background.js @@ -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(); + } +}); \ No newline at end of file diff --git a/tab-tracker-extension/content.js b/tab-tracker-extension/content.js new file mode 100644 index 0000000..becac11 --- /dev/null +++ b/tab-tracker-extension/content.js @@ -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'); \ No newline at end of file diff --git a/tab-tracker-extension/icons/icon128.png b/tab-tracker-extension/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..48045a5ec86a43d6a42bf002728b17cb8d6bbe01 GIT binary patch literal 2425 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 b+wp$@$$?a1=4I+H00000NkvXXu0mjfprP)e literal 0 HcmV?d00001 diff --git a/tab-tracker-extension/icons/icon48.png b/tab-tracker-extension/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..c6b0c3d80b91a7e5ba0272cf5e5e0667344ef6ba GIT binary patch literal 1525 zcmVqGfFP)EGRy3 zBk~vuPOEmKgH;MH_((v7i0I}53W$&BGC&a0P%YRwF=#}E{ox*&am%uZF=kojh+Ag# zyPQj{tL^Q>?cN@PH~A#w()0Lm%l)16JLgCweqIb?%4HbSPf}?C0s(28VN3>zke5`N zpFtW69a;e8NTYBJ&x0}~*Daw#Om^wQG{Hm}rl6HHa(+oRke2fQlbQl7UJkq-4=7o; zoQqug_{p{iCMuN{bdg5-GBSY}{?54LraS@HCMG#?V{)QjbrAaG+Q(nxF5yMfGlAE9 z7ww*y#QQ%-o)EpFi&y&9x!bRfKFX{iNORW?DXtUQb2 z;(Bb~{<#xI<5BTMySu-|_3PKs+uJJ$Jv}|>?CgZiW^9Vq z*ViF3ayu_7RT)m7?r`a}#nO(cQ+FaFqR1^WSrOK+Kjc*A$dQwQ6OE1C;q@(9TifCy zO73|pN9un3`S$j9?A==vc+p7*`Z2y19OG($A>}~uw4+`{S1hUker^VC9vk5MPjJ_! za}jN8YokMS{HOhp?7|aD9w9iY);G~_ih-+Z9o%V4r&n}n@l&FOe<65MjdxLP32;^C zBzn6skzP?rkzI7>z|)lE-}g;4KJSU-l{G+jHu@U0^oj;8=|oUc7lP(}iJ&)a9L(r) zucE3cM${C2fP=<&QDu0WqgTwD5MG{En|2sncrhphuKtD?GkX zLPIyBsp+(fB)JzFQs}WPshhg+B9HqJ(V83wlUY5&#^iYoFB8HyAytv%MDnPO^o5sL zyJ%`_8j{-*IhL8V&@?T;*x75n4;bVIU}-WCw|IbA3voB(FY^_sfvDcozrBakVqIMFUQ!^9cQ+yB|v(Y7gd)}VErBXERFbhrNe9Swn!kp?E zn6Xdko9GA~C-*?f;*LU%+e$S$FxUpm5YRSQL=PQm#fA<1 zQ!O$Trxyz2}Pv1n}8nt2Zmm;CFfQ#}rpIooq~I{ob=n%F2dr;vhfYi`Gys zn&JOeb3*)Zz2k|hhwJHmqKy6Uz*o<5cwpr__=8=B{o_(O`lHnzr5-6+{HRdlYlQ~A z$^m*58hjR+%!!h|2qr3(7Nq~@;~-(~k9j;zR7fr+xFN)G93($OBILy|CPUy4_!Q*% b+wp$@$$?a1=4I+H00000NkvXXu0mjfprP)e literal 0 HcmV?d00001 diff --git a/tab-tracker-extension/manifest.json b/tab-tracker-extension/manifest.json new file mode 100644 index 0000000..ebe7685 --- /dev/null +++ b/tab-tracker-extension/manifest.json @@ -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" + } +} \ No newline at end of file diff --git a/tab-tracker-extension/popup.html b/tab-tracker-extension/popup.html new file mode 100644 index 0000000..ae3f144 --- /dev/null +++ b/tab-tracker-extension/popup.html @@ -0,0 +1,79 @@ + + + + + + + + +

Tab Tracker

+ +
+ Not Tracking +
+ + + + +
+

Tracked tabs: 0

+

Visit tab.caesargaming.org to view your tabs.

+
+ + + + \ No newline at end of file diff --git a/tab-tracker-extension/popup.js b/tab-tracker-extension/popup.js new file mode 100644 index 0000000..9e480ad --- /dev/null +++ b/tab-tracker-extension/popup.js @@ -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); \ No newline at end of file diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..00d4b0f --- /dev/null +++ b/test/widget_test.dart @@ -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); + }); +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 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 literal 0 HcmV?d00001 diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 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 literal 0 HcmV?d00001 diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9b4d76e525556d5d89141648c724331630325d GIT binary patch literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! literal 0 HcmV?d00001 diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d69c56691fbdb0b7efa65097c7cc1edac12a6d3e GIT binary patch literal 20998 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 MPgg&ebxsLQ025IeI{*Lx literal 0 HcmV?d00001 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..1d055a6 --- /dev/null +++ b/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + browser_tab_manager + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..f35e95f --- /dev/null +++ b/web/manifest.json @@ -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 +} \ No newline at end of file diff --git a/web/manifest.md b/web/manifest.md new file mode 100644 index 0000000..bfc3b76 --- /dev/null +++ b/web/manifest.md @@ -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. \ No newline at end of file