Initial commit: Browser Tab Manager Flutter app

This commit is contained in:
jsnk 2025-10-19 21:02:06 +02:00
commit 58d9d1f27b
50 changed files with 5617 additions and 0 deletions

45
.gitignore vendored Normal file
View file

@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

30
.metadata Normal file
View file

@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "9f455d2486bcb28cad87b062475f42edc959f636"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: web
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

63
Dockerfile Normal file
View file

@ -0,0 +1,63 @@
FROM debian:bullseye-slim AS builder
# Install dependencies
RUN apt-get update && apt-get install -y \
curl \
git \
unzip \
xz-utils \
&& rm -rf /var/lib/apt/lists/*
# Create flutter user
RUN useradd -m -u 1000 flutter
# Switch to flutter user
USER flutter
# Install Flutter
RUN git clone https://github.com/flutter/flutter.git /home/flutter/flutter -b stable --depth 1
ENV PATH="/home/flutter/flutter/bin:${PATH}"
# Configure Flutter
RUN flutter config --no-analytics && \
flutter config --enable-web && \
flutter precache --web
# Set working directory
WORKDIR /home/flutter/app
# Copy project files
COPY --chown=flutter:flutter . .
# Enable web for this project
RUN flutter create . --platforms web
# Get dependencies
RUN flutter pub get
# Build web app
RUN flutter build web --release
# Production stage
FROM nginx:alpine
# Copy built web app
COPY --from=builder /home/flutter/app/build/web /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# This script packages the app for deployment to a server.
# It copies all necessary files into a dist folder.
# Creates a deploy.sh script that will run on the server.
# Compresses everything into a tar.gz file for easy transfer.
# Shows instructions to copy the package to the server and deploy it.

421
README.md Normal file
View file

@ -0,0 +1,421 @@
# browser_tab_manager
## Getting Started
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
- [online documentation](https://docs.flutter.dev/)
# 🧠 Browser Tab Manager - Complete Code Study Guide
## 📋 Table of Contents
1. [App Architecture Overview](#architecture)
2. [Entry Point - main.dart](#main-dart)
3. [Data Layer - Models](#models)
4. [Service Layer](#services)
5. [UI Layer - Screens](#screens)
6. [Component Layer - Widgets](#widgets)
7. [Utilities & Constants](#utilities)
8. [Data Flow & State Management](#data-flow)
9. [Browser API Integration](#browser-api)
10. [Extension Communication](#extension-comm)
---
## 🏗️ App Architecture Overview {#architecture}
```
┌─────────────────────────────────────────┐
│ main.dart │
│ (App Entry Point) │
└─────────────────┬───────────────────────┘
┌─────────────────▼───────────────────────┐
│ TabManagerHome │
│ (Main Screen State) │
└─────┬─────────────────────────────┬─────┘
│ │
┌─────▼─────┐ ┌─────▼─────┐
│ Services │ │ Widgets │
│ Layer │ │ Layer │
└─────┬─────┘ └─────┬─────┘
│ │
┌─────▼─────┐ ┌─────▼─────┐
│ Models │ │ Utils &
│ Layer │ │ Constants │
└───────────┘ └───────────┘
```
### Key Concepts:
- **Separation of Concerns**: Each layer has specific responsibilities
- **Unidirectional Data Flow**: Data flows down, events flow up
- **State Management**: Centralized in TabManagerHome using setState()
- **Service Communication**: Browser APIs and Extension messaging
---
## 🚀 Entry Point - main.dart {#main-dart}
```dart
void main() {
runApp(const BrowserTabManagerApp());
}
```
**WHAT HAPPENS HERE:**
1. `main()` is the entry point of every Dart/Flutter app
2. `runApp()` tells Flutter to start the app with our root widget
3. Creates the widget tree and starts the rendering engine
```dart
class BrowserTabManagerApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Browser Tab Manager',
theme: ThemeData(...),
home: const TabManagerHome(),
);
}
}
```
**BREAKDOWN:**
- `StatelessWidget`: Never changes its appearance based on internal state
- `MaterialApp`: Root widget that provides Material Design theming
- `theme` & `darkTheme`: Define app-wide visual styling
- `home`: The first screen users see (TabManagerHome)
---
## 📊 Data Layer - Models {#models}
### TabData Model (`models/tab_data.dart`)
```dart
class TabData {
String id; // Unique identifier
String title; // Display name
String url; // Web address
String favicon; // Icon URL
DateTime lastAccessed; // When last used
bool isPinned; // Pinned status
String type; // 'tab', 'bookmark', or 'history'
int? visitCount; // Number of visits (nullable)
String? folder; // Bookmark folder (nullable)
}
```
**PURPOSE:**
- **Data Structure**: Represents all types of browser items uniformly
- **Type Safety**: Dart ensures correct data types
- **Nullable Fields**: `?` means the field can be null
### Factory Constructor:
```dart
factory TabData.fromJson(Map<String, dynamic> json) => TabData(
id: json['id'].toString(),
title: json['title'] ?? 'Untitled', // ?? means "if null, use default"
// ... more fields
);
```
**WHAT IT DOES:**
- Converts JSON data from browser APIs into TabData objects
- Handles missing/null values gracefully with defaults
- Standardizes different API response formats
---
## 🔧 Service Layer {#services}
### BrowserApiService (`services/browser_api_service.dart`)
```dart
class BrowserApiService {
Future<List<TabData>> getTabs() async {
final result = await _callBrowserAPI('getTabs');
// Convert raw data to TabData objects
}
}
```
**KEY CONCEPTS:**
- `async/await`: Handles asynchronous operations
- `Future<T>`: Represents a value that will be available later
- **Abstraction**: Hides complex browser API details
### ExtensionService (`services/extension_service.dart`)
```dart
void setupListener() {
html.window.onMessage.listen((event) => {
// Handle messages from browser extension
});
}
```
**PURPOSE:**
- **Communication Bridge**: Between web app and browser extension
- **Event-Driven**: Responds to messages from extension
- **Callback Pattern**: Uses function pointers for responses
---
## 🎨 UI Layer - Screens {#screens}
### TabManagerHome (`screens/tab_manager_home.dart`)
This is the **BRAIN** of the application - it manages all state and coordinates everything.
#### State Variables:
```dart
class _TabManagerHomeState extends State<TabManagerHome> {
List<TabData> allItems = []; // ALL data from browser
List<TabData> filteredItems = []; // DISPLAYED data (after search/filter)
bool isGridView = true; // View mode toggle
String sortBy = 'recent'; // Current sort method
String filterType = 'all'; // Current filter
bool isLoading = true; // Loading spinner state
bool extensionMode = false; // Extension tracking mode
}
```
#### Key Methods:
**initState()** - Runs when widget is created:
```dart
@override
void initState() {
super.initState(); // Call parent setup
_setupExtensionService(); // Start listening for extension
_loadAllData(); // Load browser data
searchController.addListener(_filterItems); // Watch search input
}
```
**setState()** - The magic that updates UI:
```dart
setState(() {
allItems = newData; // Change state
_filterItems(); // Update filtered view
});
// Flutter automatically rebuilds UI after setState finishes
```
---
## 🧩 Component Layer - Widgets {#widgets}
### ItemCard (`widgets/item_card.dart`)
**STATELESS WIDGET** - Displays data, doesn't manage state:
```dart
class ItemCard extends StatelessWidget {
final TabData item; // Data from parent
final VoidCallback onTap; // Function to call when tapped
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
onTap: onTap, // Call parent's function
child: // ... UI layout
),
);
}
}
```
**DATA FLOW:**
1. Parent passes data and callback functions
2. Widget displays the data
3. User interaction calls parent's functions
4. Parent updates state and rebuilds widget with new data
### SearchBar (`widgets/search_bar.dart`)
```dart
TextField(
controller: controller, // Links to parent's TextEditingController
onChanged: (text) => { // Calls parent when text changes
// Parent handles the search logic
},
)
```
---
## 🛠️ Utilities & Constants {#utilities}
### Helpers (`utils/helpers.dart`)
```dart
static List<TabData> filterItems(
List<TabData> items,
String query,
String filterType,
) {
return items.where((item) => {
// Filter logic here
}).toList();
}
```
**PURPOSE:**
- **Pure Functions**: Same input always produces same output
- **Reusable Logic**: Can be used anywhere in the app
- **Testing**: Easy to unit test
### Constants (`constants/app_constants.dart`)
```dart
class AppConstants {
static const Color primaryColor = Color(0xFF0175C2);
static const String extensionSource = 'tab-tracker-extension';
}
```
**BENEFITS:**
- **Single Source of Truth**: Change value in one place
- **Type Safety**: Compile-time checking
- **Maintainability**: Easy to update app-wide settings
---
## 🔄 Data Flow & State Management {#data-flow}
### Complete Data Flow Cycle:
```
1. User Action (tap, type, click)
2. Widget calls parent function
3. Parent updates state with setState()
4. Flutter rebuilds widget tree
5. UI reflects new state
```
### Example: Search Flow
```dart
// 1. User types in search bar
SearchBar(onChanged: (text) => {
// 2. SearchBar calls parent's function
_filterItems();
});
// 3. Parent filters data and updates state
void _filterItems() {
setState(() => {
filteredItems = // ... filter logic
});
// 4. Flutter rebuilds UI with new filteredItems
}
```
---
## 🌐 Browser API Integration {#browser-api}
### How Browser APIs Work:
```dart
Future<String?> _callBrowserAPI(String method, [List<dynamic>? args]) async {
// 1. Check if browser API exists
if (!js_util.hasProperty(html.window, 'BrowserAPI')) {
return null; // Development mode
}
// 2. Get the API object from browser
final browserAPI = js_util.getProperty(html.window, 'BrowserAPI');
// 3. Call the specific method
final result = await js_util.promiseToFuture(
js_util.callMethod(function, 'call', [browserAPI, ...args])
);
return json.encode(result); // 4. Return as JSON string
}
```
**STEPS:**
1. **Check Availability**: Is the browser API injected?
2. **Get Reference**: Access the API object
3. **Call Method**: Execute with parameters
4. **Handle Response**: Convert to usable format
---
## 📡 Extension Communication {#extension-comm}
### Message Passing System:
```dart
// SENDING to extension:
html.window.postMessage({
'source': 'tab-tracker-webapp',
'action': 'startTracking'
}, '*');
// RECEIVING from extension:
html.window.onMessage.listen((event) => {
final data = event.data;
if (data['source'] == 'tab-tracker-extension') {
_handleExtensionMessage(data);
}
});
```
**COMMUNICATION FLOW:**
```
Web App ←→ Browser Window ←→ Extension
```
### Message Types:
- `startTracking`: Begin tab monitoring
- `stopTracking`: Stop tab monitoring
- `updateTabs`: Extension sends current tabs
- `getStatus`: Request current state
---
## 🎯 Key Learning Points
### 1. **State Management Pattern**
- State lives in parent components
- Children receive data and callback functions
- setState() triggers UI rebuilds
### 2. **Async Programming**
- `async/await` for non-blocking operations
- `Future<T>` represents eventual values
- Error handling with try/catch
### 3. **Widget Communication**
- Parent-to-child: Pass data via constructor
- Child-to-parent: Pass callback functions
- Sibling-to-sibling: Through common parent
### 4. **Service Layer Benefits**
- Separates business logic from UI
- Makes testing easier
- Provides clean abstractions
### 5. **Browser Integration**
- JavaScript interop for browser APIs
- Message passing for extension communication
- Graceful degradation for development mode
---
## 🔍 Next Steps for Deep Learning
1. **Trace a complete user action** from UI tap to state update
2. **Follow data transformation** from browser API to UI display
3. **Understand lifecycle methods** and when they're called
4. **Practice modifying** one small feature at a time
5. **Add logging** to see the flow in action
This architecture provides a solid foundation for building complex, maintainable Flutter web applications! 🚀

1207
_backup/main.dart Normal file

File diff suppressed because it is too large Load diff

28
analysis_options.yaml Normal file
View file

@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

11
app/manifest.json Normal file
View file

@ -0,0 +1,11 @@
{
"name": "Browser Tab Manager",
"short_name": "TabManager",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "Manage browser tabs in a grid view",
"orientation": "portrait-primary",
"prefer_related_applications": false
}

BIN
dist/browser-tab-manager.tar.gz vendored Normal file

Binary file not shown.

52
dist/browser-tab-manager/Dockerfile vendored Normal file
View file

@ -0,0 +1,52 @@
FROM debian:bullseye-slim AS builder
# Install dependencies
RUN apt-get update && apt-get install -y \
curl \
git \
unzip \
xz-utils \
&& rm -rf /var/lib/apt/lists/*
# Create flutter user
RUN useradd -m -u 1000 flutter
# Switch to flutter user
USER flutter
# Install Flutter
RUN git clone https://github.com/flutter/flutter.git /home/flutter/flutter -b stable --depth 1
ENV PATH="/home/flutter/flutter/bin:${PATH}"
# Configure Flutter
RUN flutter config --no-analytics && \
flutter config --enable-web && \
flutter precache --web
# Set working directory
WORKDIR /home/flutter/app
# Copy project files
COPY --chown=flutter:flutter . .
# Enable web for this project
RUN flutter create . --platforms web
# Get dependencies
RUN flutter pub get
# Build web app
RUN flutter build web --release
# Production stage
FROM nginx:alpine
# Copy built web app
COPY --from=builder /home/flutter/app/build/web /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

60
dist/browser-tab-manager/deploy.sh vendored Executable file
View file

@ -0,0 +1,60 @@
#!/bin/bash
# Server Deployment Script
set -e
echo "🚀 Deploying Browser Tab Manager on Server..."
# Ensure we're in the right directory
if [ ! -f "Dockerfile" ]; then
echo "❌ Error: Dockerfile not found. Are you in the right directory?"
exit 1
fi
# Create web/manifest.json if missing
mkdir -p web
if [ ! -f "web/manifest.json" ]; then
cat > web/manifest.json << 'EOF'
{
"name": "Browser Tab Manager",
"short_name": "TabManager",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "Manage browser tabs in a grid view",
"orientation": "portrait-primary",
"prefer_related_applications": false
}
EOF
fi
echo "✅ Project structure ready"
# Build the container
echo "🔨 Building Podman container..."
podman build -t browser-tab-manager .
# Stop and remove existing container if running
podman stop browser-tab-manager 2>/dev/null || true
podman rm browser-tab-manager 2>/dev/null || true
# Run the container on port 8081
echo "🚢 Starting container..."
podman run -d \
--name browser-tab-manager \
-p 8081:80 \
--restart unless-stopped \
browser-tab-manager
echo "✅ Container started successfully!"
echo ""
echo "🌐 Your Browser Tab Manager is now running at:"
SERVER_IP=$(hostname -I | awk '{print $1}' 2>/dev/null || echo "your-server-ip")
echo " http://${SERVER_IP}:8081"
echo ""
echo "📝 Useful commands:"
echo " View logs: podman logs -f browser-tab-manager"
echo " Stop: podman stop browser-tab-manager"
echo " Start: podman start browser-tab-manager"
echo " Restart: podman restart browser-tab-manager"
echo " Remove: podman rm -f browser-tab-manager"

890
dist/browser-tab-manager/lib/main.dart vendored Normal file
View file

@ -0,0 +1,890 @@
import 'package:flutter/material.dart';
import 'dart:html' as html;
import 'dart:convert';
import 'dart:js_util' as js_util;
void main() {
runApp(const BrowserTabManagerApp());
}
class BrowserTabManagerApp extends StatelessWidget {
const BrowserTabManagerApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Browser Tab Manager',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF0175C2),
brightness: Brightness.light,
),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF0175C2),
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const TabManagerHome(),
);
}
}
class TabData {
String id;
String title;
String url;
String favicon;
DateTime lastAccessed;
bool isPinned;
String type; // 'tab', 'bookmark', 'history'
int? visitCount;
String? folder;
TabData({
required this.id,
required this.title,
required this.url,
this.favicon = '',
DateTime? lastAccessed,
this.isPinned = false,
this.type = 'tab',
this.visitCount,
this.folder,
}) : lastAccessed = lastAccessed ?? DateTime.now();
factory TabData.fromJson(Map<String, dynamic> json) => TabData(
id: json['id'].toString(),
title: json['title'] ?? 'Untitled',
url: json['url'] ?? '',
favicon: json['favicon'] ?? json['favIconUrl'] ?? '',
lastAccessed: json['lastAccessed'] != null
? DateTime.parse(json['lastAccessed'])
: (json['lastVisitTime'] != null
? DateTime.parse(json['lastVisitTime'])
: (json['dateAdded'] != null
? DateTime.parse(json['dateAdded'])
: (json['timestamp'] != null
? DateTime.parse(json['timestamp'])
: DateTime.now()))),
isPinned: json['isPinned'] ?? false,
type: json['type'] ?? 'tab',
visitCount: json['visitCount'],
folder: json['folder'],
);
}
class TabManagerHome extends StatefulWidget {
const TabManagerHome({super.key});
@override
State<TabManagerHome> createState() => _TabManagerHomeState();
}
class _TabManagerHomeState extends State<TabManagerHome> {
List<TabData> allItems = [];
List<TabData> filteredItems = [];
final TextEditingController searchController = TextEditingController();
bool isGridView = true;
String sortBy = 'recent';
String filterType = 'all'; // 'all', 'tabs', 'bookmarks', 'history'
bool isLoading = true;
bool extensionConnected = false;
bool extensionMode = false;
@override
void initState() {
super.initState();
_setupExtensionListener();
_loadAllData();
searchController.addListener(_filterItems);
}
@override
void dispose() {
searchController.dispose();
super.dispose();
}
// Setup extension listener
void _setupExtensionListener() {
html.window.onMessage.listen((event) {
final data = event.data;
if (data is Map && data['source'] == 'tab-tracker-extension') {
_handleExtensionMessage(Map<String, dynamic>.from(data));
}
});
}
// Handle messages from extension
void _handleExtensionMessage(Map<String, dynamic> data) {
print('Received from extension: $data');
if (data['action'] == 'updateTabs') {
setState(() {
final extensionTabs = (data['tabs'] as List).map((tab) {
tab['type'] = 'tab';
return TabData.fromJson(tab);
}).toList();
allItems = extensionTabs;
extensionConnected = true;
extensionMode = true;
_filterItems();
});
} else if (data['action'] == 'clear') {
if (extensionMode) {
setState(() {
extensionMode = false;
_loadAllData();
});
}
} else if (data['response'] != null) {
final response = data['response'];
if (response['status'] == 'started') {
setState(() {
extensionMode = true;
extensionConnected = true;
});
} else if (response['status'] == 'stopped') {
setState(() {
extensionMode = false;
_loadAllData();
});
}
}
}
// Send message to extension
void _sendToExtension(Map<String, dynamic> message) {
html.window.postMessage({
'source': 'tab-tracker-webapp',
...message
}, '*');
}
// Start extension tracking
void _startExtensionTracking() {
_sendToExtension({'action': 'startTracking'});
setState(() {
extensionMode = true;
allItems.clear();
});
}
// Stop extension tracking
void _stopExtensionTracking() {
_sendToExtension({'action': 'stopTracking'});
setState(() {
extensionMode = false;
});
_loadAllData();
}
Future<void> _loadAllData() async {
if (extensionMode) return; // Don't load if in extension mode
setState(() {
isLoading = true;
});
try {
final tabs = await _getTabs();
final bookmarks = await _getBookmarks();
final history = await _getHistory();
setState(() {
allItems = [...tabs, ...bookmarks, ...history];
_filterItems();
isLoading = false;
});
// Check if extension is available
_checkExtensionConnection();
} catch (e) {
print('Error loading data: $e');
setState(() {
isLoading = false;
});
}
}
void _checkExtensionConnection() {
_sendToExtension({'action': 'getStatus'});
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
setState(() {
// extensionConnected will be set by message handler if extension responds
});
}
});
}
Future<List<TabData>> _getTabs() async {
try {
final result = await _callBrowserAPI('getTabs');
if (result != null) {
final List<dynamic> data = json.decode(result);
return data.map((item) {
item['type'] = 'tab';
return TabData.fromJson(item);
}).toList();
}
} catch (e) {
print('Error getting tabs: $e');
}
return [];
}
Future<List<TabData>> _getBookmarks() async {
try {
final result = await _callBrowserAPI('getBookmarks');
if (result != null) {
final List<dynamic> data = json.decode(result);
return data.map((item) {
item['type'] = 'bookmark';
return TabData.fromJson(item);
}).toList();
}
} catch (e) {
print('Error getting bookmarks: $e');
}
return [];
}
Future<List<TabData>> _getHistory() async {
try {
final result = await _callBrowserAPI('getHistory', [100]);
if (result != null) {
final List<dynamic> data = json.decode(result);
return data.map((item) {
item['type'] = 'history';
return TabData.fromJson(item);
}).toList();
}
} catch (e) {
print('Error getting history: $e');
}
return [];
}
Future<String?> _callBrowserAPI(String method, [List<dynamic>? args]) async {
try {
// Check if BrowserAPI exists
if (!js_util.hasProperty(html.window, 'BrowserAPI')) {
print('BrowserAPI not found - running in development mode');
return null;
}
final browserAPI = js_util.getProperty(html.window, 'BrowserAPI');
final function = js_util.getProperty(browserAPI, method);
final result = args == null
? await js_util.promiseToFuture(js_util.callMethod(function, 'call', [browserAPI]))
: await js_util.promiseToFuture(js_util.callMethod(function, 'call', [browserAPI, ...args]));
return json.encode(result);
} catch (e) {
print('Error calling $method: $e');
return null;
}
}
void _filterItems() {
final query = searchController.text.toLowerCase();
setState(() {
filteredItems = allItems.where((item) {
// Filter by type
if (filterType != 'all' && item.type != filterType.replaceAll('s', '')) {
return false;
}
// Filter by search query
if (query.isNotEmpty) {
return item.title.toLowerCase().contains(query) ||
item.url.toLowerCase().contains(query);
}
return true;
}).toList();
_sortItems();
});
}
void _sortItems() {
switch (sortBy) {
case 'recent':
filteredItems.sort((a, b) => b.lastAccessed.compareTo(a.lastAccessed));
break;
case 'title':
filteredItems.sort((a, b) => a.title.compareTo(b.title));
break;
case 'url':
filteredItems.sort((a, b) => a.url.compareTo(b.url));
break;
case 'visits':
filteredItems.sort((a, b) =>
(b.visitCount ?? 0).compareTo(a.visitCount ?? 0));
break;
}
// Keep pinned tabs at the top
filteredItems.sort((a, b) => b.isPinned ? 1 : (a.isPinned ? -1 : 0));
}
Future<void> _openItem(TabData item) async {
if (extensionMode) {
// In extension mode, just open URL in new tab
html.window.open(item.url, '_blank');
} else {
if (item.type == 'tab') {
await _callBrowserAPI('switchToTab', [item.id]);
} else {
await _callBrowserAPI('openTab', [item.url]);
}
}
}
Future<void> _deleteItem(TabData item) async {
if (extensionMode) return; // Can't delete in extension mode
if (item.type == 'tab') {
await _callBrowserAPI('closeTab', [item.id]);
} else if (item.type == 'bookmark') {
await _callBrowserAPI('removeBookmark', [item.id]);
}
await _loadAllData();
}
Future<void> _togglePin(TabData item) async {
if (extensionMode) return; // Can't pin in extension mode
if (item.type == 'tab') {
await _callBrowserAPI('togglePinTab', [item.id, !item.isPinned]);
await _loadAllData();
}
}
String _getIcon(TabData item) {
if (item.favicon.isNotEmpty && !item.favicon.contains('data:')) {
return '🌐';
}
switch (item.type) {
case 'tab':
return '📑';
case 'bookmark':
return '';
case 'history':
return '🕐';
default:
return '🌐';
}
}
@override
Widget build(BuildContext context) {
final stats = {
'tabs': allItems.where((i) => i.type == 'tab').length,
'bookmarks': allItems.where((i) => i.type == 'bookmark').length,
'history': allItems.where((i) => i.type == 'history').length,
};
return Scaffold(
appBar: AppBar(
title: Row(
children: [
const Text('Browser Tab Manager'),
if (extensionMode) ...[
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'TRACKING',
style: TextStyle(fontSize: 10, color: Colors.white),
),
),
],
],
),
actions: [
// Extension control button
if (extensionMode)
TextButton.icon(
onPressed: _stopExtensionTracking,
icon: const Icon(Icons.stop, color: Colors.white),
label: const Text('Stop', style: TextStyle(color: Colors.white)),
)
else
TextButton.icon(
onPressed: extensionConnected ? _startExtensionTracking : null,
icon: const Icon(Icons.play_arrow, color: Colors.white),
label: const Text('Track Tabs', style: TextStyle(color: Colors.white)),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: extensionMode ? null : _loadAllData,
tooltip: 'Refresh',
),
IconButton(
icon: Icon(isGridView ? Icons.view_list : Icons.grid_view),
onPressed: () {
setState(() {
isGridView = !isGridView;
});
},
tooltip: isGridView ? 'List View' : 'Grid View',
),
PopupMenuButton<String>(
icon: const Icon(Icons.sort),
tooltip: 'Sort by',
onSelected: (value) {
setState(() {
sortBy = value;
_filterItems();
});
},
itemBuilder: (context) => [
const PopupMenuItem(value: 'recent', child: Text('Recent')),
const PopupMenuItem(value: 'title', child: Text('Title')),
const PopupMenuItem(value: 'url', child: Text('URL')),
const PopupMenuItem(value: 'visits', child: Text('Most Visited')),
],
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: searchController,
decoration: InputDecoration(
hintText: extensionMode
? 'Search tracked tabs...'
: 'Search tabs, bookmarks, and history...',
prefixIcon: const Icon(Icons.search),
suffixIcon: searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
searchController.clear();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 12),
if (!extensionMode)
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
FilterChip(
label: Text('All (${allItems.length})'),
selected: filterType == 'all',
onSelected: (selected) {
setState(() {
filterType = 'all';
_filterItems();
});
},
),
const SizedBox(width: 8),
FilterChip(
label: Text('Tabs (${stats['tabs']})'),
selected: filterType == 'tabs',
onSelected: (selected) {
setState(() {
filterType = 'tabs';
_filterItems();
});
},
),
const SizedBox(width: 8),
FilterChip(
label: Text('Bookmarks (${stats['bookmarks']})'),
selected: filterType == 'bookmarks',
onSelected: (selected) {
setState(() {
filterType = 'bookmarks';
_filterItems();
});
},
),
const SizedBox(width: 8),
FilterChip(
label: Text('History (${stats['history']})'),
selected: filterType == 'history',
onSelected: (selected) {
setState(() {
filterType = 'history';
_filterItems();
});
},
),
],
),
),
],
),
),
Expanded(
child: isLoading
? const Center(child: CircularProgressIndicator())
: filteredItems.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
extensionMode ? Icons.track_changes : Icons.search_off,
size: 80,
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
extensionMode
? 'Waiting for tabs...'
: 'No items found',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
extensionMode
? 'Open some tabs to see them here'
: 'Try a different search or filter',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
)
: isGridView
? GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 300,
childAspectRatio: 1.3,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: filteredItems.length,
itemBuilder: (context, index) {
return ItemCard(
item: filteredItems[index],
icon: _getIcon(filteredItems[index]),
onTap: () => _openItem(filteredItems[index]),
onDelete: () =>
_deleteItem(filteredItems[index]),
onTogglePin: () =>
_togglePin(filteredItems[index]),
extensionMode: extensionMode,
);
},
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: filteredItems.length,
itemBuilder: (context, index) {
return ItemListTile(
item: filteredItems[index],
icon: _getIcon(filteredItems[index]),
onTap: () => _openItem(filteredItems[index]),
onDelete: () =>
_deleteItem(filteredItems[index]),
onTogglePin: () =>
_togglePin(filteredItems[index]),
extensionMode: extensionMode,
);
},
),
),
],
),
);
}
}
class ItemCard extends StatelessWidget {
final TabData item;
final String icon;
final VoidCallback onTap;
final VoidCallback onDelete;
final VoidCallback onTogglePin;
final bool extensionMode;
const ItemCard({
super.key,
required this.item,
required this.icon,
required this.onTap,
required this.onDelete,
required this.onTogglePin,
this.extensionMode = false,
});
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 60,
color: Theme.of(context).colorScheme.primaryContainer,
child: Center(
child: item.favicon.isNotEmpty && item.favicon.startsWith('http')
? Image.network(
item.favicon,
width: 32,
height: 32,
errorBuilder: (context, error, stackTrace) {
return Text(icon, style: const TextStyle(fontSize: 32));
},
)
: Text(icon, style: const TextStyle(fontSize: 32)),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (item.isPinned)
Icon(
Icons.push_pin,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
if (item.isPinned) const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: _getTypeColor(context),
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.type.toUpperCase(),
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
),
const SizedBox(height: 6),
Expanded(
child: Text(
item.title,
style: Theme.of(context).textTheme.titleSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 4),
Text(
item.url,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.secondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (item.visitCount != null) ...[
const SizedBox(height: 4),
Text(
'${item.visitCount} visits',
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
),
),
if (!extensionMode)
ButtonBar(
alignment: MainAxisAlignment.end,
buttonPadding: EdgeInsets.zero,
children: [
if (item.type == 'tab')
IconButton(
icon: Icon(
item.isPinned ? Icons.push_pin : Icons.push_pin_outlined,
size: 18,
),
onPressed: onTogglePin,
tooltip: item.isPinned ? 'Unpin' : 'Pin',
),
if (item.type != 'history')
IconButton(
icon: const Icon(Icons.delete_outline, size: 18),
onPressed: onDelete,
tooltip: 'Delete',
),
],
),
],
),
),
);
}
Color _getTypeColor(BuildContext context) {
switch (item.type) {
case 'tab':
return Colors.blue;
case 'bookmark':
return Colors.orange;
case 'history':
return Colors.purple;
default:
return Theme.of(context).colorScheme.primary;
}
}
}
class ItemListTile extends StatelessWidget {
final TabData item;
final String icon;
final VoidCallback onTap;
final VoidCallback onDelete;
final VoidCallback onTogglePin;
final bool extensionMode;
const ItemListTile({
super.key,
required this.item,
required this.icon,
required this.onTap,
required this.onDelete,
required this.onTogglePin,
this.extensionMode = false,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
child: item.favicon.isNotEmpty && item.favicon.startsWith('http')
? Image.network(
item.favicon,
width: 20,
height: 20,
errorBuilder: (context, error, stackTrace) {
return Text(icon, style: const TextStyle(fontSize: 20));
},
)
: Text(icon, style: const TextStyle(fontSize: 20)),
),
title: Row(
children: [
if (item.isPinned) ...[
Icon(
Icons.push_pin,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 4),
],
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: _getTypeColor(context),
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.type.toUpperCase(),
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.url,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (item.visitCount != null)
Text('${item.visitCount} visits',
style: Theme.of(context).textTheme.bodySmall),
],
),
trailing: extensionMode ? null : Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.type == 'tab')
IconButton(
icon: Icon(
item.isPinned ? Icons.push_pin : Icons.push_pin_outlined,
),
onPressed: onTogglePin,
tooltip: item.isPinned ? 'Unpin' : 'Pin',
),
if (item.type != 'history')
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: onDelete,
tooltip: 'Delete',
),
],
),
onTap: onTap,
),
);
}
Color _getTypeColor(BuildContext context) {
switch (item.type) {
case 'tab':
return Colors.blue;
case 'bookmark':
return Colors.orange;
case 'history':
return Colors.purple;
default:
return Theme.of(context).colorScheme.primary;
}
}
}

20
dist/browser-tab-manager/nginx.conf vendored Normal file
View file

@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Enable gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

20
dist/browser-tab-manager/pubspec.yaml vendored Normal file
View file

@ -0,0 +1,20 @@
name: browser_tab_manager
description: A Flutter web app for managing browser tabs, bookmarks, and history
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
web: ^1.1.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true

View file

@ -0,0 +1,11 @@
{
"name": "Browser Tab Manager",
"short_name": "TabManager",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "Manage browser tabs in a grid view",
"orientation": "portrait-primary",
"prefer_related_applications": false
}

View file

@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
class AppConstants {
// App Theme
static const Color primaryColor = Color(0xFF0175C2);
// Extension Communication
static const String extensionSource = 'tab-tracker-extension';
static const String webappSource = 'tab-tracker-webapp';
// Default Values
static const int defaultHistoryLimit = 100;
static const Duration extensionCheckDelay = Duration(milliseconds: 500);
// Grid Configuration
static const double maxCrossAxisExtent = 300.0;
static const double childAspectRatio = 1.3;
static const double crossAxisSpacing = 16.0;
static const double mainAxisSpacing = 16.0;
// Padding and Spacing
static const EdgeInsets defaultPadding = EdgeInsets.all(16.0);
static const EdgeInsets cardPadding = EdgeInsets.all(12.0);
static const EdgeInsets buttonPadding = EdgeInsets.symmetric(horizontal: 8, vertical: 4);
// Icon Sizes
static const double smallIconSize = 14.0;
static const double mediumIconSize = 18.0;
static const double largeIconSize = 32.0;
static const double emptyStateIconSize = 80.0;
// Text Styles
static const double smallFontSize = 10.0;
static const double mediumFontSize = 20.0;
// Sort Options
static const List<String> sortOptions = ['recent', 'title', 'url', 'visits'];
static const List<String> filterOptions = ['all', 'tabs', 'bookmarks', 'history'];
// Messages
static const String trackingMessage = 'TRACKING';
static const String waitingMessage = 'Waiting for tabs...';
static const String noItemsMessage = 'No items found';
static const String openTabsHint = 'Open some tabs to see them here';
static const String tryDifferentSearchHint = 'Try a different search or filter';
// Search Hints
static const String extensionSearchHint = 'Search tracked tabs...';
static const String normalSearchHint = 'Search tabs, bookmarks, and history...';
}
// This is our centralized configuration file where all fixed values are stored in one place.
//
// It contains settings used throughout the app.
//
// Instead of hardcoding values
//
// This makes it easy to update the entire app by changing values in just one location.
//
// For example, changing primaryColor here updates every button, header, and highlight in the app.
//
// All user-facing messages are stored here making translation and text updates simple.
//
// Grid layout values are defined here so we can adjust the card view globally.
//
// This approach keeps our code clean, consistent, and easy to maintain.

55
lib/main.dart Normal file
View file

@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'screens/tab_manager_home.dart';
void main() {
runApp(const BrowserTabManagerApp());
}
class BrowserTabManagerApp extends StatelessWidget {
const BrowserTabManagerApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Browser Tab Manager',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF0175C2),
brightness: Brightness.light,
),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF0175C2),
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const TabManagerHome(),
);
}
}
// This is the entry point of our Flutter application.
//
// The main function is the first thing that runs when the app starts.
//
// It calls runApp which takes our root widget and displays it on screen.
//
// BrowserTabManagerApp is a stateless widget that creates the MaterialApp.
//
// MaterialApp provides the overall app structure including theme, routing, and title.
//
// We define both a light theme and dark theme that automatically switch based on system settings.
//
// Both themes use the same blue seed color to generate a consistent color scheme.
//
// Material 3 design is enabled for modern UI components and styling.
//
// The home property sets TabManagerHome as the first screen users see.
//
// This file is kept simple and clean since it only sets up the app foundation.
//
// All the actual functionality lives in the screens, services, and widgets folders.

63
lib/models/tab_data.dart Normal file
View file

@ -0,0 +1,63 @@
class TabData {
String id;
String title;
String url;
String favicon;
DateTime lastAccessed;
bool isPinned;
String type;
int? visitCount;
String? folder;
TabData({
required this.id,
required this.title,
required this.url,
this.favicon = '',
DateTime? lastAccessed,
this.isPinned = false,
this.type = 'tab',
this.visitCount,
this.folder,
}) : lastAccessed = lastAccessed ?? DateTime.now();
factory TabData.fromJson(Map<String, dynamic> json) => TabData(
id: json['id'].toString(),
title: json['title'] ?? 'Untitled',
url: json['url'] ?? '',
favicon: json['favicon'] ?? json['favIconUrl'] ?? '',
lastAccessed: json['lastAccessed'] != null
? DateTime.parse(json['lastAccessed'])
: (json['lastVisitTime'] != null
? DateTime.parse(json['lastVisitTime'])
: (json['dateAdded'] != null
? DateTime.parse(json['dateAdded'])
: (json['timestamp'] != null
? DateTime.parse(json['timestamp'])
: DateTime.now()))),
isPinned: json['isPinned'] ?? false,
type: json['type'] ?? 'tab',
visitCount: json['visitCount'],
folder: json['folder'],
);
}
// This is our data model that represents a single tab, bookmark, or history item.
//
// It holds all the information we need about each item: title, URL, favicon, when it was accessed, etc.
//
// Think of it as a blueprint or template for storing browser item information.
//
// Every tab, bookmark, or history entry in our app is stored as a TabData object.
//
// The constructor creates new TabData objects with required fields like id, title, and url.
//
// Optional fields have default values, like favicon defaults to empty string and isPinned defaults to false.
//
// The fromJson factory method converts JSON data from the browser API into a TabData object.
//
// It handles different date field names from different sources like lastAccessed, lastVisitTime, dateAdded, or timestamp.
//
// It also handles missing data gracefully by using default values with the ?? operator.
//
// This class is the foundation of our app since everything revolves around displaying and managing these items.

View file

@ -0,0 +1,393 @@
import 'package:flutter/material.dart';
import 'dart:html' as html;
import '../models/tab_data.dart';
import '../services/browser_api_service.dart';
import '../services/extension_service.dart';
import '../widgets/item_card.dart';
import '../widgets/item_list_tile.dart';
import '../widgets/search_bar.dart' as custom;
import '../widgets/filter_chips.dart';
import '../widgets/app_bar_actions.dart';
class TabManagerHome extends StatefulWidget {
const TabManagerHome({super.key});
@override
State<TabManagerHome> createState() => _TabManagerHomeState();
}
class _TabManagerHomeState extends State<TabManagerHome> {
// State variables
List<TabData> allItems = [];
List<TabData> filteredItems = [];
final TextEditingController searchController = TextEditingController();
bool isGridView = true;
String sortBy = 'recent';
String filterType = 'all';
bool isLoading = true;
bool extensionConnected = false;
bool extensionMode = false;
// Services
final BrowserApiService _browserApi = BrowserApiService();
final ExtensionService _extensionService = ExtensionService();
@override
void initState() {
super.initState();
_setupExtensionService();
_loadAllData();
searchController.addListener(_filterItems);
}
@override
void dispose() {
searchController.dispose();
super.dispose();
}
void _setupExtensionService() {
_extensionService.onTabsUpdate = (tabs) {
setState(() {
allItems = tabs;
extensionConnected = true;
extensionMode = true;
_filterItems();
});
};
_extensionService.onTrackingStart = () {
setState(() {
extensionMode = true;
extensionConnected = true;
});
};
_extensionService.onTrackingStop = () {
setState(() {
extensionMode = false;
_loadAllData();
});
};
_extensionService.setupListener();
}
void _startExtensionTracking() {
_extensionService.startTracking();
setState(() {
extensionMode = true;
allItems.clear();
});
}
void _stopExtensionTracking() {
_extensionService.stopTracking();
setState(() {
extensionMode = false;
});
_loadAllData();
}
Future<void> _loadAllData() async {
if (extensionMode) return;
setState(() {
isLoading = true;
});
try {
final tabs = await _browserApi.getTabs();
final bookmarks = await _browserApi.getBookmarks();
final history = await _browserApi.getHistory();
setState(() {
allItems = [...tabs, ...bookmarks, ...history];
_filterItems();
isLoading = false;
});
_checkExtensionConnection();
} catch (e) {
print('Error loading data: $e');
setState(() {
isLoading = false;
});
}
}
void _checkExtensionConnection() {
_extensionService.getStatus();
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
setState(() {});
}
});
}
void _filterItems() {
final query = searchController.text.toLowerCase();
setState(() {
filteredItems = allItems.where((item) {
if (filterType != 'all' && item.type != filterType.replaceAll('s', '')) {
return false;
}
if (query.isNotEmpty) {
return item.title.toLowerCase().contains(query) ||
item.url.toLowerCase().contains(query);
}
return true;
}).toList();
_sortItems();
});
}
void _sortItems() {
switch (sortBy) {
case 'recent':
filteredItems.sort((a, b) => b.lastAccessed.compareTo(a.lastAccessed));
break;
case 'title':
filteredItems.sort((a, b) => a.title.compareTo(b.title));
break;
case 'url':
filteredItems.sort((a, b) => a.url.compareTo(b.url));
break;
case 'visits':
filteredItems.sort((a, b) =>
(b.visitCount ?? 0).compareTo(a.visitCount ?? 0));
break;
}
filteredItems.sort((a, b) => b.isPinned ? 1 : (a.isPinned ? -1 : 0));
}
Future<void> _openItem(TabData item) async {
print('🔥 DEBUG: _openItem called with item: ${item.title}');
print('🔥 DEBUG: Extension mode: $extensionMode');
print('🔥 DEBUG: Item type: ${item.type}');
print('🔥 DEBUG: Item URL: ${item.url}');
if (extensionMode) {
print('🔥 DEBUG: Opening in new tab via html.window.open');
html.window.open(item.url, '_blank');
} else {
if (item.type == 'tab') {
print('🔥 DEBUG: Switching to existing tab with ID: ${item.id}');
await _browserApi.switchToTab(item.id);
} else {
print('🔥 DEBUG: Opening new tab for bookmark/history');
await _browserApi.openTab(item.url);
}
}
print('🔥 DEBUG: _openItem completed');
}
Future<void> _deleteItem(TabData item) async {
if (extensionMode) return;
if (item.type == 'tab') {
await _browserApi.closeTab(item.id);
} else if (item.type == 'bookmark') {
await _browserApi.removeBookmark(item.id);
}
await _loadAllData();
}
Future<void> _togglePin(TabData item) async {
if (extensionMode) return;
if (item.type == 'tab') {
await _browserApi.togglePinTab(item.id, !item.isPinned);
await _loadAllData();
}
}
String _getIcon(TabData item) {
if (item.favicon.isNotEmpty && !item.favicon.contains('data:')) {
return '🌐';
}
switch (item.type) {
case 'tab':
return '🔖';
case 'bookmark':
return '';
case 'history':
return '🕒';
default:
return '🌐';
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
children: [
const Text('Browser Tab Manager'),
if (extensionMode) ...[
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'TRACKING',
style: TextStyle(fontSize: 10, color: Colors.white),
),
),
],
],
),
actions: [
AppBarActions(
extensionMode: extensionMode,
extensionConnected: extensionConnected,
isGridView: isGridView,
sortBy: sortBy,
onStartTracking: _startExtensionTracking,
onStopTracking: _stopExtensionTracking,
onRefresh: _loadAllData,
onToggleView: () {
setState(() {
isGridView = !isGridView;
});
},
onSortChanged: (value) {
setState(() {
sortBy = value;
_filterItems();
});
},
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
custom.SearchBar(
controller: searchController,
extensionMode: extensionMode,
onClear: () {
searchController.clear();
},
),
const SizedBox(height: 12),
if (!extensionMode)
FilterChips(
allItems: allItems,
filterType: filterType,
onFilterChanged: (type) {
setState(() {
filterType = type;
_filterItems();
});
},
),
],
),
),
Expanded(
child: isLoading
? const Center(child: CircularProgressIndicator())
: filteredItems.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
extensionMode ? Icons.track_changes : Icons.search_off,
size: 80,
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
extensionMode
? 'Waiting for tabs...'
: 'No items found',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
extensionMode
? 'Open some tabs to see them here'
: 'Try a different search or filter',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
)
: isGridView
? GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 300,
childAspectRatio: 1.3,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: filteredItems.length,
itemBuilder: (context, index) {
return ItemCard(
item: filteredItems[index],
icon: _getIcon(filteredItems[index]),
onTap: () => _openItem(filteredItems[index]),
onDelete: () => _deleteItem(filteredItems[index]),
onTogglePin: () => _togglePin(filteredItems[index]),
extensionMode: extensionMode,
);
},
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: filteredItems.length,
itemBuilder: (context, index) {
return ItemListTile(
item: filteredItems[index],
icon: _getIcon(filteredItems[index]),
onTap: () => _openItem(filteredItems[index]),
onDelete: () => _deleteItem(filteredItems[index]),
onTogglePin: () => _togglePin(filteredItems[index]),
extensionMode: extensionMode,
);
},
),
),
],
),
);
}
}
// This is the main screen of our app where users see and manage their browser tabs.
//
// It is a StatefulWidget which means it holds data that can change over time.
//
// The state includes lists of tabs, search text, view preferences, and loading status.
//
// It communicates with two services: BrowserApiService for browser data and ExtensionService for real-time tracking.
//
// When the screen loads, it fetches all tabs, bookmarks, and history from the browser.
//
// Users can search items, filter by type, sort by different criteria, and switch between grid and list views.
//
// Extension mode allows real-time tracking of browser tabs as they open and close.
//
// The build method creates the UI with an app bar, search box, filters, and either a grid or list of items.
//
// User actions like opening, deleting, or pinning items trigger methods that update the browser and refresh the display.
//
// This is the central hub that coordinates all the app functionality and user interactions.

View file

@ -0,0 +1,113 @@
import 'dart:html' as html;
import 'dart:convert';
import 'dart:js_util' as js_util;
import '../models/tab_data.dart';
class BrowserApiService {
Future<List<TabData>> getTabs() async {
try {
final result = await _callBrowserAPI('getTabs');
if (result != null) {
final List<dynamic> data = json.decode(result);
return data.map((item) {
item['type'] = 'tab';
return TabData.fromJson(item);
}).toList();
}
} catch (e) {
print('Error getting tabs: $e');
}
return [];
}
Future<List<TabData>> getBookmarks() async {
try {
final result = await _callBrowserAPI('getBookmarks');
if (result != null) {
final List<dynamic> data = json.decode(result);
return data.map((item) {
item['type'] = 'bookmark';
return TabData.fromJson(item);
}).toList();
}
} catch (e) {
print('Error getting bookmarks: $e');
}
return [];
}
Future<List<TabData>> getHistory() async {
try {
final result = await _callBrowserAPI('getHistory', [100]);
if (result != null) {
final List<dynamic> data = json.decode(result);
return data.map((item) {
item['type'] = 'history';
return TabData.fromJson(item);
}).toList();
}
} catch (e) {
print('Error getting history: $e');
}
return [];
}
Future<void> switchToTab(String tabId) async {
await _callBrowserAPI('switchToTab', [tabId]);
}
Future<void> openTab(String url) async {
await _callBrowserAPI('openTab', [url]);
}
Future<void> closeTab(String tabId) async {
await _callBrowserAPI('closeTab', [tabId]);
}
Future<void> removeBookmark(String bookmarkId) async {
await _callBrowserAPI('removeBookmark', [bookmarkId]);
}
Future<void> togglePinTab(String tabId, bool pin) async {
await _callBrowserAPI('togglePinTab', [tabId, pin]);
}
Future<String?> _callBrowserAPI(String method, [List<dynamic>? args]) async {
try {
if (!js_util.hasProperty(html.window, 'BrowserAPI')) {
print('BrowserAPI not found - running in development mode');
return null;
}
final browserAPI = js_util.getProperty(html.window, 'BrowserAPI');
final function = js_util.getProperty(browserAPI, method);
final result = args == null
? await js_util.promiseToFuture(js_util.callMethod(function, 'call', [browserAPI]))
: await js_util.promiseToFuture(js_util.callMethod(function, 'call', [browserAPI, ...args]));
return json.encode(result);
} catch (e) {
print('Error calling $method: $e');
return null;
}
}
}
// This service acts as a bridge between our Flutter app and the browser extension.
//
// It provides clean methods to interact with browser APIs for tabs, bookmarks, and history.
//
// The getTabs, getBookmarks, and getHistory methods fetch data from the browser and convert it to TabData objects.
//
// Action methods like switchToTab, openTab, closeTab allow us to control browser tabs programmatically.
//
// The private _callBrowserAPI method handles the low-level JavaScript communication.
//
// It uses dart:js_util to call JavaScript functions exposed by the browser extension through window.BrowserAPI.
//
// All methods handle errors gracefully and return empty results if the API is unavailable.
//
// This abstraction keeps browser-specific code separate from our UI logic making the app easier to maintain.
//
// When running in development without the extension, it prints debug messages instead of crashing.

View file

@ -0,0 +1,80 @@
import 'dart:html' as html;
import '../models/tab_data.dart';
class ExtensionService {
Function(List<TabData>)? onTabsUpdate;
Function()? onTrackingStart;
Function()? onTrackingStop;
void setupListener() {
html.window.onMessage.listen((event) {
final data = event.data;
if (data is Map && data['source'] == 'tab-tracker-extension') {
_handleExtensionMessage(Map<String, dynamic>.from(data));
}
});
}
void _handleExtensionMessage(Map<String, dynamic> data) {
print('Received from extension: $data');
if (data['action'] == 'updateTabs') {
final extensionTabs = (data['tabs'] as List).map((tab) {
tab['type'] = 'tab';
return TabData.fromJson(tab);
}).toList();
onTabsUpdate?.call(extensionTabs);
} else if (data['action'] == 'clear') {
onTrackingStop?.call();
} else if (data['response'] != null) {
final response = data['response'];
if (response['status'] == 'started') {
onTrackingStart?.call();
} else if (response['status'] == 'stopped') {
onTrackingStop?.call();
}
}
}
void sendMessage(Map<String, dynamic> message) {
html.window.postMessage({
'source': 'tab-tracker-webapp',
...message
}, '*');
}
void startTracking() {
sendMessage({'action': 'startTracking'});
}
void stopTracking() {
sendMessage({'action': 'stopTracking'});
}
void getStatus() {
sendMessage({'action': 'getStatus'});
}
}
// This service handles real-time communication with the browser extension for live tab tracking.
//
// It uses callback functions that get triggered when the extension sends updates.
//
// The setupListener method listens for messages from the browser extension through window.postMessage.
//
// When messages arrive, it filters them to only process ones from our specific extension.
//
// The _handleExtensionMessage method processes different types of messages like tab updates and tracking status changes.
//
// It converts raw tab data from the extension into our TabData objects.
//
// The sendMessage method sends commands back to the extension with a source identifier.
//
// Methods like startTracking, stopTracking, and getStatus provide a clean interface to control the extension.
//
// This enables our app to show live updates as users open and close tabs in their browser.
//
// The callback pattern allows the UI to react immediately when extension data arrives.

96
lib/utils/helpers.dart Normal file
View file

@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import '../models/tab_data.dart';
class Helpers {
static String getItemIcon(TabData item) {
if (item.favicon.isNotEmpty && !item.favicon.contains('data:')) {
return '🌐';
}
switch (item.type) {
case 'tab':
return '🔖';
case 'bookmark':
return '';
case 'history':
return '🕒';
default:
return '🌐';
}
}
static Color getTypeColor(BuildContext context, String type) {
switch (type) {
case 'tab':
return Colors.blue;
case 'bookmark':
return Colors.orange;
case 'history':
return Colors.purple;
default:
return Theme.of(context).colorScheme.primary;
}
}
static Map<String, int> getItemStats(List<TabData> items) {
return {
'tabs': items.where((i) => i.type == 'tab').length,
'bookmarks': items.where((i) => i.type == 'bookmark').length,
'history': items.where((i) => i.type == 'history').length,
};
}
static List<TabData> filterItems(
List<TabData> items,
String query,
String filterType,
) {
return items.where((item) {
if (filterType != 'all' && item.type != filterType.replaceAll('s', '')) {
return false;
}
if (query.isNotEmpty) {
return item.title.toLowerCase().contains(query.toLowerCase()) ||
item.url.toLowerCase().contains(query.toLowerCase());
}
return true;
}).toList();
}
static void sortItems(List<TabData> items, String sortBy) {
switch (sortBy) {
case 'recent':
items.sort((a, b) => b.lastAccessed.compareTo(a.lastAccessed));
break;
case 'title':
items.sort((a, b) => a.title.compareTo(b.title));
break;
case 'url':
items.sort((a, b) => a.url.compareTo(b.url));
break;
case 'visits':
items.sort((a, b) => (b.visitCount ?? 0).compareTo(a.visitCount ?? 0));
break;
}
// Keep pinned items at top
items.sort((a, b) => b.isPinned ? 1 : (a.isPinned ? -1 : 0));
}
}
// This is a utility class containing reusable helper functions used throughout the app.
//
// The getItemIcon method returns the appropriate emoji icon for each item type.
//
// The getTypeColor method assigns consistent colors to tabs, bookmarks, and history items.
//
// The getItemStats method calculates how many items of each type exist in a list.
//
// The filterItems method filters a list based on search query and filter type selection.
//
// The sortItems method sorts items by different criteria like recent, title, url, or visit count.
//
// All methods are static so they can be called without creating an instance of the class.
//
// This keeps common logic in one place rather than duplicating it across multiple files.
//
// Usin

View file

@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
class AppBarActions extends StatelessWidget {
final bool extensionMode;
final bool extensionConnected;
final bool isGridView;
final String sortBy;
final VoidCallback onStartTracking;
final VoidCallback onStopTracking;
final VoidCallback onRefresh;
final VoidCallback onToggleView;
final Function(String) onSortChanged;
const AppBarActions({
super.key,
required this.extensionMode,
required this.extensionConnected,
required this.isGridView,
required this.sortBy,
required this.onStartTracking,
required this.onStopTracking,
required this.onRefresh,
required this.onToggleView,
required this.onSortChanged,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (extensionMode)
TextButton.icon(
onPressed: onStopTracking,
icon: const Icon(Icons.stop, color: Colors.white),
label: const Text('Stop', style: TextStyle(color: Colors.white)),
)
else
TextButton.icon(
onPressed: extensionConnected ? onStartTracking : null,
icon: const Icon(Icons.play_arrow, color: Colors.white),
label: const Text('Track Tabs', style: TextStyle(color: Colors.white)),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: extensionMode ? null : onRefresh,
tooltip: 'Refresh',
),
IconButton(
icon: Icon(isGridView ? Icons.view_list : Icons.grid_view),
onPressed: onToggleView,
tooltip: isGridView ? 'List View' : 'Grid View',
),
PopupMenuButton<String>(
icon: const Icon(Icons.sort),
tooltip: 'Sort by',
onSelected: onSortChanged,
itemBuilder: (context) => [
const PopupMenuItem(value: 'recent', child: Text('Recent')),
const PopupMenuItem(value: 'title', child: Text('Title')),
const PopupMenuItem(value: 'url', child: Text('URL')),
const PopupMenuItem(value: 'visits', child: Text('Most Visited')),
],
),
],
);
}
}
// This widget displays all the action buttons in the top app bar.
//
// It is a stateless widget that receives all its data and callbacks from the parent.
//
// The Track Tabs button starts or stops extension tracking mode.
//
// The button changes from Track Tabs to Stop depending on whether extension mode is active.
//
// The refresh button reloads all data from the browser and is disabled during extension mode.
//
// The view toggle button switches between grid and list display layouts.
//
// The sort menu button opens a dropdown with sorting options like Recent, Title, URL, and Most Visited.
//
// All buttons trigger callbacks passed from the parent rather than handling logic themselves.
//
// This separation keeps the UI code clean and maintains a single source of truth in the parent widget.
//
// Extracting these actions into a separate widget makes the main screen code easier to read and maintain.

View file

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import '../models/tab_data.dart';
class FilterChips extends StatelessWidget {
final List<TabData> allItems;
final String filterType;
final Function(String) onFilterChanged;
const FilterChips({
super.key,
required this.allItems,
required this.filterType,
required this.onFilterChanged,
});
@override
Widget build(BuildContext context) {
final stats = {
'tabs': allItems.where((i) => i.type == 'tab').length,
'bookmarks': allItems.where((i) => i.type == 'bookmark').length,
'history': allItems.where((i) => i.type == 'history').length,
};
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
FilterChip(
label: Text('All (${allItems.length})'),
selected: filterType == 'all',
onSelected: (selected) => onFilterChanged('all'),
),
const SizedBox(width: 8),
FilterChip(
label: Text('Tabs (${stats['tabs']})'),
selected: filterType == 'tabs',
onSelected: (selected) => onFilterChanged('tabs'),
),
const SizedBox(width: 8),
FilterChip(
label: Text('Bookmarks (${stats['bookmarks']})'),
selected: filterType == 'bookmarks',
onSelected: (selected) => onFilterChanged('bookmarks'),
),
const SizedBox(width: 8),
FilterChip(
label: Text('History (${stats['history']})'),
selected: filterType == 'history',
onSelected: (selected) => onFilterChanged('history'),
),
],
),
);
}
}
// This widget displays a row of filter chips that let users filter items by type.
//
// It shows four chips: All, Tabs, Bookmarks, and History with their respective counts.
//
// The counts are calculated dynamically from the allItems list passed from the parent.
//
// Each chip shows how many items of that type exist in parentheses.
//
// The selected chip is highlighted to show which filter is currently active.
//
// Clicking a chip triggers the onFilterChanged callback to update the parent's filter state.
//
// The chips are wrapped in a horizontal scroll view so they don't overflow on small screens.
//
// This component is only shown when not in extension mode since extension mode only tracks tabs.
//
// Extracting the filter chips into a separate widget keeps the main screen cleaner and more modular.

173
lib/widgets/item_card.dart Normal file
View file

@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import '../models/tab_data.dart';
class ItemCard extends StatelessWidget {
final TabData item;
final String icon;
final VoidCallback onTap;
final VoidCallback onDelete;
final VoidCallback onTogglePin;
final bool extensionMode;
const ItemCard({
super.key,
required this.item,
required this.icon,
required this.onTap,
required this.onDelete,
required this.onTogglePin,
this.extensionMode = false,
});
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 60,
color: Theme.of(context).colorScheme.primaryContainer,
child: Center(
child: item.favicon.isNotEmpty && item.favicon.startsWith('http')
? Image.network(
item.favicon,
width: 32,
height: 32,
errorBuilder: (context, error, stackTrace) {
return Text(icon, style: const TextStyle(fontSize: 32));
},
)
: Text(icon, style: const TextStyle(fontSize: 32)),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (item.isPinned)
Icon(
Icons.push_pin,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
if (item.isPinned) const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: _getTypeColor(context),
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.type.toUpperCase(),
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
),
const SizedBox(height: 6),
Expanded(
child: Text(
item.title,
style: Theme.of(context).textTheme.titleSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 4),
Text(
item.url,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.secondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (item.visitCount != null) ...[
const SizedBox(height: 4),
Text(
'${item.visitCount} visits',
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
),
),
if (!extensionMode)
ButtonBar(
alignment: MainAxisAlignment.end,
buttonPadding: EdgeInsets.zero,
children: [
if (item.type == 'tab')
IconButton(
icon: Icon(
item.isPinned ? Icons.push_pin : Icons.push_pin_outlined,
size: 18,
),
onPressed: onTogglePin,
tooltip: item.isPinned ? 'Unpin' : 'Pin',
),
if (item.type != 'history')
IconButton(
icon: const Icon(Icons.delete_outline, size: 18),
onPressed: onDelete,
tooltip: 'Delete',
),
],
),
],
),
),
);
}
Color _getTypeColor(BuildContext context) {
switch (item.type) {
case 'tab':
return Colors.blue;
case 'bookmark':
return Colors.orange;
case 'history':
return Colors.purple;
default:
return Theme.of(context).colorScheme.primary;
}
}
}
// This widget displays a single tab, bookmark, or history item as a card in the grid view.
//
// It is a stateless widget that receives all its data from the parent component.
//
// The card shows a favicon or emoji icon at the top in a colored header section.
//
// Below that it displays a type badge, the item title, URL, and optional visit count.
//
// If the item is pinned, a pin icon appears next to the type badge.
//
// The card is tappable and triggers the onTap callback to open the item.
//
// Action buttons at the bottom allow pinning tabs and deleting tabs or bookmarks.
//
// These action buttons are hidden in extension mode since you cannot modify tracked tabs.
//
// History items do not show a delete button since browser history cannot be removed this way.
//
// The getTypeColor method assigns different colors to the type badge based on item type.
//
// This card design provides a clean visual representation of browser items in grid layout.

View file

@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import '../models/tab_data.dart';
class ItemListTile extends StatelessWidget {
final TabData item;
final String icon;
final VoidCallback onTap;
final VoidCallback onDelete;
final VoidCallback onTogglePin;
final bool extensionMode;
const ItemListTile({
super.key,
required this.item,
required this.icon,
required this.onTap,
required this.onDelete,
required this.onTogglePin,
this.extensionMode = false,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
child: item.favicon.isNotEmpty && item.favicon.startsWith('http')
? Image.network(
item.favicon,
width: 20,
height: 20,
errorBuilder: (context, error, stackTrace) {
return Text(icon, style: const TextStyle(fontSize: 20));
},
)
: Text(icon, style: const TextStyle(fontSize: 20)),
),
title: Row(
children: [
if (item.isPinned) ...[
Icon(
Icons.push_pin,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 4),
],
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: _getTypeColor(context),
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.type.toUpperCase(),
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.url,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (item.visitCount != null)
Text('${item.visitCount} visits',
style: Theme.of(context).textTheme.bodySmall),
],
),
trailing: extensionMode ? null : Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.type == 'tab')
IconButton(
icon: Icon(
item.isPinned ? Icons.push_pin : Icons.push_pin_outlined,
),
onPressed: onTogglePin,
tooltip: item.isPinned ? 'Unpin' : 'Pin',
),
if (item.type != 'history')
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: onDelete,
tooltip: 'Delete',
),
],
),
onTap: onTap,
),
);
}
Color _getTypeColor(BuildContext context) {
switch (item.type) {
case 'tab':
return Colors.blue;
case 'bookmark':
return Colors.orange;
case 'history':
return Colors.purple;
default:
return Theme.of(context).colorScheme.primary;
}
}
}
// This widget displays a single tab, bookmark, or history item as a list tile in the list view.
//
// It is the list view alternative to ItemCard and shows the same information in a horizontal layout.
//
// A circular avatar on the left shows the favicon or emoji icon for the item.
//
// The title row displays an optional pin icon, type badge, and the item title.
//
// The subtitle shows the URL and optional visit count below the title.
//
// Action buttons on the right allow pinning tabs and deleting tabs or bookmarks.
//
// These buttons are hidden in extension mode and history items do not show delete buttons.
//
// Tapping anywhere on the tile triggers the onTap callback to open the item.
//
// The list tile format is more compact and shows more items on screen compared to grid cards.
//
// Users can toggle between this list view and the grid card view using the view toggle button.
//
// This provides flexibility in how users prefer to browse their browser items.

View file

@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
class SearchBar extends StatelessWidget {
final TextEditingController controller;
final bool extensionMode;
final VoidCallback onClear;
const SearchBar({
super.key,
required this.controller,
required this.extensionMode,
required this.onClear,
});
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
decoration: InputDecoration(
hintText: extensionMode
? 'Search tracked tabs...'
: 'Search tabs, bookmarks, and history...',
prefixIcon: const Icon(Icons.search),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: onClear,
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
}
// This widget provides the search input field for filtering tabs, bookmarks, and history.
//
// It is a stateless widget that receives a TextEditingController from the parent to manage the text input.
//
// The hint text changes based on whether extension mode is active or not.
//
// In extension mode it says Search tracked tabs and in normal mode it says Search tabs, bookmarks, and history.
//
// A search icon appears on the left side of the input field.
//
// When the user types text, a clear button appears on the right side to quickly empty the search.
//
// Clicking the clear button triggers the onClear callback which clears the controller text.
//
// The text field has rounded corners for a modern appearance.
//
// As the user types, the parent widget filters the displayed items in real-time.
//
// This provides a fast and intuitive way to find specific tabs or bookmarks in large lists.

46
nginx.conf Normal file
View file

@ -0,0 +1,46 @@
server {
listen 80;
# Listen for incoming connections on port 80
server_name _;
# Accept requests from any domain name
root /usr/share/nginx/html;
# Set the directory where our app files are located
index index.html;
# Serve index.html as the default page
# Enable gzip
gzip on;
# Turn on compression for files
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Specify which file types to compress
location / {
try_files $uri $uri/ /index.html;
# Try to find the requested file, if not found serve index.html
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
# Match files with these extensions
expires 1y;
# Set files to expire in 1 year
add_header Cache-Control "public, immutable";
# Tell browsers these files never change, cache them permanently
}
}
# It compresses files to save bandwidth and caches assets.
# The try_files rule ensures Flutters client-side routing works correctly.
# Finds the right files and sends them to the browser
# Compresses files to make them download faster
# Tells browsers to cache files so they load instantly on repeat visits

111
package.sh Executable file
View file

@ -0,0 +1,111 @@
#!/bin/bash
set -e
cd "$(pwd)"
echo "📦 Packaging Browser Tab Manager..."
echo "📂 Working from: $(pwd)"
if [ ! -f "Dockerfile" ] || [ ! -f "pubspec.yaml" ]; then
echo "❌ Error: Not in project root directory!"
echo " Expected files: Dockerfile, pubspec.yaml"
echo " Current directory: $(pwd)"
exit 1
fi
rm -rf ./dist
mkdir -p ./dist/browser-tab-manager
echo "📋 Copying files..."
cp ./Dockerfile ./dist/browser-tab-manager/
cp ./nginx.conf ./dist/browser-tab-manager/
cp ./pubspec.yaml ./dist/browser-tab-manager/
cp ./pubspec.lock ./dist/browser-tab-manager/ 2>/dev/null || true
cp -r ./lib ./dist/browser-tab-manager/
mkdir -p ./dist/browser-tab-manager/web
cp ./web/manifest.json ./dist/browser-tab-manager/web/ 2>/dev/null || true
cat > dist/browser-tab-manager/deploy.sh << 'DEPLOY_EOF'
#!/bin/bash
set -e
echo "🚀 Deploying Browser Tab Manager on Server..."
if [ ! -f "Dockerfile" ]; then
echo "❌ Error: Dockerfile not found. Are you in the right directory?"
exit 1
fi
mkdir -p web
if [ ! -f "web/manifest.json" ]; then
cat > web/manifest.json << 'EOF'
{
"name": "Browser Tab Manager",
"short_name": "TabManager",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "Manage browser tabs in a grid view",
"orientation": "portrait-primary",
"prefer_related_applications": false
}
EOF
fi
echo "✅ Project structure ready"
echo "🔨 Building Podman container..."
podman build -t browser-tab-manager .
podman stop browser-tab-manager 2>/dev/null || true
podman rm browser-tab-manager 2>/dev/null || true
echo "🚢 Starting container..."
podman run -d \
--name browser-tab-manager \
-p 8081:80 \
--restart unless-stopped \
browser-tab-manager
echo "✅ Container started successfully!"
echo ""
echo "🌐 Your Browser Tab Manager is now running at:"
SERVER_IP=$(hostname -I | awk '{print $1}' 2>/dev/null || echo "your-server-ip")
echo " http://${SERVER_IP}:8081"
echo ""
echo "📋 Useful commands:"
echo " View logs: podman logs -f browser-tab-manager"
echo " Stop: podman stop browser-tab-manager"
echo " Start: podman start browser-tab-manager"
echo " Restart: podman restart browser-tab-manager"
echo " Remove: podman rm -f browser-tab-manager"
DEPLOY_EOF
chmod +x dist/browser-tab-manager/deploy.sh
echo "🗜️ Creating tarball..."
cd dist
tar -czf browser-tab-manager.tar.gz browser-tab-manager/
cd ..
echo "✅ Package created: dist/browser-tab-manager.tar.gz"
echo ""
echo "📤 To deploy on server:"
echo " 1. scp dist/browser-tab-manager.tar.gz root@188.245.68.196:/root/"
echo " 2. ssh root@188.245.68.196"
echo " 3. cd /root && tar -xzf browser-tab-manager.tar.gz"
echo " 4. cd browser-tab-manager && ./deploy.sh"
echo ""
echo "🌐 available at: https://tab.caesargaming.org"
# This script packages the app for server deployment.
# Copies all necessary files to a dist folder.
# Creates a deploy.sh script that will run on the server.
# Compresses everything into a tar.gz file for easy transfer.

213
pubspec.lock Normal file
View file

@ -0,0 +1,213 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.0"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
url: "https://pub.dev"
source: hosted
version: "3.0.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.16.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
web:
dependency: "direct main"
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
sdks:
dart: ">=3.8.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"

31
pubspec.yaml Normal file
View file

@ -0,0 +1,31 @@
name: browser_tab_manager
description: A Flutter web app for managing browser tabs, bookmarks, and history
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
web: ^1.1.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true
# Lists dependencies the app needs to run.
# Lists dev dependencies for development tools.
# Enables Material Design icons and widgets for the UI.

0
requirements.txt Normal file
View file

468
setup-extension.sh Executable file
View file

@ -0,0 +1,468 @@
#!/bin/bash
set -e
# Exit if any command fails
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
# Get the directory where this script is located and store it in SCRIPT_DIR
cd "$SCRIPT_DIR"
# Change to that directory
echo "🚀 Building Browser Tab Manager Extension..."
# Build using the existing Dockerfile which already has Flutter set up properly
echo "🔨 Building Flutter app using existing container..."
# Build the image if it doesn't exist or force rebuild
podman build -t browser-tab-manager-builder -f - . << 'EOF'
# "podman build" creates a container image
# "-t browser-tab-manager-builder" names the image
# "-f -" means read the Dockerfile from stdin (the following text)
# "." means use current directory as build context
# "<< 'EOF'" starts a here document - everything until 'EOF' is the Dockerfile
FROM debian:bullseye-slim
# Start from a base image: Debian Linux (bullseye version, slim variant)
# "FROM" is always the first instruction in a Dockerfile
# "debian:bullseye-slim" is a minimal Debian 11 operating system image
# Install dependencies
RUN apt-get update && apt-get install -y \
# "RUN" executes commands inside the container during build
# "apt-get update" refreshes the list of available software packages
# "apt-get install -y" installs packages (-y means yes to all prompts)
# "\" continues the command on the next line
curl \
# Tool for downloading files from the internet
git \
# Version control system, needed to download Flutter
unzip \
# Tool to extract zip files
xz-utils \
# Tool to extract .xz compressed files
zip \
# Tool to create zip files
libglu1-mesa \
# Graphics library needed by Flutter
&& rm -rf /var/lib/apt/lists/*
# Clean up package lists to make image smaller
# "/var/lib/apt/lists/*" contains downloaded package info we no longer need
# Create a non-root user
RUN useradd -m -u 1000 -s /bin/bash flutter && \
# "useradd" creates a new user account
# "-m" creates a home directory for the user
# "-u 1000" sets user ID to 1000 (matches most host system users)
# "-s /bin/bash" sets bash as the default shell
# "flutter" is the username
# "&&" means run next command if this succeeds
mkdir -p /opt/flutter && \
# Create directory to install Flutter
# "/opt" is conventional location for optional software
chown -R flutter:flutter /opt/flutter
# Change ownership of /opt/flutter to the flutter user
# "-R" means recursive (all files and subfolders)
# "flutter:flutter" means user:group
# Switch to non-root user for Flutter installation
USER flutter
# All subsequent commands run as the "flutter" user instead of "root"
# This is a security best practice
# Install Flutter as non-root user
RUN git clone https://github.com/flutter/flutter.git /opt/flutter -b stable --depth 1
# "git clone" downloads the Flutter repository
# URL: https://github.com/flutter/flutter.git
# Destination: /opt/flutter
# "-b stable" checks out the stable branch (most reliable version)
# "--depth 1" only downloads latest commit (saves space and time)
ENV PATH="/opt/flutter/bin:${PATH}"
# "ENV" sets environment variables
# Add Flutter's bin directory to PATH so Flutter commands can be run from anywhere
# "${PATH}" includes the existing PATH value
# Pre-download Flutter web tools
RUN flutter precache --web
# Download the necessary files for Flutter web development
# "--web" limits download to just web platform tools
# This speeds up future builds
# Configure Flutter
RUN flutter config --no-analytics && \
# Configure Flutter settings:
# "--no-analytics" disables anonymous usage statistics
# "&&" run next command
flutter config --enable-web
# Enable web platform support in Flutter
WORKDIR /home/flutter/app
# "WORKDIR" sets the working directory for subsequent commands
# Any files added or commands run will be relative to this directory
EOF
# End of the Dockerfile content
echo " Building web app..."
# Clean up any permission issues first
echo " Checking permissions..."
if [ -f "pubspec.lock" ]; then
# Check if pubspec.lock file exists
if [ ! -w "pubspec.lock" ]; then
# Check if file is NOT writable by current user
echo " Fixing pubspec.lock permissions..."
sudo chown $(id -u):$(id -g) pubspec.lock 2>/dev/null || rm -f pubspec.lock
# Try to change ownership to current user
# "$(id -u)" gets your user ID
# "$(id -g)" gets your group ID
# If that fails (|| means or), just delete the file
fi
fi
# Run the build in the container with proper user mapping
podman run --rm \
# "podman run" starts a container
# "--rm" removes container when it exits (clean up automatically)
-v "$SCRIPT_DIR:/home/flutter/app:Z" \
# "-v" mounts a volume (shares files between host and container)
# "$SCRIPT_DIR" on host maps to "/home/flutter/app" in container
# ":Z" adjusts SELinux labels for shared access (needed on some Linux systems)
-w /home/flutter/app \
# "-w" sets working directory inside container
--user $(id -u):$(id -g) \
# Run container as current host user (not root)
# Prevents file permission issues
--userns=keep-id \
# Keep user namespace mapping consistent
# Makes user IDs match between host and container
browser-tab-manager-builder \
# Name of the container image to run
bash -c '
# Run a bash command inside the container
# Everything between single quotes is the command
# Set HOME to writable location
export HOME=/tmp/flutter-home
# Set HOME environment variable to temporary directory
# "export" makes variable available to child processes
# Flutter needs a writable home directory
export PUB_CACHE=/tmp/pub-cache
# Set where Flutter stores downloaded packages
# Use temp location since we're running as non-root
# Create web platform if needed
if [ ! -d "web" ]; then
# Check if web directory doesn't exist
flutter create . --platforms web > /dev/null 2>&1
# Create Flutter web project structure
# "." means in current directory
# "> /dev/null 2>&1" hides all output messages
fi
# Get dependencies
flutter pub get
# Download all packages specified in pubspec.yaml
# "pub get" is Flutter's package manager command
# Build for web
flutter build web --release
# Compile Flutter app to optimized JavaScript/HTML/CSS for web browsers
# "--release" creates production-optimized build (smaller, faster)
'
if [ $? -ne 0 ]; then
# Check if previous command failed
# "$?" contains exit code of last command
# "-ne 0" means "not equal to 0" (0 means success)
echo "❌ Build failed!"
exit 1
fi
# Fix permissions (files might be owned by container user)
if [ -d "build/web" ]; then
# Check if build output directory exists
sudo chown -R $(id -u):$(id -g) build/ .dart_tool/ pubspec.lock 2>/dev/null || true
# Change ownership of build files to current user
# Needed because container may have created them as different user
# "|| true" means ignore if this fails
fi
# Create extension directory
echo "📦 Packaging extension..."
rm -rf extension
# Delete old extension folder if it exists
mkdir -p extension
# Create new extension folder
# Copy Flutter build files
cp -r build/web/* extension/
# Copy all files from Flutter's web build output to extension folder
# These are the compiled HTML/JS/CSS files
# Create manifest.json
echo "📋 Adding extension files..."
cat > extension/manifest.json << 'MANIFEST'
# Create manifest.json file for browser extension
# This file tells the browser how to load and run the extension
{
"manifest_version": 3,
# Version of manifest format (3 is current standard)
"name": "Browser Tab Manager",
# Extension name shown in browser
"version": "1.0.0",
# Extension version number
"description": "Manage your browser tabs, bookmarks, and history in a beautiful grid view",
# Short description of what extension does
"permissions": [
# List of browser APIs this extension needs access to
"tabs",
# Access to browser tabs (view, create, close tabs)
"bookmarks",
# Access to bookmarks (read, create, delete)
"history",
# Access to browsing history
"storage"
# Access to browser's storage API for saving data
],
"action": {
# Defines what happens when user clicks extension icon
"default_popup": "index.html",
# Open this HTML file in a popup window
"default_icon": {
# Icon sizes for different display contexts
"16": "icons/Icon-192.png",
"48": "icons/Icon-192.png",
"128": "icons/Icon-512.png"
}
},
"background": {
# Background script runs continuously, even when popup is closed
"service_worker": "background.js"
# JavaScript file that runs in background
},
"content_security_policy": {
# Security rules for what code can run in extension
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
# Allow scripts from extension itself and WebAssembly (needed for Flutter)
# "self" means only scripts packaged with the extension
# "wasm-unsafe-eval" allows WebAssembly execution
},
"icons": {
# Icons for extension in browser's extension list
"16": "icons/Icon-192.png",
"48": "icons/Icon-192.png",
"128": "icons/Icon-512.png"
}
}
MANIFEST
# End of manifest.json content
# Create background.js
cat > extension/background.js << 'BACKGROUND'
# Create background service worker script
# This runs continuously and handles events
// Background service worker for Browser Tab Manager
chrome.runtime.onInstalled.addListener(() => {
// Event listener: runs when extension is first installed or updated
// "chrome.runtime" is the extension API
// "onInstalled.addListener" registers a function to run on install
console.log('Browser Tab Manager installed');
// Log message to browser's developer console
});
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
// Listen for messages from other parts of the extension
// "request" contains the message data
// "sender" identifies who sent the message
// "sendResponse" is function to send response back
if (request.type === 'keepAlive') {
// If message type is 'keepAlive' (heartbeat to keep service worker active)
sendResponse({ status: 'alive' });
// Respond confirming we're running
}
return true;
// Return true to indicate we'll send response asynchronously
});
chrome.tabs.onCreated.addListener(() => {
// Event listener: runs whenever a new tab is created
notifyPopup('tabCreated');
// Call function to notify popup of the change
});
chrome.tabs.onRemoved.addListener(() => {
// Event listener: runs when a tab is closed
notifyPopup('tabRemoved');
});
chrome.tabs.onUpdated.addListener(() => {
// Event listener: runs when tab is updated (URL change, title change, etc.)
notifyPopup('tabUpdated');
});
function notifyPopup(event) {
// Function to send messages to the popup window
chrome.runtime.sendMessage({ type: 'tabsChanged', event }, () => {
// Send message with type and event info
// Callback function (empty arrow function) handles response
if (chrome.runtime.lastError) {
// Check if there was an error sending message
return;
// If popup isn't open, this will error - just ignore it
}
});
}
BACKGROUND
# End of background.js content
# Create browser-bridge.js
cat > extension/browser-bridge.js << 'BRIDGE'
# Create bridge script that connects Flutter app to browser APIs
# This makes browser extension APIs available to the Flutter web app
window.BrowserAPI = {
// Create global object that Flutter can call
// "window" is global JavaScript object in browsers
async getTabs() {
// Async function to get all open tabs
// "async" means function returns a Promise (for asynchronous operations)
if (typeof chrome !== 'undefined' && chrome.tabs) {
// Check if Chrome extension API exists
// "typeof" checks variable type
// "!== 'undefined'" means "is defined"
return new Promise((resolve) => {
// Create Promise for async operation
// "resolve" is function to call when operation completes
chrome.tabs.query({}, (tabs) => {
// Query all tabs (empty object {} means no filters)
// Callback receives array of tab objects
resolve(tabs.map(tab => ({
// Transform browser tab objects to our format
// "map" creates new array by transforming each item
// "=>" is arrow function syntax
id: tab.id.toString(),
// Convert tab ID to string
title: tab.title,
// Tab's page title
url: tab.url,
// Tab's current URL
favicon: tab.favIconUrl || '',
// Tab's favicon (small icon), or empty string if none
// "||" means "or" - use right side if left is falsy
isPinned: tab.pinned,
// Whether tab is pinned
isActive: tab.active,
// Whether this is the currently selected tab
windowId: tab.windowId,
// ID of browser window containing this tab
lastAccessed: new Date().toISOString()
// Current timestamp in ISO format
// "new Date()" creates current date/time
// "toISOString()" converts to standard format
})));
});
});
}
return [];
// If Chrome API not available, return empty array
},
async getBookmarks() {
// Function to get all bookmarks
if (typeof chrome !== 'undefined' && chrome.bookmarks) {
return new Promise((resolve) => {
chrome.bookmarks.getTree((bookmarkTreeNodes) => {
// Get entire bookmark tree structure
const bookmarks = [];
// Array to collect all bookmarks
function traverse(nodes, folder = 'Root') {
// Recursive function to walk through bookmark tree
// "folder = 'Root'" sets default parameter value
for (const node of nodes) {
// Loop through each bookmark node
// "for...of" iterates
# This script builds a BROWSER EXTENSION version of the app.
# Uses a temporary container to compile Flutter to JavaScript.
# Creates an extension/ folder with all files needed for Chrome/Firefox extension.
# Adds manifest.json for browser extension configuration.
# Adds background.js for extension service worker.
# Adds browser-bridge.js to connect Flutter with browser APIs.
# You can then load the extension/ folder in your browser's developer mode.
# DIFFERENCE FROM setup.sh:
# - setup.sh: Builds for WEB (runs in browser or server with nginx)
# - setup-extension.sh: Builds for BROWSER EXTENSION (installable add-on)

65
setup.sh Executable file
View file

@ -0,0 +1,65 @@
#!/bin/bash
set -e
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
cd "$SCRIPT_DIR"
echo "🚀 Setting up Browser Tab Manager (Local)..."
echo "📂 Working directory: $SCRIPT_DIR"
mkdir -p app
cat > app/manifest.json << 'EOF'
{
"name": "Browser Tab Manager",
"short_name": "TabManager",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "Manage browser tabs in a grid view",
"orientation": "portrait-primary",
"prefer_related_applications": false
}
EOF
echo "✅ Project structure ready"
echo "🔨 Building Podman container..."
podman build -t browser-tab-manager .
podman stop browser-tab-manager 2>/dev/null || true
podman rm browser-tab-manager 2>/dev/null || true
echo "🚢 Starting container..."
podman run -d \
--name browser-tab-manager \
-p 8080:80 \
--restart unless-stopped \
browser-tab-manager
echo "✅ Container started successfully!"
echo ""
echo "🌐 Your Browser Tab Manager is now running at:"
echo " http://localhost:8080"
echo ""
echo "📋 Useful commands:"
echo " View logs: podman logs -f browser-tab-manager"
echo " Stop: podman stop browser-tab-manager"
echo " Start: podman start browser-tab-manager"
echo " Restart: podman restart browser-tab-manager"
echo " Remove: podman rm -f browser-tab-manager"
# This script sets up the app for local development.
# Creates the app directory and manifest.json file.
# Builds a Podman container from the Dockerfile.
# Stops and removes any existing container to start fresh.
# Runs the container on port 8080 so you can access it at localhost:8080.
# Shows useful commands for managing the container.

View file

@ -0,0 +1,108 @@
// Track if monitoring is active
let isTracking = false;
let trackedTabs = new Map();
// Listen for messages from popup or content script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'startTracking') {
startTracking();
sendResponse({ status: 'started' });
} else if (message.action === 'stopTracking') {
stopTracking();
sendResponse({ status: 'stopped' });
} else if (message.action === 'getStatus') {
sendResponse({ isTracking, tabCount: trackedTabs.size });
} else if (message.action === 'getAllTabs') {
getAllTabs().then(tabs => {
sendResponse({ tabs });
});
return true; // Keep channel open for async response
}
});
// Start tracking tabs
async function startTracking() {
isTracking = true;
console.log('Started tracking tabs');
// Get all current tabs
const tabs = await chrome.tabs.query({});
tabs.forEach(tab => {
if (tab.url && !tab.url.startsWith('chrome://') && !tab.url.startsWith('edge://')) {
addTab(tab);
}
});
// Send initial tabs to web app
sendTabsToWebApp();
}
// Stop tracking
function stopTracking() {
isTracking = false;
trackedTabs.clear();
console.log('Stopped tracking tabs');
// Notify web app to clear data
sendToWebApp({ action: 'clear' });
}
// Add or update a tab
function addTab(tab) {
trackedTabs.set(tab.id, {
id: tab.id,
url: tab.url,
title: tab.title || tab.url,
favIconUrl: tab.favIconUrl,
timestamp: new Date().toISOString()
});
}
// Get all tabs
async function getAllTabs() {
return Array.from(trackedTabs.values());
}
// Send tabs to web app
async function sendTabsToWebApp() {
const tabs = await getAllTabs();
sendToWebApp({
action: 'updateTabs',
tabs: tabs
});
}
// Send message to web app via content script
function sendToWebApp(data) {
chrome.tabs.query({ url: ['https://tab.caesargaming.org/*', 'http://localhost:8080/*'] }, (tabs) => {
tabs.forEach(tab => {
chrome.tabs.sendMessage(tab.id, data).catch(() => {
// Ignore errors if content script not loaded
});
});
});
}
// Listen for new tabs
chrome.tabs.onCreated.addListener((tab) => {
if (isTracking && tab.url && !tab.url.startsWith('chrome://') && !tab.url.startsWith('edge://')) {
addTab(tab);
sendTabsToWebApp();
}
});
// Listen for tab updates
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (isTracking && changeInfo.status === 'complete' && tab.url && !tab.url.startsWith('chrome://') && !tab.url.startsWith('edge://')) {
addTab(tab);
sendTabsToWebApp();
}
});
// Listen for closed tabs
chrome.tabs.onRemoved.addListener((tabId) => {
if (isTracking && trackedTabs.has(tabId)) {
trackedTabs.delete(tabId);
sendTabsToWebApp();
}
});

View file

@ -0,0 +1,27 @@
// Listen for messages from background script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
// Forward message to web app
window.postMessage({
source: 'tab-tracker-extension',
...message
}, '*');
});
// Listen for messages from web app
window.addEventListener('message', (event) => {
// Only accept messages from same origin
if (event.source !== window) return;
if (event.data.source === 'tab-tracker-webapp') {
// Forward to background script
chrome.runtime.sendMessage(event.data, (response) => {
// Send response back to web app
window.postMessage({
source: 'tab-tracker-extension',
response: response
}, '*');
});
}
});
console.log('Tab Tracker extension content script loaded');

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,40 @@
{
"manifest_version": 3,
"name": "Tab Tracker",
"version": "1.0",
"description": "Track open tabs and send to tab.caesargaming.org",
"permissions": [
"tabs",
"storage",
"activeTab"
],
"host_permissions": [
"https://tab.caesargaming.org/*",
"http://localhost:8080/*"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"content_scripts": [
{
"matches": [
"https://tab.caesargaming.org/*",
"http://localhost:8080/*"
],
"js": ["content.js"]
}
],
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}

View file

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
width: 300px;
padding: 20px;
font-family: Arial, sans-serif;
}
h2 {
margin-top: 0;
color: #0175C2;
}
button {
width: 100%;
padding: 12px;
margin: 8px 0;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
}
#startBtn {
background-color: #4CAF50;
color: white;
}
#startBtn:hover {
background-color: #45a049;
}
#stopBtn {
background-color: #f44336;
color: white;
}
#stopBtn:hover {
background-color: #da190b;
}
#status {
padding: 12px;
margin: 12px 0;
border-radius: 4px;
text-align: center;
font-weight: bold;
}
.tracking {
background-color: #dff0d8;
color: #3c763d;
}
.not-tracking {
background-color: #f2dede;
color: #a94442;
}
#info {
font-size: 12px;
color: #666;
margin-top: 12px;
}
</style>
</head>
<body>
<h2>Tab Tracker</h2>
<div id="status" class="not-tracking">
Not Tracking
</div>
<button id="startBtn">Start Tracking</button>
<button id="stopBtn" style="display: none;">Stop Tracking</button>
<div id="info">
<p>Tracked tabs: <span id="tabCount">0</span></p>
<p>Visit <a href="https://tab.caesargaming.org" target="_blank">tab.caesargaming.org</a> to view your tabs.</p>
</div>
<script src="popup.js"></script>
</body>
</html>

View file

@ -0,0 +1,50 @@
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const statusDiv = document.getElementById('status');
const tabCountSpan = document.getElementById('tabCount');
// Update UI based on tracking status
function updateUI(isTracking, tabCount = 0) {
if (isTracking) {
statusDiv.textContent = 'Tracking Active';
statusDiv.className = 'tracking';
startBtn.style.display = 'none';
stopBtn.style.display = 'block';
tabCountSpan.textContent = tabCount;
} else {
statusDiv.textContent = 'Not Tracking';
statusDiv.className = 'not-tracking';
startBtn.style.display = 'block';
stopBtn.style.display = 'none';
tabCountSpan.textContent = '0';
}
}
// Start tracking
startBtn.addEventListener('click', () => {
chrome.runtime.sendMessage({ action: 'startTracking' }, (response) => {
console.log('Started tracking:', response);
checkStatus();
});
});
// Stop tracking
stopBtn.addEventListener('click', () => {
chrome.runtime.sendMessage({ action: 'stopTracking' }, (response) => {
console.log('Stopped tracking:', response);
checkStatus();
});
});
// Check current status
function checkStatus() {
chrome.runtime.sendMessage({ action: 'getStatus' }, (response) => {
updateUI(response.isTracking, response.tabCount);
});
}
// Initial status check
checkStatus();
// Update status every 2 seconds
setInterval(checkStatus, 2000);

30
test/widget_test.dart Normal file
View file

@ -0,0 +1,30 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:browser_tab_manager/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}

BIN
web/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

BIN
web/icons/Icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
web/icons/Icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

38
web/index.html Normal file
View file

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="browser_tab_manager">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>browser_tab_manager</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>

11
web/manifest.json Normal file
View file

@ -0,0 +1,11 @@
{
"name": "Browser Tab Manager",
"short_name": "TabManager",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "Manage browser tabs in a grid view",
"orientation": "portrait-primary",
"prefer_related_applications": false
}

36
web/manifest.md Normal file
View file

@ -0,0 +1,36 @@
{
"name": "Browser Tab Manager",
"short_name": "TabManager",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "Manage browser tabs in a grid view",
"orientation": "portrait-primary",
"prefer_related_applications": false
}
// This is the web app manifest file that defines how the app appears when installed on a device.
// The name field is the full application name shown to users.
// The short_name is used when there is limited space like on a home screen icon.
// The start_url tells the browser which page to load when the app launches.
// Display standalone makes the app look like a native app without browser UI.
// The background_color sets the splash screen color while the app loads.
// The theme_color defines the color of the browser toolbar and app header.
// Both colors use our app's primary blue color for consistent branding.
// The description explains what the app does for app stores and search results.
// Orientation is set to portrait-primary which works best for mobile devices.
// The prefer_related_applications flag set to false means we want users to install this web app.
// This file enables Progressive Web App features like installation and offline capabilities.