feat(knowledge): wire 4-tab shell, retire LibraryScreen, complete Knowledge View feature
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+43
-13
@@ -17,11 +17,13 @@ import 'providers/update_provider.dart';
|
|||||||
import 'providers/tasks_provider.dart';
|
import 'providers/tasks_provider.dart';
|
||||||
import 'screens/auth/login_screen.dart';
|
import 'screens/auth/login_screen.dart';
|
||||||
import 'screens/briefing/briefing_screen.dart';
|
import 'screens/briefing/briefing_screen.dart';
|
||||||
|
import 'screens/knowledge/knowledge_screen.dart';
|
||||||
import 'screens/library/project_tasks_screen.dart';
|
import 'screens/library/project_tasks_screen.dart';
|
||||||
import 'screens/chat/chat_screen.dart';
|
import 'screens/chat/chat_screen.dart';
|
||||||
import 'screens/chat/conversations_tab_screen.dart';
|
import 'screens/chat/conversations_tab_screen.dart';
|
||||||
import 'screens/library/library_screen.dart';
|
|
||||||
import 'screens/notes/note_detail_screen.dart';
|
import 'screens/notes/note_detail_screen.dart';
|
||||||
|
import 'screens/projects/project_edit_screen.dart';
|
||||||
|
import 'screens/projects/projects_screen.dart';
|
||||||
import 'screens/notes/note_edit_screen.dart';
|
import 'screens/notes/note_edit_screen.dart';
|
||||||
import 'screens/settings/settings_screen.dart';
|
import 'screens/settings/settings_screen.dart';
|
||||||
import 'screens/setup/setup_screen.dart';
|
import 'screens/setup/setup_screen.dart';
|
||||||
@@ -82,7 +84,10 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.noteNew,
|
path: Routes.noteNew,
|
||||||
builder: (_, _) => const NoteEditScreen(),
|
builder: (_, state) {
|
||||||
|
final extra = state.extra as Map<String, dynamic>?;
|
||||||
|
return NoteEditScreen(noteType: extra?['noteType'] as String?);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.noteDetail,
|
path: Routes.noteDetail,
|
||||||
@@ -116,6 +121,16 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
projectId: int.parse(state.pathParameters['id']!),
|
projectId: int.parse(state.pathParameters['id']!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/projects/new',
|
||||||
|
builder: (_, _) => const ProjectEditScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: Routes.projectEdit,
|
||||||
|
builder: (_, state) => ProjectEditScreen(
|
||||||
|
projectId: int.parse(state.pathParameters['id']!),
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.chat,
|
path: Routes.chat,
|
||||||
builder: (_, state) => ChatScreen(
|
builder: (_, state) => ChatScreen(
|
||||||
@@ -130,13 +145,17 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
builder: (_, _) => const BriefingScreen(),
|
builder: (_, _) => const BriefingScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.library,
|
path: Routes.knowledge,
|
||||||
builder: (_, _) => const LibraryScreen(),
|
builder: (_, _) => const KnowledgeScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.conversations,
|
path: Routes.conversations,
|
||||||
builder: (_, _) => const ConversationsTabScreen(),
|
builder: (_, _) => const ConversationsTabScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: Routes.projects,
|
||||||
|
builder: (_, _) => const ProjectsScreen(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -154,8 +173,9 @@ class _Shell extends ConsumerStatefulWidget {
|
|||||||
class _ShellState extends ConsumerState<_Shell> {
|
class _ShellState extends ConsumerState<_Shell> {
|
||||||
static const _tabs = [
|
static const _tabs = [
|
||||||
Routes.briefing,
|
Routes.briefing,
|
||||||
Routes.library,
|
Routes.knowledge,
|
||||||
Routes.conversations,
|
Routes.conversations,
|
||||||
|
Routes.projects,
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -290,15 +310,20 @@ class _ShellState extends ConsumerState<_Shell> {
|
|||||||
label: Text('Briefing'),
|
label: Text('Briefing'),
|
||||||
),
|
),
|
||||||
NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
icon: Icon(Icons.library_books_outlined),
|
icon: Icon(Icons.menu_book_outlined),
|
||||||
selectedIcon: Icon(Icons.library_books),
|
selectedIcon: Icon(Icons.menu_book),
|
||||||
label: Text('Library'),
|
label: Text('Knowledge'),
|
||||||
),
|
),
|
||||||
NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
icon: Icon(Icons.chat_bubble_outline),
|
icon: Icon(Icons.chat_bubble_outline),
|
||||||
selectedIcon: Icon(Icons.chat_bubble),
|
selectedIcon: Icon(Icons.chat_bubble),
|
||||||
label: Text('Chat'),
|
label: Text('Chat'),
|
||||||
),
|
),
|
||||||
|
NavigationRailDestination(
|
||||||
|
icon: Icon(Icons.folder_outlined),
|
||||||
|
selectedIcon: Icon(Icons.folder),
|
||||||
|
label: Text('Projects'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const VerticalDivider(width: 1),
|
const VerticalDivider(width: 1),
|
||||||
@@ -335,15 +360,20 @@ class _ShellState extends ConsumerState<_Shell> {
|
|||||||
label: 'Briefing',
|
label: 'Briefing',
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.library_books_outlined),
|
icon: Icon(Icons.menu_book_outlined),
|
||||||
selectedIcon: Icon(Icons.library_books),
|
selectedIcon: Icon(Icons.menu_book),
|
||||||
label: 'Library',
|
label: 'Knowledge',
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.chat_bubble_outline),
|
icon: Icon(Icons.chat_bubble_outline),
|
||||||
selectedIcon: Icon(Icons.chat_bubble),
|
selectedIcon: Icon(Icons.chat_bubble),
|
||||||
label: 'Chat',
|
label: 'Chat',
|
||||||
),
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.folder_outlined),
|
||||||
|
selectedIcon: Icon(Icons.folder),
|
||||||
|
label: 'Projects',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -411,8 +441,8 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _hintForLocation(String location) {
|
String _hintForLocation(String location) {
|
||||||
if (location.startsWith(Routes.library) &&
|
if (location.startsWith(Routes.knowledge)) return 'Capture a note…';
|
||||||
location.contains('tasks')) { return 'Add a task…'; }
|
if (location.startsWith(Routes.projects)) return 'Capture a note…';
|
||||||
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
|
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
|
||||||
return 'Capture a note…';
|
return 'Capture a note…';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,294 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import '../../core/constants.dart';
|
|
||||||
import '../../data/models/note.dart';
|
|
||||||
import '../../data/models/task.dart';
|
|
||||||
import '../../providers/notes_provider.dart';
|
|
||||||
import '../../providers/projects_provider.dart';
|
|
||||||
import '../../providers/tasks_provider.dart';
|
|
||||||
import '../../widgets/library_item_card.dart';
|
|
||||||
|
|
||||||
enum _LibraryFilter { all, notes, tasks, projects }
|
|
||||||
|
|
||||||
enum _TaskStatusFilter { all, todo, inProgress, done }
|
|
||||||
|
|
||||||
class LibraryScreen extends ConsumerStatefulWidget {
|
|
||||||
const LibraryScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<LibraryScreen> createState() => _LibraryScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LibraryScreenState extends ConsumerState<LibraryScreen> {
|
|
||||||
_LibraryFilter _filter = _LibraryFilter.all;
|
|
||||||
_TaskStatusFilter _taskStatus = _TaskStatusFilter.all;
|
|
||||||
bool _searchActive = false;
|
|
||||||
String _searchQuery = '';
|
|
||||||
final _searchController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_searchController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _matchesSearch(String text) {
|
|
||||||
if (_searchQuery.isEmpty) return true;
|
|
||||||
return text.toLowerCase().contains(_searchQuery.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final notesAsync = ref.watch(notesProvider);
|
|
||||||
final tasksAsync = ref.watch(tasksProvider);
|
|
||||||
final projectsAsync = ref.watch(projectsProvider);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: _searchActive
|
|
||||||
? TextField(
|
|
||||||
controller: _searchController,
|
|
||||||
autofocus: true,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: 'Search…',
|
|
||||||
border: InputBorder.none,
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
onChanged: (q) => setState(() => _searchQuery = q),
|
|
||||||
)
|
|
||||||
: Text('Library', style: theme.textTheme.titleLarge),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(_searchActive ? Icons.close : Icons.search),
|
|
||||||
onPressed: () => setState(() {
|
|
||||||
_searchActive = !_searchActive;
|
|
||||||
if (!_searchActive) {
|
|
||||||
_searchQuery = '';
|
|
||||||
_searchController.clear();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: Column(
|
|
||||||
children: [
|
|
||||||
// ── Filter pills ──────────────────────────────────────────────────
|
|
||||||
SingleChildScrollView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
|
||||||
child: Row(
|
|
||||||
children: _LibraryFilter.values.map((f) {
|
|
||||||
final label = switch (f) {
|
|
||||||
_LibraryFilter.all => 'All',
|
|
||||||
_LibraryFilter.notes => 'Notes',
|
|
||||||
_LibraryFilter.tasks => 'Tasks',
|
|
||||||
_LibraryFilter.projects => 'Projects',
|
|
||||||
};
|
|
||||||
final selected = _filter == f;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8),
|
|
||||||
child: FilterChip(
|
|
||||||
label: Text(label),
|
|
||||||
selected: selected,
|
|
||||||
onSelected: (_) => setState(() {
|
|
||||||
_filter = f;
|
|
||||||
_taskStatus = _TaskStatusFilter.all;
|
|
||||||
}),
|
|
||||||
selectedColor:
|
|
||||||
theme.colorScheme.primary.withValues(alpha: 0.18),
|
|
||||||
checkmarkColor: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Task status sub-filter (Tasks pill only) ───────────────────
|
|
||||||
if (_filter == _LibraryFilter.tasks)
|
|
||||||
SingleChildScrollView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
|
|
||||||
child: Row(
|
|
||||||
children: _TaskStatusFilter.values.map((s) {
|
|
||||||
final label = switch (s) {
|
|
||||||
_TaskStatusFilter.all => 'All',
|
|
||||||
_TaskStatusFilter.todo => 'To Do',
|
|
||||||
_TaskStatusFilter.inProgress => 'In Progress',
|
|
||||||
_TaskStatusFilter.done => 'Done',
|
|
||||||
};
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8),
|
|
||||||
child: ChoiceChip(
|
|
||||||
label: Text(label),
|
|
||||||
selected: _taskStatus == s,
|
|
||||||
onSelected: (_) => setState(() => _taskStatus = s),
|
|
||||||
selectedColor:
|
|
||||||
theme.colorScheme.secondary.withValues(alpha: 0.15),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const Divider(height: 1),
|
|
||||||
|
|
||||||
// ── Content ───────────────────────────────────────────────────────
|
|
||||||
Expanded(
|
|
||||||
child: switch (_filter) {
|
|
||||||
_LibraryFilter.notes => _buildNotesList(notesAsync),
|
|
||||||
_LibraryFilter.tasks => _buildTasksList(tasksAsync),
|
|
||||||
_LibraryFilter.projects => _buildProjectsList(projectsAsync),
|
|
||||||
_LibraryFilter.all => _buildAllList(notesAsync, tasksAsync),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
floatingActionButton: FloatingActionButton(
|
|
||||||
onPressed: () => _showCreateSheet(context),
|
|
||||||
child: const Icon(Icons.add),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildNotesList(AsyncValue<List<Note>> notesAsync) {
|
|
||||||
return notesAsync.when(
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
|
||||||
error: (e, _) => Center(child: Text('Error: $e')),
|
|
||||||
data: (notes) {
|
|
||||||
final filtered = notes
|
|
||||||
.where((n) => _matchesSearch(n.title) || _matchesSearch(n.body))
|
|
||||||
.toList();
|
|
||||||
if (filtered.isEmpty) {
|
|
||||||
return const Center(child: Text('No notes found'));
|
|
||||||
}
|
|
||||||
return RefreshIndicator(
|
|
||||||
onRefresh: () async => ref.invalidate(notesProvider),
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: filtered.length,
|
|
||||||
itemBuilder: (_, i) => NoteLibraryCard(note: filtered[i]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTasksList(AsyncValue<List<Task>> tasksAsync) {
|
|
||||||
return tasksAsync.when(
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
|
||||||
error: (e, _) => Center(child: Text('Error: $e')),
|
|
||||||
data: (tasks) {
|
|
||||||
var filtered = tasks.where((t) => _matchesSearch(t.title)).toList();
|
|
||||||
if (_taskStatus != _TaskStatusFilter.all) {
|
|
||||||
final status = switch (_taskStatus) {
|
|
||||||
_TaskStatusFilter.todo => TaskStatus.todo,
|
|
||||||
_TaskStatusFilter.inProgress => TaskStatus.inProgress,
|
|
||||||
_TaskStatusFilter.done => TaskStatus.done,
|
|
||||||
_ => TaskStatus.todo,
|
|
||||||
};
|
|
||||||
filtered = filtered.where((t) => t.status == status).toList();
|
|
||||||
}
|
|
||||||
if (filtered.isEmpty) {
|
|
||||||
return const Center(child: Text('No tasks found'));
|
|
||||||
}
|
|
||||||
return RefreshIndicator(
|
|
||||||
onRefresh: () async => ref.invalidate(tasksProvider),
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: filtered.length,
|
|
||||||
itemBuilder: (_, i) => TaskLibraryCard(task: filtered[i]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildProjectsList(AsyncValue<List<dynamic>> projectsAsync) {
|
|
||||||
return projectsAsync.when(
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
|
||||||
error: (e, _) => Center(child: Text('Error: $e')),
|
|
||||||
data: (projects) {
|
|
||||||
if (projects.isEmpty) {
|
|
||||||
return const Center(child: Text('No projects'));
|
|
||||||
}
|
|
||||||
return RefreshIndicator(
|
|
||||||
onRefresh: () async => ref.invalidate(projectsProvider),
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: projects.length,
|
|
||||||
itemBuilder: (_, i) =>
|
|
||||||
ProjectLibraryCard(project: projects[i]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAllList(
|
|
||||||
AsyncValue<List<Note>> notesAsync,
|
|
||||||
AsyncValue<List<Task>> tasksAsync,
|
|
||||||
) {
|
|
||||||
final notes = notesAsync.value ?? [];
|
|
||||||
final tasks = tasksAsync.value ?? [];
|
|
||||||
|
|
||||||
// Merge and sort by updatedAt desc
|
|
||||||
final items = <(DateTime, Widget)>[];
|
|
||||||
for (final n in notes) {
|
|
||||||
if (_matchesSearch(n.title) || _matchesSearch(n.body)) {
|
|
||||||
items.add((n.updatedAt, NoteLibraryCard(note: n)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (final t in tasks) {
|
|
||||||
if (_matchesSearch(t.title)) {
|
|
||||||
items.add((t.updatedAt, TaskLibraryCard(task: t)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items.sort((a, b) => b.$1.compareTo(a.$1));
|
|
||||||
|
|
||||||
if (notesAsync.isLoading || tasksAsync.isLoading) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
if (items.isEmpty) {
|
|
||||||
return const Center(child: Text('Nothing here yet'));
|
|
||||||
}
|
|
||||||
return RefreshIndicator(
|
|
||||||
onRefresh: () async {
|
|
||||||
ref.invalidate(notesProvider);
|
|
||||||
ref.invalidate(tasksProvider);
|
|
||||||
},
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: items.length,
|
|
||||||
itemBuilder: (_, i) => items[i].$2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showCreateSheet(BuildContext context) {
|
|
||||||
showModalBottomSheet<void>(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => SafeArea(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.article_outlined),
|
|
||||||
title: const Text('New note'),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
context.push(Routes.noteNew);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.check_box_outlined),
|
|
||||||
title: const Text('New task'),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
context.push(Routes.taskNew);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import '../core/constants.dart';
|
|
||||||
import '../data/models/note.dart';
|
|
||||||
import '../data/models/project.dart';
|
|
||||||
import '../data/models/task.dart';
|
|
||||||
import '../providers/tasks_provider.dart';
|
|
||||||
|
|
||||||
// ── Note card ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class NoteLibraryCard extends StatelessWidget {
|
|
||||||
final Note note;
|
|
||||||
const NoteLibraryCard({super.key, required this.note});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () => context
|
|
||||||
.push(Routes.noteDetail.replaceFirst(':id', '${note.id}')),
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.article_outlined,
|
|
||||||
size: 15, color: theme.colorScheme.onSurfaceVariant),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
note.title.isNotEmpty ? note.title : 'Untitled',
|
|
||||||
style: theme.textTheme.titleSmall,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
_relativeTime(note.updatedAt),
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (note.body.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
note.body.replaceAll('\n', ' '),
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (note.tags.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Wrap(
|
|
||||||
spacing: 4,
|
|
||||||
runSpacing: 2,
|
|
||||||
children: note.tags
|
|
||||||
.take(4)
|
|
||||||
.map((t) => Chip(
|
|
||||||
label: Text(t),
|
|
||||||
materialTapTargetSize:
|
|
||||||
MaterialTapTargetSize.shrinkWrap,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
))
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Task card ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TaskLibraryCard extends ConsumerWidget {
|
|
||||||
final Task task;
|
|
||||||
const TaskLibraryCard({super.key, required this.task});
|
|
||||||
|
|
||||||
Color _priorityColor(BuildContext context) {
|
|
||||||
return switch (task.priority) {
|
|
||||||
TaskPriority.high => const Color(0xFFEF4444),
|
|
||||||
TaskPriority.medium => const Color(0xFFF59E0B),
|
|
||||||
_ => Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData get _statusIcon => switch (task.status) {
|
|
||||||
TaskStatus.done => Icons.check_circle,
|
|
||||||
TaskStatus.inProgress => Icons.timelapse,
|
|
||||||
_ => Icons.radio_button_unchecked,
|
|
||||||
};
|
|
||||||
|
|
||||||
Color _statusColor(BuildContext context) {
|
|
||||||
final cs = Theme.of(context).colorScheme;
|
|
||||||
return switch (task.status) {
|
|
||||||
TaskStatus.done => const Color(0xFF22C55E),
|
|
||||||
TaskStatus.inProgress => cs.primary,
|
|
||||||
_ => cs.onSurfaceVariant,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
TaskStatus get _nextStatus => switch (task.status) {
|
|
||||||
TaskStatus.todo => TaskStatus.inProgress,
|
|
||||||
TaskStatus.inProgress => TaskStatus.done,
|
|
||||||
_ => TaskStatus.todo,
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () => context
|
|
||||||
.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')),
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(8, 10, 14, 10),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
// Status cycle button
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(_statusIcon, color: _statusColor(context)),
|
|
||||||
onPressed: () => ref
|
|
||||||
.read(tasksProvider.notifier)
|
|
||||||
.updateTask(task.id, {'status': _nextStatus.value}),
|
|
||||||
tooltip: 'Cycle status',
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
task.title.isNotEmpty ? task.title : 'Untitled',
|
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
|
||||||
decoration: task.status == TaskStatus.done
|
|
||||||
? TextDecoration.lineThrough
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
if (task.dueDate != null) ...[
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
'Due ${_formatDate(task.dueDate!)}',
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: task.dueDate!.isBefore(DateTime.now()) &&
|
|
||||||
task.status != TaskStatus.done
|
|
||||||
? const Color(0xFFEF4444)
|
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (task.priority != TaskPriority.none &&
|
|
||||||
task.priority != TaskPriority.low)
|
|
||||||
Container(
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _priorityColor(context),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Project card ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class ProjectLibraryCard extends StatelessWidget {
|
|
||||||
final Project project;
|
|
||||||
const ProjectLibraryCard({super.key, required this.project});
|
|
||||||
|
|
||||||
Color _parseColor(String? hex) {
|
|
||||||
if (hex == null || hex.isEmpty) return const Color(0xFF6366F1);
|
|
||||||
try {
|
|
||||||
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
|
|
||||||
} catch (_) {
|
|
||||||
return const Color(0xFF6366F1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final color = _parseColor(project.color);
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () => context
|
|
||||||
.push('/projects/${project.id}/tasks'),
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
// Colour strip
|
|
||||||
Container(width: 6, height: 64, color: color),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
project.title,
|
|
||||||
style: theme.textTheme.titleSmall,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
if (project.description?.isNotEmpty == true) ...[
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
project.description!,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
String _relativeTime(DateTime dt) {
|
|
||||||
final diff = DateTime.now().difference(dt);
|
|
||||||
if (diff.inMinutes < 1) return 'just now';
|
|
||||||
if (diff.inHours < 1) return '${diff.inMinutes}m ago';
|
|
||||||
if (diff.inDays < 1) return '${diff.inHours}h ago';
|
|
||||||
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
|
||||||
return _formatDate(dt);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDate(DateTime dt) {
|
|
||||||
const months = [
|
|
||||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
||||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
|
||||||
];
|
|
||||||
return '${months[dt.month - 1]} ${dt.day}';
|
|
||||||
}
|
|
||||||
@@ -6,10 +6,18 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <flutter_timezone/flutter_timezone_plugin.h>
|
||||||
#include <open_file_linux/open_file_linux_plugin.h>
|
#include <open_file_linux/open_file_linux_plugin.h>
|
||||||
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
|
g_autoptr(FlPluginRegistrar) flutter_timezone_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterTimezonePlugin");
|
||||||
|
flutter_timezone_plugin_register_with_registrar(flutter_timezone_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) open_file_linux_registrar =
|
g_autoptr(FlPluginRegistrar) open_file_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin");
|
||||||
open_file_linux_plugin_register_with_registrar(open_file_linux_registrar);
|
open_file_linux_plugin_register_with_registrar(open_file_linux_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
|
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
flutter_timezone
|
||||||
open_file_linux
|
open_file_linux
|
||||||
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
@@ -6,13 +6,17 @@ import FlutterMacOS
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import flutter_inappwebview_macos
|
import flutter_inappwebview_macos
|
||||||
|
import flutter_timezone
|
||||||
import open_file_mac
|
import open_file_mac
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
|
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
|
||||||
|
FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin"))
|
||||||
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
|
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -957,6 +957,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.1"
|
version: "2.3.1"
|
||||||
|
url_launcher:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: url_launcher
|
||||||
|
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.2"
|
||||||
|
url_launcher_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_android
|
||||||
|
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.29"
|
||||||
|
url_launcher_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_ios
|
||||||
|
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.4.1"
|
||||||
|
url_launcher_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_linux
|
||||||
|
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.2"
|
||||||
|
url_launcher_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_macos
|
||||||
|
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.5"
|
||||||
|
url_launcher_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_platform_interface
|
||||||
|
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.2"
|
||||||
|
url_launcher_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_web
|
||||||
|
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
url_launcher_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_windows
|
||||||
|
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.5"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -7,8 +7,17 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
|
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
|
||||||
|
#include <flutter_timezone/flutter_timezone_plugin_c_api.h>
|
||||||
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
|
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
|
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
|
||||||
|
FlutterTimezonePluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi"));
|
||||||
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
flutter_inappwebview_windows
|
flutter_inappwebview_windows
|
||||||
|
flutter_timezone
|
||||||
|
permission_handler_windows
|
||||||
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
Reference in New Issue
Block a user