1207 lines
No EOL
39 KiB
Dart
1207 lines
No EOL
39 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());
|
|
// ☝️ STATEMENT: Command that starts the app
|
|
}
|
|
|
|
class BrowserTabManagerApp extends StatelessWidget {
|
|
// This is a STATELESS widget - no changing data
|
|
|
|
const BrowserTabManagerApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
// STATEMENT: Return/create a MaterialApp widget
|
|
|
|
title: 'Browser Tab Manager',
|
|
theme: ThemeData(
|
|
colorScheme: ColorScheme.fromSeed(
|
|
seedColor: const Color(0xFF0175C2),
|
|
brightness: Brightness.light,
|
|
),
|
|
useMaterial3: true,
|
|
),
|
|
darkTheme: ThemeData(
|
|
colorScheme: ColorScheme.fromSeed(
|
|
seedColor: const Color(0xFF0175C2),
|
|
brightness: Brightness.dark,
|
|
),
|
|
useMaterial3: true,
|
|
),
|
|
home: const TabManagerHome(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class TabData {
|
|
// This is a DATA MODEL - holds information about a tab
|
|
|
|
String id; // STATEMENT: Declare a variable
|
|
String title; // STATEMENT: Declare a variable
|
|
String url; // STATEMENT: Declare a variable
|
|
String favicon;
|
|
DateTime lastAccessed;
|
|
bool isPinned;
|
|
String type;
|
|
int? visitCount;
|
|
String? folder;
|
|
|
|
TabData({
|
|
required this.id,
|
|
required this.title,
|
|
required this.url,
|
|
this.favicon = '',
|
|
DateTime? lastAccessed,
|
|
this.isPinned = false,
|
|
this.type = 'tab',
|
|
this.visitCount,
|
|
this.folder,
|
|
}) : lastAccessed = lastAccessed ?? DateTime.now();
|
|
// ☝️ STATEMENT: Constructor that creates TabData objects
|
|
|
|
factory TabData.fromJson(Map<String, dynamic> json) => TabData(
|
|
// ☝️ STATEMENT: Factory method to create TabData from JSON
|
|
id: json['id'].toString(),
|
|
title: json['title'] ?? 'Untitled',
|
|
url: json['url'] ?? '',
|
|
favicon: json['favicon'] ?? json['favIconUrl'] ?? '',
|
|
lastAccessed: json['lastAccessed'] != null
|
|
? DateTime.parse(json['lastAccessed'])
|
|
: (json['lastVisitTime'] != null
|
|
? DateTime.parse(json['lastVisitTime'])
|
|
: (json['dateAdded'] != null
|
|
? DateTime.parse(json['dateAdded'])
|
|
: (json['timestamp'] != null
|
|
? DateTime.parse(json['timestamp'])
|
|
: DateTime.now()))),
|
|
isPinned: json['isPinned'] ?? false,
|
|
type: json['type'] ?? 'tab',
|
|
visitCount: json['visitCount'],
|
|
folder: json['folder'],
|
|
);
|
|
}
|
|
|
|
class TabManagerHome extends StatefulWidget {
|
|
// This is a STATEFUL widget - has changing data (STATE)
|
|
|
|
const TabManagerHome({super.key});
|
|
|
|
@override
|
|
State<TabManagerHome> createState() => _TabManagerHomeState();
|
|
// STATEMENT: Create the state object
|
|
}
|
|
|
|
class _TabManagerHomeState extends State<TabManagerHome> {
|
|
// This class holds all the STATE (changing data)
|
|
|
|
List<TabData> allItems = [];
|
|
// 🔴 STATE: List of all tabs/bookmarks/history
|
|
// When this changes, UI rebuilds to show new data
|
|
|
|
List<TabData> filteredItems = [];
|
|
// 🔴 STATE: Filtered/searched items currently displayed
|
|
|
|
final TextEditingController searchController = TextEditingController();
|
|
// 🔴 STATE: Controls search text field
|
|
|
|
bool isGridView = true;
|
|
// 🔴 STATE: Whether showing grid or list view
|
|
|
|
String sortBy = 'recent';
|
|
// 🔴 STATE: Current sort method
|
|
|
|
String filterType = 'all';
|
|
// 🔴 STATE: Current filter (all, tabs, bookmarks, history)
|
|
|
|
bool isLoading = true;
|
|
// 🔴 STATE: Whether data is loading
|
|
|
|
bool extensionConnected = false;
|
|
// 🔴 STATE: Whether browser extension is connected
|
|
|
|
bool extensionMode = false;
|
|
// 🔴 STATE: Whether in extension tracking mode
|
|
|
|
@override
|
|
void initState() {
|
|
// STATEMENT: Method that runs when widget is created
|
|
|
|
super.initState();
|
|
// STATEMENT: Call parent's initState
|
|
|
|
_setupExtensionListener();
|
|
// STATEMENT: Call function to setup listener
|
|
|
|
_loadAllData();
|
|
// STATEMENT: Call function to load data
|
|
|
|
searchController.addListener(_filterItems);
|
|
// STATEMENT: Add listener to search field
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// STATEMENT: Method that runs when widget is destroyed
|
|
|
|
searchController.dispose();
|
|
// STATEMENT: Clean up resources
|
|
|
|
super.dispose();
|
|
// STATEMENT: Call parent's dispose
|
|
}
|
|
|
|
void _setupExtensionListener() {
|
|
// STATEMENT: Function declaration
|
|
|
|
html.window.onMessage.listen((event) {
|
|
// STATEMENT: Start listening for messages
|
|
|
|
final data = event.data;
|
|
// STATEMENT: Get data from event
|
|
|
|
if (data is Map && data['source'] == 'tab-tracker-extension') {
|
|
// STATEMENT: Conditional check
|
|
|
|
_handleExtensionMessage(Map<String, dynamic>.from(data));
|
|
// STATEMENT: Call function with data
|
|
}
|
|
});
|
|
}
|
|
|
|
void _handleExtensionMessage(Map<String, dynamic> data) {
|
|
print('Received from extension: $data');
|
|
// STATEMENT: Print to console
|
|
|
|
if (data['action'] == 'updateTabs') {
|
|
// STATEMENT: Check if action is updateTabs
|
|
|
|
setState(() {
|
|
// 🔥 SPECIAL STATEMENT: Updates STATE and rebuilds UI
|
|
// Everything inside this block changes STATE
|
|
|
|
final extensionTabs = (data['tabs'] as List).map((tab) {
|
|
tab['type'] = 'tab';
|
|
return TabData.fromJson(tab);
|
|
}).toList();
|
|
// STATEMENT: Transform data
|
|
|
|
allItems = extensionTabs;
|
|
// 🔴 STATE CHANGE: Update the list
|
|
|
|
extensionConnected = true;
|
|
// 🔴 STATE CHANGE: Mark extension as connected
|
|
|
|
extensionMode = true;
|
|
// 🔴 STATE CHANGE: Enable extension mode
|
|
|
|
_filterItems();
|
|
// STATEMENT: Call filter function
|
|
});
|
|
// After setState finishes, Flutter rebuilds the UI automatically
|
|
|
|
} else if (data['action'] == 'clear') {
|
|
// STATEMENT: Check another condition
|
|
|
|
if (extensionMode) {
|
|
setState(() {
|
|
extensionMode = false;
|
|
// 🔴 STATE CHANGE: Disable extension mode
|
|
|
|
_loadAllData();
|
|
// STATEMENT: Reload data
|
|
});
|
|
}
|
|
} else if (data['response'] != null) {
|
|
final response = data['response'];
|
|
if (response['status'] == 'started') {
|
|
setState(() {
|
|
extensionMode = true;
|
|
extensionConnected = true;
|
|
});
|
|
} else if (response['status'] == 'stopped') {
|
|
setState(() {
|
|
extensionMode = false;
|
|
_loadAllData();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
void _sendToExtension(Map<String, dynamic> message) {
|
|
// STATEMENT: Function to send message
|
|
|
|
html.window.postMessage({
|
|
'source': 'tab-tracker-webapp',
|
|
...message
|
|
}, '*');
|
|
// STATEMENT: Post message to window
|
|
}
|
|
|
|
void _startExtensionTracking() {
|
|
_sendToExtension({'action': 'startTracking'});
|
|
// STATEMENT: Send message to extension
|
|
|
|
setState(() {
|
|
// 🔥 UPDATE STATE
|
|
|
|
extensionMode = true;
|
|
// 🔴 STATE CHANGE
|
|
|
|
allItems.clear();
|
|
// 🔴 STATE CHANGE: Clear the list
|
|
});
|
|
}
|
|
|
|
void _stopExtensionTracking() {
|
|
_sendToExtension({'action': 'stopTracking'});
|
|
// STATEMENT: Send stop message
|
|
|
|
setState(() {
|
|
extensionMode = false;
|
|
// 🔴 STATE CHANGE
|
|
});
|
|
|
|
_loadAllData();
|
|
// STATEMENT: Reload data
|
|
}
|
|
|
|
Future<void> _loadAllData() async {
|
|
// STATEMENT: Async function declaration
|
|
|
|
if (extensionMode) return;
|
|
// STATEMENT: Early return if in extension mode
|
|
|
|
setState(() {
|
|
isLoading = true;
|
|
// 🔴 STATE CHANGE: Show loading spinner
|
|
});
|
|
|
|
try {
|
|
// STATEMENT: Try block for error handling
|
|
|
|
final tabs = await _getTabs();
|
|
// STATEMENT: Wait for tabs to load
|
|
|
|
final bookmarks = await _getBookmarks();
|
|
// STATEMENT: Wait for bookmarks to load
|
|
|
|
final history = await _getHistory();
|
|
// STATEMENT: Wait for history to load
|
|
|
|
setState(() {
|
|
// 🔥 UPDATE STATE
|
|
|
|
allItems = [...tabs, ...bookmarks, ...history];
|
|
// 🔴 STATE CHANGE: Combine all items
|
|
|
|
_filterItems();
|
|
// STATEMENT: Filter items
|
|
|
|
isLoading = false;
|
|
// 🔴 STATE CHANGE: Hide loading spinner
|
|
});
|
|
|
|
_checkExtensionConnection();
|
|
// STATEMENT: Check extension
|
|
|
|
} catch (e) {
|
|
// STATEMENT: Catch errors
|
|
|
|
print('Error loading data: $e');
|
|
// STATEMENT: Print error
|
|
|
|
setState(() {
|
|
isLoading = false;
|
|
// 🔴 STATE CHANGE: Hide loading spinner
|
|
});
|
|
}
|
|
}
|
|
|
|
void _checkExtensionConnection() {
|
|
_sendToExtension({'action': 'getStatus'});
|
|
// STATEMENT: Request status
|
|
|
|
Future.delayed(const Duration(milliseconds: 500), () {
|
|
// STATEMENT: Wait 500ms
|
|
|
|
if (mounted) {
|
|
// STATEMENT: Check if widget still exists
|
|
|
|
setState(() {
|
|
// 🔥 UPDATE STATE (even if empty, triggers rebuild)
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<List<TabData>> _getTabs() async {
|
|
// STATEMENT: Function that returns Future
|
|
|
|
try {
|
|
final result = await _callBrowserAPI('getTabs');
|
|
// STATEMENT: Call browser API
|
|
|
|
if (result != null) {
|
|
// STATEMENT: Check if got result
|
|
|
|
final List<dynamic> data = json.decode(result);
|
|
// STATEMENT: Parse JSON
|
|
|
|
return data.map((item) {
|
|
// STATEMENT: Transform each item
|
|
|
|
item['type'] = 'tab';
|
|
// STATEMENT: Set type
|
|
|
|
return TabData.fromJson(item);
|
|
// STATEMENT: Create TabData object
|
|
}).toList();
|
|
}
|
|
} catch (e) {
|
|
print('Error getting tabs: $e');
|
|
// STATEMENT: Print error
|
|
}
|
|
|
|
return [];
|
|
// STATEMENT: Return empty list
|
|
}
|
|
|
|
Future<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 {
|
|
if (!js_util.hasProperty(html.window, 'BrowserAPI')) {
|
|
// STATEMENT: Check if API exists
|
|
|
|
print('BrowserAPI not found - running in development mode');
|
|
return null;
|
|
// STATEMENT: Return null
|
|
}
|
|
|
|
final browserAPI = js_util.getProperty(html.window, 'BrowserAPI');
|
|
// STATEMENT: Get API object
|
|
|
|
final function = js_util.getProperty(browserAPI, method);
|
|
// STATEMENT: Get function
|
|
|
|
final result = args == null
|
|
? await js_util.promiseToFuture(js_util.callMethod(function, 'call', [browserAPI]))
|
|
: await js_util.promiseToFuture(js_util.callMethod(function, 'call', [browserAPI, ...args]));
|
|
// STATEMENT: Call function and wait for result
|
|
|
|
return json.encode(result);
|
|
// STATEMENT: Convert to JSON string
|
|
|
|
} catch (e) {
|
|
print('Error calling $method: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
void _filterItems() {
|
|
final query = searchController.text.toLowerCase();
|
|
// STATEMENT: Get search text
|
|
|
|
setState(() {
|
|
// 🔥 UPDATE STATE
|
|
|
|
filteredItems = allItems.where((item) {
|
|
// STATEMENT: Filter list
|
|
|
|
if (filterType != 'all' && item.type != filterType.replaceAll('s', '')) {
|
|
return false;
|
|
// STATEMENT: Exclude item
|
|
}
|
|
|
|
if (query.isNotEmpty) {
|
|
return item.title.toLowerCase().contains(query) ||
|
|
item.url.toLowerCase().contains(query);
|
|
// STATEMENT: Check if matches search
|
|
}
|
|
|
|
return true;
|
|
// STATEMENT: Include item
|
|
}).toList();
|
|
|
|
_sortItems();
|
|
// STATEMENT: Sort the filtered items
|
|
});
|
|
}
|
|
|
|
void _sortItems() {
|
|
switch (sortBy) {
|
|
// STATEMENT: Switch based on sortBy value
|
|
|
|
case 'recent':
|
|
filteredItems.sort((a, b) => b.lastAccessed.compareTo(a.lastAccessed));
|
|
// STATEMENT: Sort by date
|
|
break;
|
|
|
|
case 'title':
|
|
filteredItems.sort((a, b) => a.title.compareTo(b.title));
|
|
// STATEMENT: Sort alphabetically
|
|
break;
|
|
|
|
case 'url':
|
|
filteredItems.sort((a, b) => a.url.compareTo(b.url));
|
|
break;
|
|
|
|
case 'visits':
|
|
filteredItems.sort((a, b) =>
|
|
(b.visitCount ?? 0).compareTo(a.visitCount ?? 0));
|
|
break;
|
|
}
|
|
|
|
filteredItems.sort((a, b) => b.isPinned ? 1 : (a.isPinned ? -1 : 0));
|
|
// STATEMENT: Keep pinned items at top
|
|
}
|
|
|
|
Future<void> _openItem(TabData item) async {
|
|
if (extensionMode) {
|
|
html.window.open(item.url, '_blank');
|
|
// STATEMENT: Open in new tab
|
|
} else {
|
|
if (item.type == 'tab') {
|
|
await _callBrowserAPI('switchToTab', [item.id]);
|
|
// STATEMENT: Switch to tab
|
|
} else {
|
|
await _callBrowserAPI('openTab', [item.url]);
|
|
// STATEMENT: Open new tab
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteItem(TabData item) async {
|
|
if (extensionMode) return;
|
|
// STATEMENT: Can't delete in extension mode
|
|
|
|
if (item.type == 'tab') {
|
|
await _callBrowserAPI('closeTab', [item.id]);
|
|
// STATEMENT: Close tab
|
|
} else if (item.type == 'bookmark') {
|
|
await _callBrowserAPI('removeBookmark', [item.id]);
|
|
// STATEMENT: Delete bookmark
|
|
}
|
|
|
|
await _loadAllData();
|
|
// STATEMENT: Reload data
|
|
}
|
|
|
|
Future<void> _togglePin(TabData item) async {
|
|
if (extensionMode) return;
|
|
|
|
if (item.type == 'tab') {
|
|
await _callBrowserAPI('togglePinTab', [item.id, !item.isPinned]);
|
|
// STATEMENT: Toggle pin status
|
|
|
|
await _loadAllData();
|
|
// STATEMENT: Reload data
|
|
}
|
|
}
|
|
|
|
String _getIcon(TabData item) {
|
|
// STATEMENT: Function that returns icon emoji
|
|
|
|
if (item.favicon.isNotEmpty && !item.favicon.contains('data:')) {
|
|
return '🌐';
|
|
// STATEMENT: Return globe icon
|
|
}
|
|
|
|
switch (item.type) {
|
|
case 'tab':
|
|
return '📑';
|
|
case 'bookmark':
|
|
return '⭐';
|
|
case 'history':
|
|
return '🕐';
|
|
default:
|
|
return '🌐';
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// STATEMENT: Build method that creates UI
|
|
// This method runs every time setState() is called
|
|
|
|
final stats = {
|
|
'tabs': allItems.where((i) => i.type == 'tab').length,
|
|
'bookmarks': allItems.where((i) => i.type == 'bookmark').length,
|
|
'history': allItems.where((i) => i.type == 'history').length,
|
|
};
|
|
// STATEMENT: Calculate statistics
|
|
// This uses current STATE (allItems) to calculate counts
|
|
|
|
return Scaffold(
|
|
// STATEMENT: Return Scaffold widget
|
|
|
|
appBar: AppBar(
|
|
title: Row(
|
|
children: [
|
|
const Text('Browser Tab Manager'),
|
|
|
|
if (extensionMode) ...[
|
|
// STATEMENT: Conditionally show badge based on STATE
|
|
|
|
const SizedBox(width: 12),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.green,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: const Text(
|
|
'TRACKING',
|
|
style: TextStyle(fontSize: 10, color: Colors.white),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
actions: [
|
|
if (extensionMode)
|
|
// STATEMENT: Show stop button if in extension mode (based on STATE)
|
|
|
|
TextButton.icon(
|
|
onPressed: _stopExtensionTracking,
|
|
// STATEMENT: When pressed, call function that changes STATE
|
|
|
|
icon: const Icon(Icons.stop, color: Colors.white),
|
|
label: const Text('Stop', style: TextStyle(color: Colors.white)),
|
|
)
|
|
else
|
|
TextButton.icon(
|
|
onPressed: extensionConnected ? _startExtensionTracking : null,
|
|
// STATEMENT: Enable button based on STATE
|
|
|
|
icon: const Icon(Icons.play_arrow, color: Colors.white),
|
|
label: const Text('Track Tabs', style: TextStyle(color: Colors.white)),
|
|
),
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh),
|
|
onPressed: extensionMode ? null : _loadAllData,
|
|
// STATEMENT: Disable button based on STATE
|
|
|
|
tooltip: 'Refresh',
|
|
),
|
|
|
|
IconButton(
|
|
icon: Icon(isGridView ? Icons.view_list : Icons.grid_view),
|
|
// STATEMENT: Show different icon based on STATE
|
|
|
|
onPressed: () {
|
|
setState(() {
|
|
// 🔥 UPDATE STATE
|
|
|
|
isGridView = !isGridView;
|
|
// 🔴 STATE CHANGE: Toggle view mode
|
|
});
|
|
},
|
|
tooltip: isGridView ? 'List View' : 'Grid View',
|
|
),
|
|
|
|
PopupMenuButton<String>(
|
|
icon: const Icon(Icons.sort),
|
|
tooltip: 'Sort by',
|
|
onSelected: (value) {
|
|
setState(() {
|
|
// 🔥 UPDATE STATE
|
|
|
|
sortBy = value;
|
|
// 🔴 STATE CHANGE: Change sort method
|
|
|
|
_filterItems();
|
|
// STATEMENT: Re-filter with new sort
|
|
});
|
|
},
|
|
itemBuilder: (context) => [
|
|
const PopupMenuItem(value: 'recent', child: Text('Recent')),
|
|
const PopupMenuItem(value: 'title', child: Text('Title')),
|
|
const PopupMenuItem(value: 'url', child: Text('URL')),
|
|
const PopupMenuItem(value: 'visits', child: Text('Most Visited')),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
|
|
body: Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
children: [
|
|
TextField(
|
|
controller: searchController,
|
|
// Uses STATE (searchController)
|
|
|
|
decoration: InputDecoration(
|
|
hintText: extensionMode
|
|
? 'Search tracked tabs...'
|
|
: 'Search tabs, bookmarks, and history...',
|
|
// STATEMENT: Show different hint based on STATE
|
|
|
|
prefixIcon: const Icon(Icons.search),
|
|
suffixIcon: searchController.text.isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(Icons.clear),
|
|
onPressed: () {
|
|
searchController.clear();
|
|
// STATEMENT: Clear search (changes STATE)
|
|
},
|
|
)
|
|
: null,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
if (!extensionMode)
|
|
// STATEMENT: Show filters only if not in extension mode (based on STATE)
|
|
|
|
SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: [
|
|
FilterChip(
|
|
label: Text('All (${allItems.length})'),
|
|
// Uses STATE to show count
|
|
|
|
selected: filterType == 'all',
|
|
// Uses STATE to show selection
|
|
|
|
onSelected: (selected) {
|
|
setState(() {
|
|
// 🔥 UPDATE STATE
|
|
|
|
filterType = 'all';
|
|
// 🔴 STATE CHANGE
|
|
|
|
_filterItems();
|
|
});
|
|
},
|
|
),
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
FilterChip(
|
|
label: Text('Tabs (${stats['tabs']})'),
|
|
selected: filterType == 'tabs',
|
|
onSelected: (selected) {
|
|
setState(() {
|
|
filterType = 'tabs';
|
|
_filterItems();
|
|
});
|
|
},
|
|
),
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
FilterChip(
|
|
label: Text('Bookmarks (${stats['bookmarks']})'),
|
|
selected: filterType == 'bookmarks',
|
|
onSelected: (selected) {
|
|
setState(() {
|
|
filterType = 'bookmarks';
|
|
_filterItems();
|
|
});
|
|
},
|
|
),
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
FilterChip(
|
|
label: Text('History (${stats['history']})'),
|
|
selected: filterType == 'history',
|
|
onSelected: (selected) {
|
|
setState(() {
|
|
filterType = 'history';
|
|
_filterItems();
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
Expanded(
|
|
child: isLoading
|
|
// STATEMENT: Show different UI based on STATE
|
|
|
|
? const Center(child: CircularProgressIndicator())
|
|
// If isLoading is true, show spinner
|
|
|
|
: filteredItems.isEmpty
|
|
// If filteredItems is empty, show message
|
|
|
|
? Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
extensionMode ? Icons.track_changes : Icons.search_off,
|
|
// Different icon based on STATE
|
|
|
|
size: 80,
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.primary
|
|
.withOpacity(0.3),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
extensionMode
|
|
? 'Waiting for tabs...'
|
|
: 'No items found',
|
|
// Different message based on STATE
|
|
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
extensionMode
|
|
? 'Open some tabs to see them here'
|
|
: 'Try a different search or filter',
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
],
|
|
),
|
|
)
|
|
|
|
: isGridView
|
|
// If isGridView is true, show grid
|
|
|
|
? GridView.builder(
|
|
padding: const EdgeInsets.all(16),
|
|
gridDelegate:
|
|
const SliverGridDelegateWithMaxCrossAxisExtent(
|
|
maxCrossAxisExtent: 300,
|
|
childAspectRatio: 1.3,
|
|
crossAxisSpacing: 16,
|
|
mainAxisSpacing: 16,
|
|
),
|
|
itemCount: filteredItems.length,
|
|
// Uses STATE to determine how many items
|
|
|
|
itemBuilder: (context, index) {
|
|
return ItemCard(
|
|
item: filteredItems[index],
|
|
// Passes STATE data to child widget
|
|
|
|
icon: _getIcon(filteredItems[index]),
|
|
onTap: () => _openItem(filteredItems[index]),
|
|
onDelete: () => _deleteItem(filteredItems[index]),
|
|
onTogglePin: () => _togglePin(filteredItems[index]),
|
|
extensionMode: extensionMode,
|
|
// Passes STATE to child
|
|
);
|
|
},
|
|
)
|
|
|
|
: ListView.builder(
|
|
// Otherwise show list
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: filteredItems.length,
|
|
itemBuilder: (context, index) {
|
|
return ItemListTile(
|
|
item: filteredItems[index],
|
|
icon: _getIcon(filteredItems[index]),
|
|
onTap: () => _openItem(filteredItems[index]),
|
|
onDelete: () => _deleteItem(filteredItems[index]),
|
|
onTogglePin: () => _togglePin(filteredItems[index]),
|
|
extensionMode: extensionMode,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ItemCard and ItemListTile are STATELESS widgets
|
|
// They receive data (STATE) from parent and display it
|
|
// They don't manage their own STATE
|
|
|
|
class ItemCard extends StatelessWidget {
|
|
// This widget receives STATE from parent via constructor parameters
|
|
|
|
final TabData item; // STATE from parent
|
|
final String icon; // STATE from parent
|
|
final VoidCallback onTap; // Function from parent
|
|
final VoidCallback onDelete; // Function from parent
|
|
final VoidCallback onTogglePin; // Function from parent
|
|
final bool extensionMode; // STATE from parent
|
|
|
|
const ItemCard({
|
|
super.key,
|
|
required this.item,
|
|
required this.icon,
|
|
required this.onTap,
|
|
required this.onDelete,
|
|
required this.onTogglePin,
|
|
this.extensionMode = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// STATEMENT: Build method creates UI
|
|
// This widget doesn't have setState because it's StatelessWidget
|
|
// It just displays the data it receives
|
|
|
|
return Card(
|
|
clipBehavior: Clip.antiAlias,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
// STATEMENT: When tapped, call function from parent
|
|
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
height: 60,
|
|
color: Theme.of(context).colorScheme.primaryContainer,
|
|
child: Center(
|
|
child: item.favicon.isNotEmpty && item.favicon.startsWith('http')
|
|
? Image.network(
|
|
item.favicon,
|
|
width: 32,
|
|
height: 32,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return Text(icon, style: const TextStyle(fontSize: 32));
|
|
},
|
|
)
|
|
: Text(icon, style: const TextStyle(fontSize: 32)),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
if (item.isPinned)
|
|
// STATEMENT: Show pin icon if item is pinned
|
|
|
|
Icon(
|
|
Icons.push_pin,
|
|
size: 14,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
if (item.isPinned) const SizedBox(width: 4),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: _getTypeColor(context),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(
|
|
item.type.toUpperCase(),
|
|
// STATEMENT: Display item type from STATE
|
|
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: Theme.of(context).colorScheme.onPrimary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Expanded(
|
|
child: Text(
|
|
item.title,
|
|
// STATEMENT: Display title from STATE
|
|
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
item.url,
|
|
// STATEMENT: Display URL from STATE
|
|
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.secondary,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (item.visitCount != null) ...[
|
|
// STATEMENT: Show visit count if it exists
|
|
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'${item.visitCount} visits',
|
|
// STATEMENT: Display visit count from STATE
|
|
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (!extensionMode)
|
|
// STATEMENT: Show buttons only if not in extension mode (based on STATE)
|
|
|
|
ButtonBar(
|
|
alignment: MainAxisAlignment.end,
|
|
buttonPadding: EdgeInsets.zero,
|
|
children: [
|
|
if (item.type == 'tab')
|
|
// STATEMENT: Show pin button only for tabs
|
|
|
|
IconButton(
|
|
icon: Icon(
|
|
item.isPinned ? Icons.push_pin : Icons.push_pin_outlined,
|
|
// STATEMENT: Different icon based on pin STATE
|
|
|
|
size: 18,
|
|
),
|
|
onPressed: onTogglePin,
|
|
// STATEMENT: When pressed, call parent's function
|
|
|
|
tooltip: item.isPinned ? 'Unpin' : 'Pin',
|
|
),
|
|
if (item.type != 'history')
|
|
// STATEMENT: Show delete button for tabs and bookmarks (not history)
|
|
|
|
IconButton(
|
|
icon: const Icon(Icons.delete_outline, size: 18),
|
|
onPressed: onDelete,
|
|
// STATEMENT: When pressed, call parent's delete function
|
|
|
|
tooltip: 'Delete',
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Color _getTypeColor(BuildContext context) {
|
|
// STATEMENT: Function that returns color based on item type
|
|
|
|
switch (item.type) {
|
|
// STATEMENT: Check item type (from STATE)
|
|
|
|
case 'tab':
|
|
return Colors.blue;
|
|
// STATEMENT: Return blue for tabs
|
|
|
|
case 'bookmark':
|
|
return Colors.orange;
|
|
// STATEMENT: Return orange for bookmarks
|
|
|
|
case 'history':
|
|
return Colors.purple;
|
|
// STATEMENT: Return purple for history
|
|
|
|
default:
|
|
return Theme.of(context).colorScheme.primary;
|
|
// STATEMENT: Return theme color as fallback
|
|
}
|
|
}
|
|
}
|
|
|
|
class ItemListTile extends StatelessWidget {
|
|
// Another STATELESS widget - displays data from parent
|
|
|
|
final TabData item; // STATE from parent
|
|
final String icon; // STATE from parent
|
|
final VoidCallback onTap; // Function from parent
|
|
final VoidCallback onDelete; // Function from parent
|
|
final VoidCallback onTogglePin; // Function from parent
|
|
final bool extensionMode; // STATE from parent
|
|
|
|
const ItemListTile({
|
|
super.key,
|
|
required this.item,
|
|
required this.icon,
|
|
required this.onTap,
|
|
required this.onDelete,
|
|
required this.onTogglePin,
|
|
this.extensionMode = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// STATEMENT: Build method creates UI
|
|
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
child: ListTile(
|
|
leading: CircleAvatar(
|
|
child: item.favicon.isNotEmpty && item.favicon.startsWith('http')
|
|
? Image.network(
|
|
item.favicon,
|
|
width: 20,
|
|
height: 20,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return Text(icon, style: const TextStyle(fontSize: 20));
|
|
},
|
|
)
|
|
: Text(icon, style: const TextStyle(fontSize: 20)),
|
|
),
|
|
title: Row(
|
|
children: [
|
|
if (item.isPinned) ...[
|
|
// STATEMENT: Show pin icon if pinned
|
|
|
|
Icon(
|
|
Icons.push_pin,
|
|
size: 14,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 4),
|
|
],
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: _getTypeColor(context),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(
|
|
item.type.toUpperCase(),
|
|
// STATEMENT: Display type from STATE
|
|
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: Theme.of(context).colorScheme.onPrimary,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
item.title,
|
|
// STATEMENT: Display title from STATE
|
|
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.url,
|
|
// STATEMENT: Display URL from STATE
|
|
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (item.visitCount != null)
|
|
// STATEMENT: Show visit count if exists
|
|
|
|
Text('${item.visitCount} visits',
|
|
style: Theme.of(context).textTheme.bodySmall),
|
|
],
|
|
),
|
|
trailing: extensionMode ? null : Row(
|
|
// STATEMENT: Show buttons based on extensionMode STATE
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (item.type == 'tab')
|
|
// STATEMENT: Pin button only for tabs
|
|
|
|
IconButton(
|
|
icon: Icon(
|
|
item.isPinned ? Icons.push_pin : Icons.push_pin_outlined,
|
|
),
|
|
onPressed: onTogglePin,
|
|
// STATEMENT: Call parent's function
|
|
|
|
tooltip: item.isPinned ? 'Unpin' : 'Pin',
|
|
),
|
|
if (item.type != 'history')
|
|
// STATEMENT: Delete button for tabs and bookmarks
|
|
|
|
IconButton(
|
|
icon: const Icon(Icons.delete_outline),
|
|
onPressed: onDelete,
|
|
// STATEMENT: Call parent's function
|
|
|
|
tooltip: 'Delete',
|
|
),
|
|
],
|
|
),
|
|
onTap: onTap,
|
|
// STATEMENT: When tapped, call parent's function
|
|
),
|
|
);
|
|
}
|
|
|
|
Color _getTypeColor(BuildContext context) {
|
|
// STATEMENT: Function to get color based on type
|
|
|
|
switch (item.type) {
|
|
case 'tab':
|
|
return Colors.blue;
|
|
case 'bookmark':
|
|
return Colors.orange;
|
|
case 'history':
|
|
return Colors.purple;
|
|
default:
|
|
return Theme.of(context).colorScheme.primary;
|
|
}
|
|
}
|
|
} |