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 'screens/auth/login_screen.dart';
|
||||
import 'screens/briefing/briefing_screen.dart';
|
||||
import 'screens/knowledge/knowledge_screen.dart';
|
||||
import 'screens/library/project_tasks_screen.dart';
|
||||
import 'screens/chat/chat_screen.dart';
|
||||
import 'screens/chat/conversations_tab_screen.dart';
|
||||
import 'screens/library/library_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/settings/settings_screen.dart';
|
||||
import 'screens/setup/setup_screen.dart';
|
||||
@@ -82,7 +84,10 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.noteNew,
|
||||
builder: (_, _) => const NoteEditScreen(),
|
||||
builder: (_, state) {
|
||||
final extra = state.extra as Map<String, dynamic>?;
|
||||
return NoteEditScreen(noteType: extra?['noteType'] as String?);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.noteDetail,
|
||||
@@ -116,6 +121,16 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
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(
|
||||
path: Routes.chat,
|
||||
builder: (_, state) => ChatScreen(
|
||||
@@ -130,13 +145,17 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
builder: (_, _) => const BriefingScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.library,
|
||||
builder: (_, _) => const LibraryScreen(),
|
||||
path: Routes.knowledge,
|
||||
builder: (_, _) => const KnowledgeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.conversations,
|
||||
builder: (_, _) => const ConversationsTabScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.projects,
|
||||
builder: (_, _) => const ProjectsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -154,8 +173,9 @@ class _Shell extends ConsumerStatefulWidget {
|
||||
class _ShellState extends ConsumerState<_Shell> {
|
||||
static const _tabs = [
|
||||
Routes.briefing,
|
||||
Routes.library,
|
||||
Routes.knowledge,
|
||||
Routes.conversations,
|
||||
Routes.projects,
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -290,15 +310,20 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
label: Text('Briefing'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.library_books_outlined),
|
||||
selectedIcon: Icon(Icons.library_books),
|
||||
label: Text('Library'),
|
||||
icon: Icon(Icons.menu_book_outlined),
|
||||
selectedIcon: Icon(Icons.menu_book),
|
||||
label: Text('Knowledge'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
label: Text('Chat'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.folder_outlined),
|
||||
selectedIcon: Icon(Icons.folder),
|
||||
label: Text('Projects'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
@@ -335,15 +360,20 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
label: 'Briefing',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.library_books_outlined),
|
||||
selectedIcon: Icon(Icons.library_books),
|
||||
label: 'Library',
|
||||
icon: Icon(Icons.menu_book_outlined),
|
||||
selectedIcon: Icon(Icons.menu_book),
|
||||
label: 'Knowledge',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
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) {
|
||||
if (location.startsWith(Routes.library) &&
|
||||
location.contains('tasks')) { return 'Add a task…'; }
|
||||
if (location.startsWith(Routes.knowledge)) return 'Capture a note…';
|
||||
if (location.startsWith(Routes.projects)) return 'Capture a note…';
|
||||
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
|
||||
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 <flutter_timezone/flutter_timezone_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) {
|
||||
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 =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin");
|
||||
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
|
||||
flutter_timezone
|
||||
open_file_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -6,13 +6,17 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import flutter_inappwebview_macos
|
||||
import flutter_timezone
|
||||
import open_file_mac
|
||||
import package_info_plus
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
|
||||
FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin"))
|
||||
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
||||
@@ -957,6 +957,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -7,8 +7,17 @@
|
||||
#include "generated_plugin_registrant.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) {
|
||||
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
|
||||
FlutterTimezonePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi"));
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_inappwebview_windows
|
||||
flutter_timezone
|
||||
permission_handler_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
Reference in New Issue
Block a user