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