Browser-Tab-Manager/dist/browser-tab-manager/lib/main.dart

890 lines
28 KiB
Dart
Raw Normal View History

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;
}
}
}