commit 58d9d1f27b981457024046d9184e9c572af6cf66 Author: jsnk Date: Sun Oct 19 21:02:06 2025 +0200 Initial commit: Browser Tab Manager Flutter app 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 0000000..0903211 Binary files /dev/null and b/dist/browser-tab-manager.tar.gz differ diff --git a/dist/browser-tab-manager/Dockerfile b/dist/browser-tab-manager/Dockerfile new file mode 100644 index 0000000..daf3f3f --- /dev/null +++ b/dist/browser-tab-manager/Dockerfile @@ -0,0 +1,52 @@ +FROM debian:bullseye-slim AS builder + +# Install dependencies +RUN apt-get update && apt-get install -y \ + curl \ + git \ + unzip \ + xz-utils \ + && rm -rf /var/lib/apt/lists/* + +# Create flutter user +RUN useradd -m -u 1000 flutter + +# Switch to flutter user +USER flutter + +# Install Flutter +RUN git clone https://github.com/flutter/flutter.git /home/flutter/flutter -b stable --depth 1 +ENV PATH="/home/flutter/flutter/bin:${PATH}" + +# Configure Flutter +RUN flutter config --no-analytics && \ + flutter config --enable-web && \ + flutter precache --web + +# Set working directory +WORKDIR /home/flutter/app + +# Copy project files +COPY --chown=flutter:flutter . . + +# Enable web for this project +RUN flutter create . --platforms web + +# Get dependencies +RUN flutter pub get + +# Build web app +RUN flutter build web --release + +# Production stage +FROM nginx:alpine + +# Copy built web app +COPY --from=builder /home/flutter/app/build/web /usr/share/nginx/html + +# Copy nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/dist/browser-tab-manager/deploy.sh b/dist/browser-tab-manager/deploy.sh new file mode 100755 index 0000000..26ad299 --- /dev/null +++ b/dist/browser-tab-manager/deploy.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# Server Deployment Script +set -e + +echo "🚀 Deploying Browser Tab Manager on Server..." + +# Ensure we're in the right directory +if [ ! -f "Dockerfile" ]; then + echo "❌ Error: Dockerfile not found. Are you in the right directory?" + exit 1 +fi + +# Create web/manifest.json if missing +mkdir -p web +if [ ! -f "web/manifest.json" ]; then + cat > web/manifest.json << 'EOF' +{ + "name": "Browser Tab Manager", + "short_name": "TabManager", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "Manage browser tabs in a grid view", + "orientation": "portrait-primary", + "prefer_related_applications": false +} +EOF +fi + +echo "✅ Project structure ready" + +# Build the container +echo "🔨 Building Podman container..." +podman build -t browser-tab-manager . + +# Stop and remove existing container if running +podman stop browser-tab-manager 2>/dev/null || true +podman rm browser-tab-manager 2>/dev/null || true + +# Run the container on port 8081 +echo "🚢 Starting container..." +podman run -d \ + --name browser-tab-manager \ + -p 8081:80 \ + --restart unless-stopped \ + browser-tab-manager + +echo "✅ Container started successfully!" +echo "" +echo "🌐 Your Browser Tab Manager is now running at:" +SERVER_IP=$(hostname -I | awk '{print $1}' 2>/dev/null || echo "your-server-ip") +echo " http://${SERVER_IP}:8081" +echo "" +echo "📝 Useful commands:" +echo " View logs: podman logs -f browser-tab-manager" +echo " Stop: podman stop browser-tab-manager" +echo " Start: podman start browser-tab-manager" +echo " Restart: podman restart browser-tab-manager" +echo " Remove: podman rm -f browser-tab-manager" 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 0000000..48045a5 Binary files /dev/null and b/tab-tracker-extension/icons/icon128.png differ diff --git a/tab-tracker-extension/icons/icon16.png b/tab-tracker-extension/icons/icon16.png new file mode 100644 index 0000000..c6b0c3d Binary files /dev/null and b/tab-tracker-extension/icons/icon16.png differ diff --git a/tab-tracker-extension/icons/icon48.png b/tab-tracker-extension/icons/icon48.png new file mode 100644 index 0000000..c6b0c3d Binary files /dev/null and b/tab-tracker-extension/icons/icon48.png differ 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 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ 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