diff --git a/lib/screens/knowledge/knowledge_screen.dart b/lib/screens/knowledge/knowledge_screen.dart new file mode 100644 index 0000000..e46d778 --- /dev/null +++ b/lib/screens/knowledge/knowledge_screen.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../providers/knowledge_provider.dart'; +import '../../widgets/knowledge_item_card.dart'; + +// Type tab configuration: (label, noteType filter value) +const _kTabs = [ + (label: 'All', type: null), + (label: 'Notes', type: 'note'), + (label: 'People', type: 'person'), + (label: 'Places', type: 'place'), + (label: 'Lists', type: 'list'), + (label: 'Tasks', type: 'task'), +]; + +class KnowledgeScreen extends ConsumerStatefulWidget { + const KnowledgeScreen({super.key}); + + @override + ConsumerState createState() => _KnowledgeScreenState(); +} + +class _KnowledgeScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabController; + late final ScrollController _scrollController; + bool _searchActive = false; + final _searchController = TextEditingController(); + var _debounce = DateTime.now(); + + @override + void initState() { + super.initState(); + _tabController = TabController(length: _kTabs.length, vsync: this); + _scrollController = ScrollController(); + _scrollController.addListener(_onScroll); + // Trigger initial load + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(knowledgeProvider.notifier).refresh(); + }); + _tabController.addListener(_onTabChanged); + } + + @override + void dispose() { + _tabController.dispose(); + _scrollController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + void _onTabChanged() { + if (!_tabController.indexIsChanging) return; + final newType = _kTabs[_tabController.index].type; + ref.read(knowledgeProvider.notifier).setTypeFilter(newType); + } + + void _onScroll() { + final pos = _scrollController.position; + if (pos.pixels >= pos.maxScrollExtent - 300) { + ref.read(knowledgeProvider.notifier).hydrateNext(); + } + } + + void _onSearchChanged(String q) { + final now = DateTime.now(); + _debounce = now; + Future.delayed(const Duration(milliseconds: 400), () { + if (_debounce == now && mounted) { + ref.read(knowledgeProvider.notifier).setSearch(q); + } + }); + } + + String _tabLabel(int index, Map counts) { + final tab = _kTabs[index]; + if (tab.type == null) { + final total = counts.values.fold(0, (a, b) => a + b); + return total > 0 ? 'All ($total)' : 'All'; + } + final count = counts[tab.type]; + return count != null && count > 0 ? '${tab.label} ($count)' : tab.label; + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(knowledgeProvider); + + return Scaffold( + appBar: AppBar( + title: _searchActive + ? TextField( + controller: _searchController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search knowledge…', + border: InputBorder.none, + ), + onChanged: _onSearchChanged, + ) + : const Text('Knowledge'), + actions: [ + IconButton( + icon: Icon(_searchActive ? Icons.close : Icons.search), + onPressed: () { + setState(() => _searchActive = !_searchActive); + if (!_searchActive) { + _searchController.clear(); + ref.read(knowledgeProvider.notifier).setSearch(null); + } + }, + ), + ], + bottom: TabBar( + controller: _tabController, + isScrollable: true, + tabAlignment: TabAlignment.start, + tabs: List.generate( + _kTabs.length, + (i) => Tab(text: _tabLabel(i, state.counts)), + ), + ), + ), + body: Column( + children: [ + // Tag filter chips + if (state.availableTags.isNotEmpty) + SizedBox( + height: 48, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: state.availableTags.length, + separatorBuilder: (_, _) => const SizedBox(width: 6), + itemBuilder: (_, i) { + final tag = state.availableTags[i]; + final active = state.activeTags.contains(tag); + return FilterChip( + label: Text(tag), + selected: active, + onSelected: (_) => + ref.read(knowledgeProvider.notifier).toggleTag(tag), + visualDensity: VisualDensity.compact, + ); + }, + ), + ), + // Main list + Expanded( + child: _buildList(state), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _onFabTapped(context), + tooltip: 'New', + child: const Icon(Icons.edit_outlined), + ), + ); + } + + Widget _buildList(KnowledgeState state) { + if (state.isLoadingIds && state.ids.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (state.error != null && state.ids.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Failed to load', + style: Theme.of(context).textTheme.bodyLarge), + const SizedBox(height: 8), + TextButton( + onPressed: () => + ref.read(knowledgeProvider.notifier).refresh(), + child: const Text('Retry'), + ), + ], + ), + ); + } + final items = state.orderedItems; + if (items.isEmpty && !state.isLoadingIds && !state.isLoadingBatch) { + return Center( + child: Text( + 'No ${_kTabs[_tabController.index].label.toLowerCase()} yet.', + style: Theme.of(context).textTheme.bodyLarge, + ), + ); + } + return RefreshIndicator( + onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(), + child: ListView.separated( + controller: _scrollController, + itemCount: items.length + (state.isLoadingBatch ? 1 : 0), + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (_, i) { + if (i >= items.length) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + return KnowledgeItemCard(item: items[i]); + }, + ), + ); + } + + void _onFabTapped(BuildContext context) { + final currentType = _kTabs[_tabController.index].type; + if (currentType == 'task') { + context.push('/tasks/new'); + return; + } + _showTypePicker(context); + } + + void _showTypePicker(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _TypePickerRow( + icon: Icons.description_outlined, + label: 'Note', + description: 'General note or document', + onTap: () { + Navigator.pop(sheetContext); + context.push('/notes/new', extra: {'noteType': 'note'}); + }, + ), + _TypePickerRow( + icon: Icons.person_outlined, + label: 'Person', + description: 'Contact, colleague, or reference person', + onTap: () { + Navigator.pop(sheetContext); + context.push('/notes/new', extra: {'noteType': 'person'}); + }, + ), + _TypePickerRow( + icon: Icons.place_outlined, + label: 'Place', + description: 'Location, venue, or place of interest', + onTap: () { + Navigator.pop(sheetContext); + context.push('/notes/new', extra: {'noteType': 'place'}); + }, + ), + _TypePickerRow( + icon: Icons.checklist_outlined, + label: 'List', + description: 'Checklist or structured list', + onTap: () { + Navigator.pop(sheetContext); + context.push('/notes/new', extra: {'noteType': 'list'}); + }, + ), + ], + ), + ), + ); + } +} + +class _TypePickerRow extends StatelessWidget { + final IconData icon; + final String label; + final String description; + final VoidCallback onTap; + + const _TypePickerRow({ + required this.icon, + required this.label, + required this.description, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon(icon), + title: Text(label), + subtitle: Text(description), + onTap: onTap, + ); + } +}