feat: app overhaul — Briefing-first navigation, Library, capture queue
Plans 1-3 implemented: Plan 1 — Foundation - Add google_fonts ^6.2.1 - lib/core/theme.dart: slate-indigo ColorScheme (dark/light), Fraunces headings, GradientButton widget - lib/providers/capture_work_queue_provider.dart: in-memory sequential work queue; CaptureWorkQueueNotifier drains one item at a time; captureResultProvider feeds snackbars to UI - lib/app.dart: wire fabledDarkTheme/fabledLightTheme; replace blocking _QuickCaptureBar with queue-based implementation (progress bar, badge) Plan 2 — Navigation - 3-tab shell: Briefing · Library · Chat (was Notes · Tasks · Projects · Chat) - lib/screens/library/library_screen.dart: unified notes+tasks+projects list with filter pills, status sub-filter for tasks, live search, FAB - lib/widgets/library_item_card.dart: NoteLibraryCard, TaskLibraryCard (status cycle), ProjectLibraryCard - lib/screens/chat/conversations_tab_screen.dart: focused replacement for ConversationsListScreen - Delete 5 dead screens: notes_list, tasks_list, project_list, conversations_list, quick_capture Plan 3 — Briefing - lib/data/models/briefing_conversation.dart - lib/data/api/briefing_api.dart: getToday, getHistory, getMessages, triggerSlot - lib/providers/briefing_provider.dart: BriefingNotifier with sendReply (optimistic + SSE + poll, same pattern as MessagesNotifier) and refresh - lib/widgets/chat_message_bubble.dart: extracted + redesigned shared bubble (ghost user bubbles, left-accent assistant bubbles) - lib/widgets/briefing_digest_card.dart: expandable first-message card - lib/screens/briefing/briefing_screen.dart: digest card, conversation list, streaming reply bar, refresh button, history overflow menu - lib/screens/briefing/briefing_history_screen.dart: read-only past dates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+119
-163
@@ -4,27 +4,26 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'core/constants.dart';
|
||||
import 'core/exceptions.dart';
|
||||
import 'core/theme.dart';
|
||||
import 'providers/api_client_provider.dart';
|
||||
import 'providers/auth_provider.dart';
|
||||
import 'providers/capture_queue_provider.dart';
|
||||
import 'providers/milestones_provider.dart';
|
||||
import 'providers/capture_work_queue_provider.dart';
|
||||
import 'providers/notes_provider.dart';
|
||||
import 'providers/projects_provider.dart';
|
||||
import 'providers/settings_provider.dart';
|
||||
import 'providers/update_provider.dart';
|
||||
import 'providers/tasks_provider.dart';
|
||||
import 'screens/auth/login_screen.dart';
|
||||
import 'screens/briefing/briefing_screen.dart';
|
||||
import 'screens/chat/chat_screen.dart';
|
||||
import 'screens/chat/conversations_list_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_edit_screen.dart';
|
||||
import 'screens/notes/notes_list_screen.dart';
|
||||
import 'screens/projects/project_list_screen.dart';
|
||||
import 'screens/settings/settings_screen.dart';
|
||||
import 'screens/setup/setup_screen.dart';
|
||||
import 'screens/splash/splash_screen.dart';
|
||||
import 'screens/tasks/task_edit_screen.dart';
|
||||
import 'screens/tasks/tasks_list_screen.dart';
|
||||
|
||||
// ChangeNotifier that fires when auth or server URL changes,
|
||||
// used as GoRouter.refreshListenable so the router re-evaluates redirects
|
||||
@@ -118,20 +117,16 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
builder: (context, state, child) => _Shell(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: Routes.notes,
|
||||
builder: (_, _) => const NotesListScreen(),
|
||||
path: Routes.briefing,
|
||||
builder: (_, _) => const BriefingScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.tasks,
|
||||
builder: (_, _) => const TasksListScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.projects,
|
||||
builder: (_, _) => const ProjectListScreen(),
|
||||
path: Routes.library,
|
||||
builder: (_, _) => const LibraryScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.conversations,
|
||||
builder: (_, _) => const ConversationsListScreen(),
|
||||
builder: (_, _) => const ConversationsTabScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -148,7 +143,11 @@ class _Shell extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _ShellState extends ConsumerState<_Shell> {
|
||||
static const _tabs = [Routes.notes, Routes.tasks, Routes.projects, Routes.conversations];
|
||||
static const _tabs = [
|
||||
Routes.briefing,
|
||||
Routes.library,
|
||||
Routes.conversations,
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -236,8 +235,6 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final index = _tabIndex(location);
|
||||
final child = widget.child;
|
||||
// Use NavigationRail whenever the screen is wide enough — covers both
|
||||
// phone landscape and tablets in either orientation (600 dp breakpoint).
|
||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||
|
||||
if (isWide) {
|
||||
@@ -255,19 +252,14 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: const [
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.note_outlined),
|
||||
selectedIcon: Icon(Icons.note),
|
||||
label: Text('Notes'),
|
||||
icon: Icon(Icons.wb_sunny_outlined),
|
||||
selectedIcon: Icon(Icons.wb_sunny),
|
||||
label: Text('Briefing'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.check_box_outlined),
|
||||
selectedIcon: Icon(Icons.check_box),
|
||||
label: Text('Tasks'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.folder_outlined),
|
||||
selectedIcon: Icon(Icons.folder),
|
||||
label: Text('Projects'),
|
||||
icon: Icon(Icons.library_books_outlined),
|
||||
selectedIcon: Icon(Icons.library_books),
|
||||
label: Text('Library'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
@@ -298,11 +290,21 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
selectedIndex: index,
|
||||
onDestinationSelected: (i) => context.go(_tabs[i]),
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.note), label: 'Notes'),
|
||||
NavigationDestination(icon: Icon(Icons.check_box), label: 'Tasks'),
|
||||
NavigationDestination(icon: Icon(Icons.folder), label: 'Projects'),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_bubble), label: 'Chat'),
|
||||
icon: Icon(Icons.wb_sunny_outlined),
|
||||
selectedIcon: Icon(Icons.wb_sunny),
|
||||
label: 'Briefing',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.library_books_outlined),
|
||||
selectedIcon: Icon(Icons.library_books),
|
||||
label: 'Library',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
label: 'Chat',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -318,13 +320,11 @@ class _QuickCaptureBar extends ConsumerStatefulWidget {
|
||||
|
||||
class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
final _controller = TextEditingController();
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Retry any offline-queued captures from previous sessions.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _drainQueue());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _drainOfflineQueue());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -333,63 +333,15 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
void _submit() {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty || _busy) return;
|
||||
|
||||
if (text.isEmpty) return;
|
||||
_controller.clear();
|
||||
setState(() => _busy = true);
|
||||
|
||||
// Capture the messenger before the async gap so we can show a snackbar
|
||||
// even if the user has navigated to a deeper view by the time it resolves.
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
try {
|
||||
final result = await ref.read(quickCaptureApiProvider).capture(text);
|
||||
|
||||
switch (result.type) {
|
||||
case 'note':
|
||||
ref.invalidate(notesProvider);
|
||||
case 'task':
|
||||
case 'todo':
|
||||
ref.invalidate(tasksProvider);
|
||||
ref.invalidate(projectsProvider);
|
||||
ref.invalidate(projectMilestonesProvider);
|
||||
}
|
||||
|
||||
final msg = result.message.isNotEmpty
|
||||
? result.message
|
||||
: '${_typeLabel(result.type)} created: ${result.title}';
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(msg), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
_drainQueue(); // Silently flush any offline-queued captures.
|
||||
} on NetworkException {
|
||||
await ref.read(captureQueueProvider.notifier).enqueue(text);
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
"You're offline — capture saved and will retry automatically."),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} on AppException catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(e.message), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
} catch (_) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Capture failed. Please try again.'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
setState(() {}); // clear suffix icon
|
||||
ref.read(captureWorkQueueProvider.notifier).enqueue(text);
|
||||
}
|
||||
|
||||
Future<void> _drainQueue() async {
|
||||
Future<void> _drainOfflineQueue() async {
|
||||
if (!mounted) return;
|
||||
final queue = ref.read(captureQueueProvider);
|
||||
if (queue.isEmpty) return;
|
||||
@@ -406,28 +358,20 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
case 'task':
|
||||
case 'todo':
|
||||
ref.invalidate(tasksProvider);
|
||||
ref.invalidate(projectsProvider);
|
||||
ref.invalidate(projectMilestonesProvider);
|
||||
}
|
||||
} on NetworkException {
|
||||
break; // Still offline — stop draining.
|
||||
break;
|
||||
} catch (_) {
|
||||
// Server/parse error — remove to avoid infinite retries.
|
||||
if (mounted) await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||
if (mounted) {
|
||||
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _typeLabel(String type) => switch (type) {
|
||||
'note' => 'Note',
|
||||
'task' => 'Task',
|
||||
'event' => 'Event',
|
||||
'todo' => 'To-do',
|
||||
_ => type,
|
||||
};
|
||||
|
||||
String _hintForLocation(String location) {
|
||||
if (location.startsWith(Routes.tasks)) return 'Add a task…';
|
||||
if (location.startsWith(Routes.library) &&
|
||||
location.contains('tasks')) return 'Add a task…';
|
||||
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
|
||||
return 'Capture a note…';
|
||||
}
|
||||
@@ -435,61 +379,82 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final queueCount = ref.watch(captureQueueProvider).length;
|
||||
final offlineQueueCount = ref.watch(captureQueueProvider).length;
|
||||
final workQueue = ref.watch(captureWorkQueueProvider);
|
||||
final isWorking = workQueue.isNotEmpty;
|
||||
final totalPending = workQueue.length + offlineQueueCount;
|
||||
|
||||
// Show snackbar when a result is published.
|
||||
ref.listen(captureResultProvider, (_, result) {
|
||||
if (result == null || !mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result.message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 4, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
enabled: !_busy,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _submit(),
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
hintText: _hintForLocation(location),
|
||||
isDense: true,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
prefixIcon: _busy
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: queueCount > 0
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 4, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _submit(),
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
hintText: _hintForLocation(location),
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 10),
|
||||
prefixIcon: totalPending > 0
|
||||
? Badge(
|
||||
label: Text('$queueCount'),
|
||||
child:
|
||||
const Icon(Icons.cloud_upload_outlined),
|
||||
label: Text('$totalPending'),
|
||||
child: const Icon(Icons.cloud_upload_outlined),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome_outlined),
|
||||
suffixIcon: _controller.text.trim().isNotEmpty && !_busy
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _submit,
|
||||
tooltip: 'Capture',
|
||||
)
|
||||
: null,
|
||||
: isWorking
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome_outlined),
|
||||
suffixIcon: _controller.text.trim().isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _submit,
|
||||
tooltip: 'Capture',
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => context.push(Routes.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => context.push(Routes.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Thin progress bar while the work queue is draining.
|
||||
if (isWorking)
|
||||
const LinearProgressIndicator(minHeight: 2)
|
||||
else
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -506,17 +471,8 @@ class FabledApp extends ConsumerWidget {
|
||||
return MaterialApp.router(
|
||||
title: 'Fabled',
|
||||
themeMode: themeMode,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.indigo,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
theme: fabledLightTheme(),
|
||||
darkTheme: fabledDarkTheme(),
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,4 +14,6 @@ abstract class Routes {
|
||||
static const chat = '/chat/:id';
|
||||
static const quickCapture = '/quick-capture';
|
||||
static const settings = '/settings';
|
||||
static const briefing = '/briefing';
|
||||
static const library = '/library';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
// ── Colour constants ──────────────────────────────────────────────────────────
|
||||
|
||||
const _darkBackground = Color(0xFF111113);
|
||||
const _darkSurface = Color(0xFF18181C);
|
||||
const _darkSurfaceVar = Color(0xFF1E1E24);
|
||||
const _darkPrimary = Color(0xFF6366F1);
|
||||
const _darkOnSurface = Color(0xFFE8E8F0);
|
||||
const _darkOnSurfaceVar = Color(0xFF8888A8);
|
||||
const _darkOutline = Color(0xFF2E2E3A);
|
||||
|
||||
const _lightBackground = Color(0xFFF4F4F8);
|
||||
const _lightSurface = Color(0xFFFFFFFF);
|
||||
const _lightSurfaceVar = Color(0xFFF0F0F5);
|
||||
const _lightPrimary = Color(0xFF4F46E5);
|
||||
const _lightOnSurface = Color(0xFF18181C);
|
||||
const _lightOnSurfaceVar = Color(0xFF6B6B88);
|
||||
const _lightOutline = Color(0xFFD4D4E4);
|
||||
|
||||
// ── Typography ─────────────────────────────────────────────────────────────────
|
||||
|
||||
TextTheme _buildTextTheme(TextTheme base) {
|
||||
final fraunces = GoogleFonts.frauncesTextTheme(base);
|
||||
return base.copyWith(
|
||||
// Headings / titles use Fraunces
|
||||
headlineLarge: fraunces.headlineLarge,
|
||||
headlineMedium: fraunces.headlineMedium,
|
||||
headlineSmall: fraunces.headlineSmall,
|
||||
titleLarge: fraunces.titleLarge,
|
||||
titleMedium: fraunces.titleMedium,
|
||||
// Body / labels remain system default
|
||||
);
|
||||
}
|
||||
|
||||
// ── Themes ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
ThemeData fabledDarkTheme() {
|
||||
final cs = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
primary: _darkPrimary,
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: const Color(0xFF3730A3),
|
||||
onPrimaryContainer: _darkOnSurface,
|
||||
secondary: _darkPrimary,
|
||||
onSecondary: Colors.white,
|
||||
secondaryContainer: _darkSurfaceVar,
|
||||
onSecondaryContainer: _darkOnSurface,
|
||||
tertiary: _darkPrimary,
|
||||
onTertiary: Colors.white,
|
||||
tertiaryContainer: _darkSurfaceVar,
|
||||
onTertiaryContainer: _darkOnSurface,
|
||||
error: const Color(0xFFEF4444),
|
||||
onError: Colors.white,
|
||||
errorContainer: const Color(0xFF7F1D1D),
|
||||
onErrorContainer: const Color(0xFFFEE2E2),
|
||||
surface: _darkSurface,
|
||||
onSurface: _darkOnSurface,
|
||||
surfaceContainerHighest: _darkSurfaceVar,
|
||||
onSurfaceVariant: _darkOnSurfaceVar,
|
||||
outline: _darkOutline,
|
||||
outlineVariant: _darkOutline,
|
||||
shadow: Colors.black,
|
||||
scrim: Colors.black,
|
||||
inverseSurface: _darkOnSurface,
|
||||
onInverseSurface: _darkSurface,
|
||||
inversePrimary: _lightPrimary,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: cs,
|
||||
scaffoldBackgroundColor: _darkBackground,
|
||||
textTheme: _buildTextTheme(ThemeData.dark().textTheme),
|
||||
cardTheme: CardThemeData(
|
||||
color: _darkSurface,
|
||||
elevation: 2,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.4),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: _darkSurface,
|
||||
indicatorColor: _darkPrimary.withValues(alpha: 0.2),
|
||||
),
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
backgroundColor: _darkSurface,
|
||||
indicatorColor: _darkPrimary.withValues(alpha: 0.2),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: _darkSurfaceVar,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: _darkOutline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: _darkOutline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: _darkPrimary, width: 2),
|
||||
),
|
||||
),
|
||||
dividerTheme: DividerThemeData(color: _darkOutline, thickness: 1),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: _darkSurfaceVar,
|
||||
labelStyle: TextStyle(color: _darkOnSurfaceVar, fontSize: 12),
|
||||
side: BorderSide(color: _darkOutline),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ThemeData fabledLightTheme() {
|
||||
final cs = ColorScheme(
|
||||
brightness: Brightness.light,
|
||||
primary: _lightPrimary,
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: const Color(0xFFE0E0FF),
|
||||
onPrimaryContainer: _lightOnSurface,
|
||||
secondary: _lightPrimary,
|
||||
onSecondary: Colors.white,
|
||||
secondaryContainer: _lightSurfaceVar,
|
||||
onSecondaryContainer: _lightOnSurface,
|
||||
tertiary: _lightPrimary,
|
||||
onTertiary: Colors.white,
|
||||
tertiaryContainer: _lightSurfaceVar,
|
||||
onTertiaryContainer: _lightOnSurface,
|
||||
error: const Color(0xFFDC2626),
|
||||
onError: Colors.white,
|
||||
errorContainer: const Color(0xFFFEE2E2),
|
||||
onErrorContainer: const Color(0xFF7F1D1D),
|
||||
surface: _lightSurface,
|
||||
onSurface: _lightOnSurface,
|
||||
surfaceContainerHighest: _lightSurfaceVar,
|
||||
onSurfaceVariant: _lightOnSurfaceVar,
|
||||
outline: _lightOutline,
|
||||
outlineVariant: _lightOutline,
|
||||
shadow: Colors.black,
|
||||
scrim: Colors.black,
|
||||
inverseSurface: _lightOnSurface,
|
||||
onInverseSurface: _lightSurface,
|
||||
inversePrimary: _darkPrimary,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: cs,
|
||||
scaffoldBackgroundColor: _lightBackground,
|
||||
textTheme: _buildTextTheme(ThemeData.light().textTheme),
|
||||
cardTheme: CardThemeData(
|
||||
color: _lightSurface,
|
||||
elevation: 1,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.08),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: _lightSurface,
|
||||
indicatorColor: _lightPrimary.withValues(alpha: 0.12),
|
||||
),
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
backgroundColor: _lightSurface,
|
||||
indicatorColor: _lightPrimary.withValues(alpha: 0.12),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: _lightSurfaceVar,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: _lightOutline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: _lightOutline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: _lightPrimary, width: 2),
|
||||
),
|
||||
),
|
||||
dividerTheme: DividerThemeData(color: _lightOutline, thickness: 1),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: _lightSurfaceVar,
|
||||
labelStyle: TextStyle(color: _lightOnSurfaceVar, fontSize: 12),
|
||||
side: BorderSide(color: _lightOutline),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── GradientButton ─────────────────────────────────────────────────────────────
|
||||
// Use wherever the web app uses the indigo gradient button (send, primary actions).
|
||||
|
||||
class GradientButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
const GradientButton({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final disabled = onPressed == null;
|
||||
return AnimatedOpacity(
|
||||
opacity: disabled ? 0.45 : 1.0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: disabled
|
||||
? null
|
||||
: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
|
||||
),
|
||||
color: disabled ? const Color(0xFF6366F1) : null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: disabled
|
||||
? null
|
||||
: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF6366F1).withValues(alpha: 0.35),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(padding: padding, child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/briefing_conversation.dart';
|
||||
import '../models/message.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class BriefingApi {
|
||||
final Dio _dio;
|
||||
const BriefingApi(this._dio);
|
||||
|
||||
/// GET /api/briefing/conversations/today
|
||||
/// Returns (or creates) today's briefing conversation with messages embedded.
|
||||
Future<BriefingConversation> getToday() async {
|
||||
try {
|
||||
final response = await _dio.get('/api/briefing/conversations/today');
|
||||
return BriefingConversation.fromJson(
|
||||
response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/briefing/conversations
|
||||
/// Returns list of past briefing conversations (no messages embedded).
|
||||
Future<List<BriefingConversation>> getHistory() async {
|
||||
try {
|
||||
final response = await _dio.get('/api/briefing/conversations');
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final list = data['conversations'] as List<dynamic>;
|
||||
return list
|
||||
.map((e) => BriefingConversation.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/briefing/conversations/<id>/messages
|
||||
Future<List<Message>> getMessages(int convId) async {
|
||||
try {
|
||||
final response =
|
||||
await _dio.get('/api/briefing/conversations/$convId/messages');
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final list = data['messages'] as List<dynamic>;
|
||||
return list
|
||||
.map((e) => Message.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/briefing/trigger body: {"slot": slot}
|
||||
/// slot: "compilation" | "morning" | "midday" | "afternoon"
|
||||
Future<void> triggerSlot(String slot) async {
|
||||
try {
|
||||
await _dio.post('/api/briefing/trigger', data: {'slot': slot});
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'message.dart';
|
||||
|
||||
class BriefingConversation {
|
||||
final int id;
|
||||
final String title;
|
||||
final String? briefingDate; // YYYY-MM-DD or null
|
||||
final List<Message> messages;
|
||||
|
||||
const BriefingConversation({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.briefingDate,
|
||||
required this.messages,
|
||||
});
|
||||
|
||||
factory BriefingConversation.fromJson(Map<String, dynamic> json) {
|
||||
final rawMessages = json['messages'] as List<dynamic>? ?? [];
|
||||
return BriefingConversation(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
briefingDate: json['briefing_date'] as String?,
|
||||
messages: rawMessages
|
||||
.map((e) => Message.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
BriefingConversation copyWith({List<Message>? messages}) =>
|
||||
BriefingConversation(
|
||||
id: id,
|
||||
title: title,
|
||||
briefingDate: briefingDate,
|
||||
messages: messages ?? this.messages,
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/api/api_client.dart';
|
||||
import '../data/api/auth_api.dart';
|
||||
import '../data/api/briefing_api.dart';
|
||||
import '../data/api/chat_api.dart';
|
||||
import '../data/api/milestones_api.dart';
|
||||
import '../data/api/notes_api.dart';
|
||||
@@ -80,3 +81,7 @@ final milestonesApiProvider = Provider<MilestonesApi>((ref) {
|
||||
final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
|
||||
return MilestonesRepository(ref.watch(milestonesApiProvider));
|
||||
});
|
||||
|
||||
final briefingApiProvider = Provider<BriefingApi>((ref) {
|
||||
return BriefingApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/models/briefing_conversation.dart';
|
||||
import '../data/models/message.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
/// Drives the loading indicator in BriefingScreen's reply area.
|
||||
final isBriefingStreamingProvider = StateProvider<bool>((ref) => false);
|
||||
|
||||
final briefingProvider =
|
||||
AsyncNotifierProvider<BriefingNotifier, BriefingConversation>(
|
||||
BriefingNotifier.new);
|
||||
|
||||
class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
||||
@override
|
||||
Future<BriefingConversation> build() async {
|
||||
return ref.read(briefingApiProvider).getToday();
|
||||
}
|
||||
|
||||
/// Trigger a briefing slot (e.g. "compilation") then reload.
|
||||
Future<void> refresh(String slot) async {
|
||||
await ref.read(briefingApiProvider).triggerSlot(slot);
|
||||
ref.invalidateSelf();
|
||||
await future;
|
||||
}
|
||||
|
||||
/// Send a reply to today's briefing conversation.
|
||||
///
|
||||
/// Mirrors MessagesNotifier.sendMessage():
|
||||
/// 1. Optimistic UI update
|
||||
/// 2. POST message to chat endpoint
|
||||
/// 3. SSE stream (best-effort)
|
||||
/// 4. Poll until complete
|
||||
Future<void> sendReply(String content) async {
|
||||
final conv = state.valueOrNull;
|
||||
if (conv == null) return;
|
||||
final convId = conv.id;
|
||||
final chatApi = ref.read(chatApiProvider);
|
||||
|
||||
final previous = conv.messages;
|
||||
final userMsg = Message(
|
||||
conversationId: convId,
|
||||
role: MessageRole.user,
|
||||
content: content,
|
||||
);
|
||||
final placeholder = Message(
|
||||
conversationId: convId,
|
||||
role: MessageRole.assistant,
|
||||
content: '',
|
||||
status: 'generating',
|
||||
);
|
||||
state = AsyncData(conv.copyWith(messages: [...previous, userMsg, placeholder]));
|
||||
ref.read(isBriefingStreamingProvider.notifier).state = true;
|
||||
|
||||
try {
|
||||
await chatApi.sendMessage(convId, content);
|
||||
} catch (e) {
|
||||
state = AsyncData(conv.copyWith(messages: previous));
|
||||
ref.read(isBriefingStreamingProvider.notifier).state = false;
|
||||
rethrow;
|
||||
}
|
||||
|
||||
// SSE stream (best-effort)
|
||||
bool streamedContent = false;
|
||||
try {
|
||||
await for (final chunk in chatApi.streamGeneration(convId)) {
|
||||
streamedContent = true;
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) break;
|
||||
final msgs = current.messages;
|
||||
if (msgs.isEmpty) continue;
|
||||
final updated = msgs.last.copyWith(content: msgs.last.content + chunk);
|
||||
state = AsyncData(current.copyWith(
|
||||
messages: [...msgs.sublist(0, msgs.length - 1), updated]));
|
||||
}
|
||||
} catch (_) {
|
||||
// Fall through to polling.
|
||||
}
|
||||
|
||||
// Poll until complete (max 20 attempts, 2s apart)
|
||||
try {
|
||||
for (var attempt = 0; attempt < 20; attempt++) {
|
||||
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
|
||||
final fresh = await ref.read(briefingApiProvider).getMessages(convId);
|
||||
final done = fresh.any(
|
||||
(m) => m.role == MessageRole.assistant && m.status != 'generating',
|
||||
);
|
||||
final hasContent = fresh.any(
|
||||
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
|
||||
);
|
||||
final current = state.valueOrNull;
|
||||
if (current != null && (!streamedContent || done || hasContent)) {
|
||||
state = AsyncData(current.copyWith(messages: fresh));
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
} catch (_) {
|
||||
// Clear the generating placeholder so UI doesn't spin forever.
|
||||
final current = state.valueOrNull;
|
||||
if (current != null) {
|
||||
final msgs = current.messages;
|
||||
if (msgs.isNotEmpty && msgs.last.status == 'generating') {
|
||||
state = AsyncData(current.copyWith(messages: [
|
||||
...msgs.sublist(0, msgs.length - 1),
|
||||
msgs.last.copyWith(status: 'complete'),
|
||||
]));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ref.read(isBriefingStreamingProvider.notifier).state = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/exceptions.dart';
|
||||
import 'api_client_provider.dart';
|
||||
import 'capture_queue_provider.dart';
|
||||
import 'notes_provider.dart';
|
||||
import 'tasks_provider.dart';
|
||||
|
||||
/// Outcome of a single capture attempt — consumed by the UI for snackbars.
|
||||
class CaptureResult {
|
||||
final String message;
|
||||
final bool isError;
|
||||
const CaptureResult(this.message, {this.isError = false});
|
||||
}
|
||||
|
||||
/// The most recent capture result. UI watches this to show snackbars.
|
||||
/// Reset to null by the notifier before each new item so listeners always fire.
|
||||
final captureResultProvider = StateProvider<CaptureResult?>((_) => null);
|
||||
|
||||
/// In-memory sequential work queue for quick captures.
|
||||
/// Separate from [captureQueueProvider] (which is the offline persistence queue).
|
||||
final captureWorkQueueProvider =
|
||||
StateNotifierProvider<CaptureWorkQueueNotifier, List<String>>(
|
||||
(ref) => CaptureWorkQueueNotifier(ref),
|
||||
);
|
||||
|
||||
class CaptureWorkQueueNotifier extends StateNotifier<List<String>> {
|
||||
final Ref _ref;
|
||||
bool _running = false;
|
||||
|
||||
CaptureWorkQueueNotifier(this._ref) : super([]);
|
||||
|
||||
/// Add text to the queue and start the drain loop if not already running.
|
||||
void enqueue(String text) {
|
||||
state = [...state, text];
|
||||
_drain();
|
||||
}
|
||||
|
||||
Future<void> _drain() async {
|
||||
if (_running) return;
|
||||
_running = true;
|
||||
try {
|
||||
while (state.isNotEmpty) {
|
||||
final text = state.first;
|
||||
// Signal "no result yet" so the same result value can re-trigger watch.
|
||||
_ref.read(captureResultProvider.notifier).state = null;
|
||||
try {
|
||||
final api = _ref.read(quickCaptureApiProvider);
|
||||
final result = await api.capture(text);
|
||||
|
||||
// Dequeue on success.
|
||||
state = state.length > 1 ? state.sublist(1) : [];
|
||||
|
||||
// Invalidate content providers so lists refresh.
|
||||
switch (result.type) {
|
||||
case 'note':
|
||||
_ref.invalidate(notesProvider);
|
||||
case 'task':
|
||||
case 'todo':
|
||||
_ref.invalidate(tasksProvider);
|
||||
}
|
||||
|
||||
// Publish result for snackbar.
|
||||
final msg = result.message.isNotEmpty
|
||||
? result.message
|
||||
: '${_typeLabel(result.type)} created: ${result.title}';
|
||||
_ref.read(captureResultProvider.notifier).state = CaptureResult(msg);
|
||||
} on NetworkException catch (_) {
|
||||
// Persist to offline queue and stop draining — still offline.
|
||||
await _ref.read(captureQueueProvider.notifier).enqueue(text);
|
||||
state = state.length > 1 ? state.sublist(1) : [];
|
||||
_ref.read(captureResultProvider.notifier).state = CaptureResult(
|
||||
"You're offline — capture saved and will retry automatically.",
|
||||
);
|
||||
break;
|
||||
} on AppException catch (e) {
|
||||
state = state.length > 1 ? state.sublist(1) : [];
|
||||
_ref.read(captureResultProvider.notifier).state =
|
||||
CaptureResult(e.message, isError: true);
|
||||
} catch (_) {
|
||||
state = state.length > 1 ? state.sublist(1) : [];
|
||||
_ref.read(captureResultProvider.notifier).state =
|
||||
CaptureResult('Capture failed. Please try again.', isError: true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_running = false;
|
||||
}
|
||||
}
|
||||
|
||||
String _typeLabel(String type) => switch (type) {
|
||||
'note' => 'Note',
|
||||
'task' => 'Task',
|
||||
'event' => 'Event',
|
||||
'todo' => 'To-do',
|
||||
_ => type,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../data/models/briefing_conversation.dart';
|
||||
import '../../data/models/message.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../widgets/chat_message_bubble.dart';
|
||||
|
||||
class BriefingHistoryScreen extends ConsumerWidget {
|
||||
const BriefingHistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final historyAsync = ref.watch(_briefingHistoryProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Past Briefings')),
|
||||
body: historyAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) =>
|
||||
const Center(child: Text('Could not load briefing history.')),
|
||||
data: (convs) {
|
||||
if (convs.isEmpty) {
|
||||
return const Center(child: Text('No past briefings.'));
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: convs.length,
|
||||
itemBuilder: (context, i) {
|
||||
final conv = convs[i];
|
||||
final label = conv.briefingDate ?? conv.title;
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.wb_sunny_outlined),
|
||||
title: Text(label),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => _BriefingDetailScreen(conv: conv),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lazily loads and displays all messages for a past briefing.
|
||||
class _BriefingDetailScreen extends ConsumerWidget {
|
||||
final BriefingConversation conv;
|
||||
const _BriefingDetailScreen({required this.conv});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final messagesAsync = ref.watch(_briefingMessagesProvider(conv.id));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(conv.briefingDate ?? conv.title)),
|
||||
body: messagesAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => const Center(child: Text('Could not load messages.')),
|
||||
data: (messages) {
|
||||
if (messages.isEmpty) {
|
||||
return const Center(child: Text('No messages.'));
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (_, i) => ChatMessageBubble(message: messages[i]),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private providers (scoped to this file) ──────────────────────────────────
|
||||
|
||||
final _briefingHistoryProvider =
|
||||
FutureProvider<List<BriefingConversation>>((ref) async {
|
||||
return ref.watch(briefingApiProvider).getHistory();
|
||||
});
|
||||
|
||||
final _briefingMessagesProvider =
|
||||
FutureProvider.family<List<Message>, int>((ref, convId) async {
|
||||
return ref.watch(briefingApiProvider).getMessages(convId);
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/exceptions.dart';
|
||||
import '../../data/models/message.dart';
|
||||
import '../../providers/briefing_provider.dart';
|
||||
import '../../widgets/briefing_digest_card.dart';
|
||||
import '../../widgets/chat_message_bubble.dart';
|
||||
import 'briefing_history_screen.dart';
|
||||
|
||||
class BriefingScreen extends ConsumerStatefulWidget {
|
||||
const BriefingScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<BriefingScreen> createState() => _BriefingScreenState();
|
||||
}
|
||||
|
||||
class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
||||
final _controller = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
bool _refreshing = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _sendReply() async {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
_controller.clear();
|
||||
try {
|
||||
await ref.read(briefingProvider.notifier).sendReply(text);
|
||||
} on AppException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(e.message)));
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to send reply.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
setState(() => _refreshing = true);
|
||||
try {
|
||||
await ref.read(briefingProvider.notifier).refresh('compilation');
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not generate briefing.')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _refreshing = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final briefingAsync = ref.watch(briefingProvider);
|
||||
final isStreaming = ref.watch(isBriefingStreamingProvider);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
ref.listen(briefingProvider, (_, _) => _scrollToBottom());
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Briefing', style: Theme.of(context).textTheme.titleLarge),
|
||||
Text(
|
||||
_todayLabel(),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (_refreshing)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh_outlined),
|
||||
tooltip: 'Generate briefing',
|
||||
onPressed: _refresh,
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
if (value == 'history') {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => const BriefingHistoryScreen(),
|
||||
));
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(
|
||||
value: 'history',
|
||||
child: Text('View past briefings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: briefingAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text("Could not load today's briefing."),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.invalidate(briefingProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (conv) {
|
||||
// First assistant message for the digest card (null if none yet)
|
||||
final Message? firstAssistant = conv.messages
|
||||
.where((m) => m.role == MessageRole.assistant)
|
||||
.toList()
|
||||
.firstOrNull;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Digest card header
|
||||
BriefingDigestCard(
|
||||
message: firstAssistant,
|
||||
onGenerateNow: _refresh,
|
||||
),
|
||||
|
||||
// Divider + label
|
||||
if (conv.messages.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(children: [
|
||||
const Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
'Conversation',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(child: Divider()),
|
||||
]),
|
||||
],
|
||||
|
||||
// Message list
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 8),
|
||||
itemCount: conv.messages.length,
|
||||
itemBuilder: (_, i) =>
|
||||
ChatMessageBubble(message: conv.messages[i]),
|
||||
),
|
||||
),
|
||||
|
||||
// Progress bar while streaming
|
||||
if (isStreaming)
|
||||
LinearProgressIndicator(
|
||||
minHeight: 2,
|
||||
color: scheme.primary,
|
||||
),
|
||||
|
||||
// Reply bar
|
||||
const Divider(height: 1),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Reply to your briefing…',
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 10),
|
||||
),
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
textInputAction: TextInputAction.newline,
|
||||
enabled: !isStreaming,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_GradientSendButton(
|
||||
onPressed: isStreaming ? null : _sendReply,
|
||||
isStreaming: isStreaming,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _todayLabel() {
|
||||
final now = DateTime.now();
|
||||
const days = [
|
||||
'Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
||||
'Friday', 'Saturday', 'Sunday'
|
||||
];
|
||||
const months = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
return '${days[now.weekday - 1]}, ${months[now.month - 1]} ${now.day}';
|
||||
}
|
||||
}
|
||||
|
||||
class _GradientSendButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
final bool isStreaming;
|
||||
|
||||
const _GradientSendButton({
|
||||
required this.onPressed,
|
||||
required this.isStreaming,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final disabled = onPressed == null;
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: disabled
|
||||
? null
|
||||
: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
|
||||
),
|
||||
color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: isStreaming
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.send,
|
||||
color: disabled
|
||||
? scheme.onSurface.withValues(alpha: 0.38)
|
||||
: Colors.white,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import 'dart:math' show min;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/exceptions.dart';
|
||||
import '../../data/models/message.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import '../../widgets/chat_message_bubble.dart';
|
||||
|
||||
class ChatScreen extends ConsumerStatefulWidget {
|
||||
final int conversationId;
|
||||
@@ -102,7 +99,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
horizontal: 8, vertical: 12),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, i) =>
|
||||
_MessageBubble(message: messages[i]),
|
||||
ChatMessageBubble(message: messages[i]),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -153,42 +150,3 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageBubble extends StatelessWidget {
|
||||
final Message message;
|
||||
const _MessageBubble({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isUser = message.role == MessageRole.user;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Align(
|
||||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: min(MediaQuery.of(context).size.width * 0.8, 480),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? scheme.primary : scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(isUser ? 16 : 4),
|
||||
bottomRight: Radius.circular(isUser ? 4 : 16),
|
||||
),
|
||||
),
|
||||
child: isUser
|
||||
? Text(
|
||||
message.content,
|
||||
style: TextStyle(color: scheme.onPrimary),
|
||||
)
|
||||
: MarkdownBody(
|
||||
data: message.content.isEmpty ? '...' : message.content,
|
||||
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,185 +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 '../../core/exceptions.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import 'chat_screen.dart';
|
||||
|
||||
class ConversationsListScreen extends ConsumerStatefulWidget {
|
||||
const ConversationsListScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConversationsListScreen> createState() =>
|
||||
_ConversationsListScreenState();
|
||||
}
|
||||
|
||||
class _ConversationsListScreenState
|
||||
extends ConsumerState<ConversationsListScreen> {
|
||||
int? _selectedConvId;
|
||||
|
||||
Future<void> _newConversation(bool isWide) async {
|
||||
try {
|
||||
final conv = await ref.read(conversationsProvider.notifier).create('');
|
||||
if (!mounted) return;
|
||||
if (isWide) {
|
||||
setState(() => _selectedConvId = conv.id);
|
||||
} else {
|
||||
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
|
||||
}
|
||||
} on AppException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(e.message)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final convsAsync = ref.watch(conversationsProvider);
|
||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||
|
||||
// Clear stale selection when switching to narrow mode.
|
||||
if (!isWide && _selectedConvId != null) {
|
||||
_selectedConvId = null;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: isWide
|
||||
? Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(child: _buildListPane(convsAsync, isWide)),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add),
|
||||
title: const Text('New conversation'),
|
||||
onTap: () => _newConversation(isWide),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: _buildDetailPane()),
|
||||
],
|
||||
)
|
||||
: _buildListPane(convsAsync, isWide),
|
||||
floatingActionButton: isWide
|
||||
? null
|
||||
: FloatingActionButton(
|
||||
heroTag: 'chat_fab',
|
||||
onPressed: () => _newConversation(isWide),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailPane() {
|
||||
if (_selectedConvId == null) {
|
||||
return const Center(child: Text('Select a conversation to open it.'));
|
||||
}
|
||||
return ChatScreen(
|
||||
key: ValueKey(_selectedConvId),
|
||||
conversationId: _selectedConvId!,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListPane(AsyncValue convsAsync, bool isWide) {
|
||||
return convsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Could not load conversations.'),
|
||||
const SizedBox(height: 4),
|
||||
TextButton(
|
||||
onPressed: () => ref.invalidate(conversationsProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (convs) {
|
||||
if (convs.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('No conversations yet. Tap + to start one.'));
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.refresh(conversationsProvider.future),
|
||||
child: ListView.separated(
|
||||
itemCount: convs.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final conv = convs[i];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.chat_bubble_outline),
|
||||
title: Text(
|
||||
conv.title.isNotEmpty ? conv.title : 'New conversation',
|
||||
),
|
||||
subtitle: Text(
|
||||
conv.updatedAt.toLocal().toString().substring(0, 16),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
selected: isWide && _selectedConvId == conv.id,
|
||||
selectedTileColor:
|
||||
Theme.of(context).colorScheme.secondaryContainer,
|
||||
onTap: () {
|
||||
if (isWide) {
|
||||
setState(() => _selectedConvId = conv.id);
|
||||
} else {
|
||||
context.push(
|
||||
Routes.chat.replaceFirst(':id', '${conv.id}'),
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPress: () async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete conversation?'),
|
||||
content: Text(
|
||||
conv.title.isNotEmpty
|
||||
? conv.title
|
||||
: 'New conversation',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm == true) {
|
||||
await ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.delete(conv.id);
|
||||
if (mounted && _selectedConvId == conv.id) {
|
||||
setState(() => _selectedConvId = null);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
|
||||
class ConversationsTabScreen extends ConsumerWidget {
|
||||
const ConversationsTabScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final convsAsync = ref.watch(conversationsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Chat', style: theme.textTheme.titleLarge),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
tooltip: 'New conversation',
|
||||
onPressed: () async {
|
||||
final conv = await ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.create('New conversation');
|
||||
if (context.mounted) {
|
||||
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: convsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Error: $e')),
|
||||
data: (convs) {
|
||||
if (convs.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline,
|
||||
size: 48, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 16),
|
||||
Text('No conversations yet',
|
||||
style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Start a conversation'),
|
||||
onPressed: () async {
|
||||
final conv = await ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.create('New conversation');
|
||||
if (context.mounted) {
|
||||
context.push(
|
||||
Routes.chat.replaceFirst(':id', '${conv.id}'));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(conversationsProvider),
|
||||
child: ListView.builder(
|
||||
itemCount: convs.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final c = convs[i];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.chat_bubble_outline),
|
||||
title: Text(c.title,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
_relativeTime(c.updatedAt),
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () =>
|
||||
_confirmDelete(context, ref, c.id, c.title),
|
||||
),
|
||||
onTap: () =>
|
||||
ctx.push(Routes.chat.replaceFirst(':id', '${c.id}')),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(
|
||||
BuildContext context, WidgetRef ref, int id, String title) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Delete conversation?'),
|
||||
content: Text('"$title" will be permanently deleted.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Delete')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
await ref.read(conversationsProvider.notifier).delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 '${dt.day}/${dt.month}/${dt.year}';
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
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.valueOrNull ?? [];
|
||||
final tasks = tasksAsync.valueOrNull ?? [];
|
||||
|
||||
// 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,216 +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 '../../providers/notes_provider.dart';
|
||||
import 'note_detail_screen.dart';
|
||||
|
||||
class NotesListScreen extends ConsumerStatefulWidget {
|
||||
const NotesListScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<NotesListScreen> createState() => _NotesListScreenState();
|
||||
}
|
||||
|
||||
class _NotesListScreenState extends ConsumerState<NotesListScreen> {
|
||||
bool _showSearch = false;
|
||||
String _search = '';
|
||||
int? _selectedNoteId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final notesAsync = ref.watch(notesProvider);
|
||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||
|
||||
// Clear stale selection when switching to narrow mode.
|
||||
if (!isWide && _selectedNoteId != null) {
|
||||
_selectedNoteId = null;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: isWide
|
||||
? Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(child: _buildListPane(notesAsync, isWide)),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add),
|
||||
title: const Text('New note'),
|
||||
onTap: () => context.push(Routes.noteNew),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: _buildDetailPane()),
|
||||
],
|
||||
)
|
||||
: _buildListPane(notesAsync, isWide),
|
||||
floatingActionButton: isWide
|
||||
? null
|
||||
: FloatingActionButton(
|
||||
heroTag: 'notes_fab',
|
||||
onPressed: () => context.push(Routes.noteNew),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailPane() {
|
||||
if (_selectedNoteId == null) {
|
||||
return const Center(child: Text('Select a note to read it.'));
|
||||
}
|
||||
return NoteDetailScreen(
|
||||
key: ValueKey(_selectedNoteId),
|
||||
noteId: _selectedNoteId!,
|
||||
onDeleted: () => setState(() => _selectedNoteId = null),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListPane(AsyncValue<List<Note>> notesAsync, bool isWide) {
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
if (_showSearch)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search notes…',
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
onChanged: (v) =>
|
||||
setState(() => _search = v.trim().toLowerCase()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: 'Close search',
|
||||
onPressed: () => setState(() {
|
||||
_showSearch = false;
|
||||
_search = '';
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: notesAsync.when(
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Could not load notes.'),
|
||||
const SizedBox(height: 4),
|
||||
TextButton(
|
||||
onPressed: () => ref.invalidate(notesProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (notes) {
|
||||
final filtered = _search.isEmpty
|
||||
? notes
|
||||
: notes
|
||||
.where((n) =>
|
||||
n.title.toLowerCase().contains(_search) ||
|
||||
n.body.toLowerCase().contains(_search))
|
||||
.toList();
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
_search.isEmpty
|
||||
? 'No notes yet. Tap + to create one.'
|
||||
: 'No notes match "$_search".',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.refresh(notesProvider.future),
|
||||
child: ListView.separated(
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final note = filtered[i];
|
||||
final preview = note.body
|
||||
.split('\n')
|
||||
.firstWhere((l) => l.trim().isNotEmpty,
|
||||
orElse: () => '')
|
||||
.trim();
|
||||
return ListTile(
|
||||
title: Text(note.title),
|
||||
subtitle: Text(
|
||||
preview.isNotEmpty
|
||||
? preview
|
||||
: note.updatedAt
|
||||
.toLocal()
|
||||
.toString()
|
||||
.substring(0, 16),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
selected: isWide && _selectedNoteId == note.id,
|
||||
selectedTileColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.secondaryContainer,
|
||||
onTap: () {
|
||||
if (isWide) {
|
||||
setState(() => _selectedNoteId = note.id);
|
||||
} else {
|
||||
context.push(
|
||||
Routes.noteDetail
|
||||
.replaceFirst(':id', '${note.id}'),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Floating search button — only visible when search is closed.
|
||||
if (!_showSearch)
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
tooltip: 'Search',
|
||||
onPressed: () => setState(() => _showSearch = true),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,631 +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 '../../core/exceptions.dart';
|
||||
import '../../data/models/milestone.dart';
|
||||
import '../../data/models/project.dart';
|
||||
import '../../data/models/task.dart';
|
||||
import '../../providers/milestones_provider.dart';
|
||||
import '../../providers/projects_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
|
||||
class ProjectListScreen extends ConsumerWidget {
|
||||
const ProjectListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final projectsAsync = ref.watch(projectsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Projects')),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showCreateDialog(context, ref),
|
||||
tooltip: 'New project',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: 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 yet.\nTap + to create one.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
final active =
|
||||
projects.where((p) => p.status == 'active').toList();
|
||||
final other =
|
||||
projects.where((p) => p.status != 'active').toList();
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(bottom: 88),
|
||||
children: [
|
||||
if (active.isNotEmpty) ...[
|
||||
_SectionHeader(title: 'Active (${active.length})'),
|
||||
...active.map((p) => _ProjectExpansionTile(project: p)),
|
||||
],
|
||||
if (other.isNotEmpty) ...[
|
||||
_SectionHeader(title: 'Other'),
|
||||
...other.map((p) => _ProjectExpansionTile(project: p)),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCreateDialog(BuildContext context, WidgetRef ref) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => _CreateProjectDialog(
|
||||
onCreate: (title, description, goal) async {
|
||||
try {
|
||||
await ref
|
||||
.read(projectsProvider.notifier)
|
||||
.create(title: title, description: description, goal: goal);
|
||||
} on AppException catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.message)),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Section header ────────────────────────────────────────────────────────────
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium
|
||||
?.copyWith(color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Project expansion tile ────────────────────────────────────────────────────
|
||||
|
||||
class _ProjectExpansionTile extends ConsumerStatefulWidget {
|
||||
final Project project;
|
||||
const _ProjectExpansionTile({required this.project});
|
||||
|
||||
@override
|
||||
ConsumerState<_ProjectExpansionTile> createState() =>
|
||||
_ProjectExpansionTileState();
|
||||
}
|
||||
|
||||
class _ProjectExpansionTileState
|
||||
extends ConsumerState<_ProjectExpansionTile> {
|
||||
bool _expanded = false;
|
||||
|
||||
Color _statusColor(BuildContext context) => switch (widget.project.status) {
|
||||
'completed' => Colors.green,
|
||||
'archived' => Colors.grey,
|
||||
_ => Theme.of(context).colorScheme.primary,
|
||||
};
|
||||
|
||||
void _showOptions(BuildContext context) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (_) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.check_circle_outline),
|
||||
title: const Text('Mark completed'),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
await ref.read(projectsProvider.notifier).updateProject(
|
||||
widget.project.id,
|
||||
{'status': 'completed'},
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.archive_outlined),
|
||||
title: const Text('Archive'),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
await ref.read(projectsProvider.notifier).updateProject(
|
||||
widget.project.id,
|
||||
{'status': 'archived'},
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.delete_outline,
|
||||
color: Theme.of(context).colorScheme.error),
|
||||
title: Text('Delete',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error)),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete project?'),
|
||||
content: const Text(
|
||||
'Notes and tasks will be unlinked, not deleted.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm == true && context.mounted) {
|
||||
await ref
|
||||
.read(projectsProvider.notifier)
|
||||
.delete(widget.project.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final statusColor = _statusColor(context);
|
||||
final tasksAsync = ref.watch(tasksProvider);
|
||||
final unfinished = tasksAsync.valueOrNull
|
||||
?.where((t) =>
|
||||
t.projectId == widget.project.id &&
|
||||
t.status != TaskStatus.done)
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
final subtitle = unfinished.isEmpty
|
||||
? (widget.project.description != null &&
|
||||
widget.project.description!.isNotEmpty
|
||||
? widget.project.description!
|
||||
: null)
|
||||
: '${unfinished.length} task${unfinished.length == 1 ? '' : 's'} in progress';
|
||||
|
||||
return ExpansionTile(
|
||||
key: PageStorageKey('project-${widget.project.id}'),
|
||||
initiallyExpanded: false,
|
||||
onExpansionChanged: (v) => setState(() => _expanded = v),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: statusColor.withValues(alpha: 0.15),
|
||||
child: Icon(Icons.folder_outlined, color: statusColor, size: 20),
|
||||
),
|
||||
title: GestureDetector(
|
||||
onLongPress: () => _showOptions(context),
|
||||
child: Text(widget.project.title),
|
||||
),
|
||||
subtitle: subtitle != null
|
||||
? Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
)
|
||||
: null,
|
||||
trailing: _StatusChip(status: widget.project.status),
|
||||
children: [
|
||||
if (_expanded)
|
||||
_ProjectTaskList(
|
||||
project: widget.project,
|
||||
unfinishedTasks: unfinished,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Expanded task list grouped by milestone ───────────────────────────────────
|
||||
|
||||
class _ProjectTaskList extends ConsumerWidget {
|
||||
final Project project;
|
||||
final List<Task> unfinishedTasks;
|
||||
const _ProjectTaskList({
|
||||
required this.project,
|
||||
required this.unfinishedTasks,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final milestonesAsync =
|
||||
ref.watch(projectMilestonesProvider(project.id));
|
||||
|
||||
return milestonesAsync.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
),
|
||||
error: (e, _) => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('Error loading milestones: $e',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error)),
|
||||
),
|
||||
data: (milestones) {
|
||||
// Only active milestones in order
|
||||
final activeMilestones = milestones
|
||||
.where((m) => m.status == 'active')
|
||||
.toList()
|
||||
..sort((a, b) => a.orderIndex.compareTo(b.orderIndex));
|
||||
|
||||
if (unfinishedTasks.isEmpty) {
|
||||
return _EmptyProjectContent(project: project);
|
||||
}
|
||||
|
||||
// Build milestone → tasks map
|
||||
final Map<int?, List<Task>> grouped = {};
|
||||
for (final task in unfinishedTasks) {
|
||||
grouped.putIfAbsent(task.milestoneId, () => []).add(task);
|
||||
}
|
||||
|
||||
final widgets = <Widget>[];
|
||||
|
||||
// Milestone groups (in order)
|
||||
for (final ms in activeMilestones) {
|
||||
final tasks = grouped[ms.id];
|
||||
if (tasks == null || tasks.isEmpty) continue;
|
||||
widgets.add(_MilestoneHeader(milestone: ms));
|
||||
for (final task in tasks) {
|
||||
widgets.add(_TaskRow(task: task));
|
||||
}
|
||||
}
|
||||
|
||||
// No-milestone group
|
||||
final noMilestoneTasks = grouped[null] ?? [];
|
||||
if (noMilestoneTasks.isNotEmpty) {
|
||||
if (activeMilestones.isNotEmpty) {
|
||||
widgets.add(const _NoMilestoneHeader());
|
||||
}
|
||||
for (final task in noMilestoneTasks) {
|
||||
widgets.add(_TaskRow(task: task));
|
||||
}
|
||||
}
|
||||
|
||||
widgets.add(_AddTaskRow(project: project));
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: widgets,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyProjectContent extends StatelessWidget {
|
||||
final Project project;
|
||||
const _EmptyProjectContent({required this.project});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Text(
|
||||
'No open tasks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
_AddTaskRow(project: project),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Milestone header ──────────────────────────────────────────────────────────
|
||||
|
||||
class _MilestoneHeader extends StatelessWidget {
|
||||
final Milestone milestone;
|
||||
const _MilestoneHeader({required this.milestone});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pct = milestone.total == 0 ? 0.0 : milestone.pct / 100.0;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(56, 12, 16, 2),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.flag_outlined, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
milestone.title,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.secondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${milestone.completed}/${milestone.total}',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: pct,
|
||||
minHeight: 3,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
backgroundColor:
|
||||
colorScheme.secondaryContainer.withValues(alpha: 0.4),
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(colorScheme.secondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoMilestoneHeader extends StatelessWidget {
|
||||
const _NoMilestoneHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(56, 12, 16, 2),
|
||||
child: Text(
|
||||
'No milestone',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Task row ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class _TaskRow extends StatelessWidget {
|
||||
final Task task;
|
||||
const _TaskRow({required this.task});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isInProgress = task.status == TaskStatus.inProgress;
|
||||
final dotColor = isInProgress ? colorScheme.primary : colorScheme.outline;
|
||||
final priorityColor = switch (task.priority) {
|
||||
TaskPriority.high => Colors.red,
|
||||
TaskPriority.medium => Colors.orange,
|
||||
TaskPriority.low => Colors.blue,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.fromLTRB(56, 0, 16, 0),
|
||||
leading: Icon(
|
||||
isInProgress ? Icons.radio_button_checked : Icons.radio_button_unchecked,
|
||||
size: 18,
|
||||
color: dotColor,
|
||||
),
|
||||
title: Text(task.title, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
subtitle: task.dueDate != null
|
||||
? Text(
|
||||
_formatDue(task.dueDate!),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _isDueOverdue(task.dueDate!)
|
||||
? colorScheme.error
|
||||
: colorScheme.outline,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
trailing: priorityColor != null
|
||||
? Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: priorityColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: () => context.push(
|
||||
Routes.taskEdit.replaceFirst(':id', '${task.id}'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDue(DateTime due) {
|
||||
final now = DateTime.now();
|
||||
final diff = due.difference(DateTime(now.year, now.month, now.day)).inDays;
|
||||
if (diff == 0) return 'Due today';
|
||||
if (diff == 1) return 'Due tomorrow';
|
||||
if (diff < 0) return 'Overdue ${(-diff)} day${(-diff) == 1 ? '' : 's'}';
|
||||
if (diff < 7) return 'Due in $diff days';
|
||||
return 'Due ${due.month}/${due.day}';
|
||||
}
|
||||
|
||||
bool _isDueOverdue(DateTime due) {
|
||||
final now = DateTime.now();
|
||||
return due.isBefore(DateTime(now.year, now.month, now.day));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Add task row ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _AddTaskRow extends StatelessWidget {
|
||||
final Project project;
|
||||
const _AddTaskRow({required this.project});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(48, 4, 16, 8),
|
||||
child: TextButton.icon(
|
||||
onPressed: () => context.push('${Routes.taskNew}?projectId=${project.id}'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('New task'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.outline,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
textStyle: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Status chip ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
final String status;
|
||||
const _StatusChip({required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, color) = switch (status) {
|
||||
'completed' => ('Done', Colors.green),
|
||||
'archived' => ('Archived', Colors.grey),
|
||||
_ => ('Active', Theme.of(context).colorScheme.primary),
|
||||
};
|
||||
return Chip(
|
||||
label: Text(label, style: const TextStyle(fontSize: 11)),
|
||||
padding: EdgeInsets.zero,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
side: BorderSide(color: color.withValues(alpha: 0.4)),
|
||||
backgroundColor: color.withValues(alpha: 0.1),
|
||||
labelStyle: TextStyle(color: color),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Create project dialog ─────────────────────────────────────────────────────
|
||||
|
||||
class _CreateProjectDialog extends StatefulWidget {
|
||||
final Future<void> Function(String title, String? description, String? goal)
|
||||
onCreate;
|
||||
|
||||
const _CreateProjectDialog({required this.onCreate});
|
||||
|
||||
@override
|
||||
State<_CreateProjectDialog> createState() => _CreateProjectDialogState();
|
||||
}
|
||||
|
||||
class _CreateProjectDialogState extends State<_CreateProjectDialog> {
|
||||
final _titleController = TextEditingController();
|
||||
final _descController = TextEditingController();
|
||||
final _goalController = TextEditingController();
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_descController.dispose();
|
||||
_goalController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final title = _titleController.text.trim();
|
||||
if (title.isEmpty) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await widget.onCreate(
|
||||
title,
|
||||
_descController.text.trim().isEmpty ? null : _descController.text.trim(),
|
||||
_goalController.text.trim().isEmpty ? null : _goalController.text.trim(),
|
||||
);
|
||||
if (mounted) Navigator.pop(context);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('New Project'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Title',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _descController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description (optional)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 2,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _goalController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Goal (optional)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _saving ? null : _submit,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Create'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/exceptions.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/notes_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
|
||||
class QuickCaptureScreen extends ConsumerStatefulWidget {
|
||||
const QuickCaptureScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<QuickCaptureScreen> createState() =>
|
||||
_QuickCaptureScreenState();
|
||||
}
|
||||
|
||||
class _QuickCaptureScreenState extends ConsumerState<QuickCaptureScreen> {
|
||||
final _controller = TextEditingController();
|
||||
final _focusNode = FocusNode();
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final result =
|
||||
await ref.read(quickCaptureApiProvider).capture(text);
|
||||
// Invalidate the relevant provider so the list screen re-fetches.
|
||||
switch (result.type) {
|
||||
case 'note':
|
||||
ref.invalidate(notesProvider);
|
||||
case 'task':
|
||||
case 'todo':
|
||||
ref.invalidate(tasksProvider);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
// Use the server's human-readable message if available, else compose one.
|
||||
final msg = result.message.isNotEmpty
|
||||
? result.message
|
||||
: '${_typeLabel(result.type)} created: ${result.title}';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(msg),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
context.pop();
|
||||
}
|
||||
} on AppException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _typeLabel(String type) => switch (type) {
|
||||
'note' => 'Note',
|
||||
'task' => 'Task',
|
||||
'event' => 'Event',
|
||||
'todo' => 'To-do',
|
||||
_ => type,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Quick Capture')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'What\'s on your mind?',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Describe a note, task, event, or research item — Fabled will figure out the rest.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
enabled: !_loading,
|
||||
maxLines: 6,
|
||||
minLines: 3,
|
||||
keyboardType: TextInputType.multiline,
|
||||
decoration: InputDecoration(
|
||||
hintText:
|
||||
'e.g. "Remind me to call the dentist next Monday" or "Note: project meeting went well, key points were..."',
|
||||
border: const OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
suffixIcon: _controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: (_loading || _controller.text.trim().isEmpty)
|
||||
? null
|
||||
: _send,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome),
|
||||
label: Text(_loading ? 'Processing...' : 'Capture'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,199 +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/task.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
|
||||
class TasksListScreen extends ConsumerStatefulWidget {
|
||||
const TasksListScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<TasksListScreen> createState() => _TasksListScreenState();
|
||||
}
|
||||
|
||||
class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabs;
|
||||
bool _showSearch = false;
|
||||
String _search = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabs = TabController(length: 3, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabs.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tasksAsync = ref.watch(tasksProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
// Search field — appears above the tabs when active
|
||||
if (_showSearch)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search tasks…',
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
onChanged: (v) =>
|
||||
setState(() => _search = v.trim().toLowerCase()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: 'Close search',
|
||||
onPressed: () => setState(() {
|
||||
_showSearch = false;
|
||||
_search = '';
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Tab bar row — search icon sits to the right of the tabs
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TabBar(
|
||||
controller: _tabs,
|
||||
tabs: const [
|
||||
Tab(text: 'To Do'),
|
||||
Tab(text: 'In Progress'),
|
||||
Tab(text: 'Done'),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!_showSearch)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
tooltip: 'Search',
|
||||
onPressed: () => setState(() => _showSearch = true),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Content
|
||||
Expanded(
|
||||
child: tasksAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Could not load tasks.'),
|
||||
const SizedBox(height: 4),
|
||||
TextButton(
|
||||
onPressed: () => ref.invalidate(tasksProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (tasks) {
|
||||
final filtered = _search.isEmpty
|
||||
? tasks
|
||||
: tasks
|
||||
.where((t) =>
|
||||
t.title.toLowerCase().contains(_search) ||
|
||||
(t.description ?? '')
|
||||
.toLowerCase()
|
||||
.contains(_search))
|
||||
.toList();
|
||||
|
||||
final todo = filtered
|
||||
.where((t) => t.status == TaskStatus.todo)
|
||||
.toList();
|
||||
final inProgress = filtered
|
||||
.where((t) => t.status == TaskStatus.inProgress)
|
||||
.toList();
|
||||
final done = filtered
|
||||
.where((t) => t.status == TaskStatus.done)
|
||||
.toList();
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.refresh(tasksProvider.future),
|
||||
child: TabBarView(
|
||||
controller: _tabs,
|
||||
children: [
|
||||
_TaskList(tasks: todo, search: _search),
|
||||
_TaskList(tasks: inProgress, search: _search),
|
||||
_TaskList(tasks: done, search: _search),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
heroTag: 'tasks_fab',
|
||||
onPressed: () => context.push(Routes.taskNew),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TaskList extends ConsumerWidget {
|
||||
final List<Task> tasks;
|
||||
final String search;
|
||||
const _TaskList({required this.tasks, this.search = ''});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (tasks.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
search.isEmpty ? 'No tasks here.' : 'No tasks match "$search".',
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: tasks.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final task = tasks[i];
|
||||
return ListTile(
|
||||
leading: _priorityIcon(task.priority),
|
||||
title: Text(task.title),
|
||||
subtitle: task.dueDate != null
|
||||
? Text(
|
||||
'Due: ${task.dueDate!.toLocal().toString().substring(0, 10)}')
|
||||
: null,
|
||||
onTap: () => context.push(
|
||||
Routes.taskEdit.replaceFirst(':id', '${task.id}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _priorityIcon(TaskPriority p) {
|
||||
final color = switch (p) {
|
||||
TaskPriority.high => Colors.red,
|
||||
TaskPriority.medium => Colors.orange,
|
||||
TaskPriority.low => Colors.green,
|
||||
TaskPriority.none => Colors.grey,
|
||||
};
|
||||
return Icon(Icons.flag, color: color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
|
||||
import '../data/models/message.dart';
|
||||
|
||||
class BriefingDigestCard extends StatefulWidget {
|
||||
/// The first assistant message from today's briefing, or null if none yet.
|
||||
final Message? message;
|
||||
|
||||
/// Called when the user taps "Generate now".
|
||||
final VoidCallback? onGenerateNow;
|
||||
|
||||
const BriefingDigestCard({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.onGenerateNow,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BriefingDigestCard> createState() => _BriefingDigestCardState();
|
||||
}
|
||||
|
||||
class _BriefingDigestCardState extends State<BriefingDigestCard> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: BorderSide(
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.5),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header row
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.wb_sunny_outlined, size: 18, color: scheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_todayLabel(),
|
||||
style: textTheme.labelMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Body
|
||||
if (widget.message == null) ...[
|
||||
Text(
|
||||
'No briefing yet today.',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (widget.onGenerateNow != null)
|
||||
FilledButton.tonal(
|
||||
onPressed: widget.onGenerateNow,
|
||||
child: const Text('Generate now'),
|
||||
),
|
||||
] else ...[
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _expanded
|
||||
? MarkdownBody(data: widget.message!.content)
|
||||
: _TruncatedMarkdown(
|
||||
data: widget.message!.content,
|
||||
maxLines: 5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Text(
|
||||
_expanded ? 'Show less ↑' : 'Show more ↓',
|
||||
style: TextStyle(
|
||||
color: scheme.primary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _todayLabel() {
|
||||
final now = DateTime.now();
|
||||
const days = [
|
||||
'Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
||||
'Friday', 'Saturday', 'Sunday'
|
||||
];
|
||||
const months = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
return '${days[now.weekday - 1]}, ${months[now.month - 1]} ${now.day}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders Markdown truncated to [maxLines] visible lines.
|
||||
class _TruncatedMarkdown extends StatelessWidget {
|
||||
final String data;
|
||||
final int maxLines;
|
||||
const _TruncatedMarkdown({required this.data, required this.maxLines});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxLines * 20.0),
|
||||
child: ClipRect(child: MarkdownBody(data: data)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'dart:math' show min;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
|
||||
import '../data/models/message.dart';
|
||||
|
||||
class ChatMessageBubble extends StatelessWidget {
|
||||
final Message message;
|
||||
const ChatMessageBubble({super.key, required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isUser = message.role == MessageRole.user;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final isGenerating = message.status == 'generating';
|
||||
|
||||
return Align(
|
||||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: min(MediaQuery.of(context).size.width * 0.82, 480),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
|
||||
decoration: isUser
|
||||
? BoxDecoration(
|
||||
// Ghost style: transparent bg, thin border
|
||||
color: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: scheme.primary.withValues(alpha: 0.35),
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(16),
|
||||
bottomRight: Radius.circular(4),
|
||||
),
|
||||
)
|
||||
: BoxDecoration(
|
||||
// Assistant: elevated surface + left accent border
|
||||
color: scheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
left: BorderSide(color: scheme.primary, width: 2),
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(4),
|
||||
topRight: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(4),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: isGenerating && message.content.isEmpty
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: MarkdownBody(
|
||||
data: message.content.isEmpty ? '…' : message.content,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: TextStyle(
|
||||
color: isUser
|
||||
? scheme.onSurface.withValues(alpha: 0.75)
|
||||
: scheme.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
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: () {}, // No project detail screen in this app
|
||||
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}';
|
||||
}
|
||||
Reference in New Issue
Block a user