890 lines
28 KiB
Dart
890 lines
28 KiB
Dart
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|