Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d9e4af6f3 | |||
| 0f05f47eef | |||
| 5bdd4f565b | |||
| dd250788f6 |
+28
-55
@@ -11,15 +11,14 @@ import 'providers/auth_provider.dart';
|
|||||||
import 'core/exceptions.dart';
|
import 'core/exceptions.dart';
|
||||||
import 'providers/capture_queue_provider.dart';
|
import 'providers/capture_queue_provider.dart';
|
||||||
import 'providers/capture_work_queue_provider.dart';
|
import 'providers/capture_work_queue_provider.dart';
|
||||||
import 'providers/briefing_provider.dart';
|
|
||||||
import 'providers/calendar_provider.dart';
|
import 'providers/calendar_provider.dart';
|
||||||
import 'providers/chat_provider.dart';
|
import 'providers/chat_provider.dart';
|
||||||
|
import 'providers/journal_provider.dart';
|
||||||
import 'providers/knowledge_provider.dart';
|
import 'providers/knowledge_provider.dart';
|
||||||
import 'providers/news_provider.dart';
|
|
||||||
import 'providers/settings_provider.dart';
|
import 'providers/settings_provider.dart';
|
||||||
import 'providers/update_provider.dart';
|
import 'providers/update_provider.dart';
|
||||||
import 'screens/auth/login_screen.dart';
|
import 'screens/auth/login_screen.dart';
|
||||||
import 'screens/briefing/briefing_screen.dart';
|
import 'screens/journal/journal_screen.dart';
|
||||||
import 'screens/knowledge/knowledge_screen.dart';
|
import 'screens/knowledge/knowledge_screen.dart';
|
||||||
import 'screens/library/project_tasks_screen.dart';
|
import 'screens/library/project_tasks_screen.dart';
|
||||||
import 'screens/chat/chat_screen.dart';
|
import 'screens/chat/chat_screen.dart';
|
||||||
@@ -29,7 +28,6 @@ import 'screens/projects/project_edit_screen.dart';
|
|||||||
import 'screens/projects/projects_screen.dart';
|
import 'screens/projects/projects_screen.dart';
|
||||||
import 'screens/notes/note_edit_screen.dart';
|
import 'screens/notes/note_edit_screen.dart';
|
||||||
import 'screens/settings/settings_screen.dart';
|
import 'screens/settings/settings_screen.dart';
|
||||||
import 'screens/news/news_screen.dart';
|
|
||||||
import 'screens/setup/setup_screen.dart';
|
import 'screens/setup/setup_screen.dart';
|
||||||
import 'screens/splash/splash_screen.dart';
|
import 'screens/splash/splash_screen.dart';
|
||||||
import 'screens/tasks/task_edit_screen.dart';
|
import 'screens/tasks/task_edit_screen.dart';
|
||||||
@@ -161,8 +159,8 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
builder: (context, state, child) => _Shell(child: child),
|
builder: (context, state, child) => _Shell(child: child),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.briefing,
|
path: Routes.journal,
|
||||||
builder: (_, _) => const BriefingScreen(),
|
builder: (_, _) => const JournalScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.knowledge,
|
path: Routes.knowledge,
|
||||||
@@ -176,10 +174,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: Routes.projects,
|
path: Routes.projects,
|
||||||
builder: (_, _) => const ProjectsScreen(),
|
builder: (_, _) => const ProjectsScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
path: Routes.news,
|
|
||||||
builder: (_, _) => const NewsScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.calendar,
|
path: Routes.calendar,
|
||||||
builder: (_, _) => const CalendarScreen(),
|
builder: (_, _) => const CalendarScreen(),
|
||||||
@@ -200,15 +194,14 @@ class _Shell extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||||
static const _baseTabs = [
|
static const _baseTabs = [
|
||||||
Routes.briefing,
|
Routes.journal,
|
||||||
Routes.knowledge,
|
Routes.knowledge,
|
||||||
Routes.conversations,
|
Routes.conversations,
|
||||||
Routes.projects,
|
Routes.projects,
|
||||||
];
|
];
|
||||||
|
|
||||||
List<String> _tabs(bool rssEnabled) => [
|
List<String> _tabs() => [
|
||||||
..._baseTabs,
|
..._baseTabs,
|
||||||
if (rssEnabled) Routes.news,
|
|
||||||
Routes.calendar,
|
Routes.calendar,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -232,7 +225,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
ref.read(updateProvider.notifier).check(repoUrl);
|
ref.read(updateProvider.notifier).check(repoUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Sync device timezone to backend so briefing and chat use local time.
|
// Sync device timezone to backend so journal and chat use local time.
|
||||||
_syncTimezone();
|
_syncTimezone();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -260,24 +253,20 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
void _refreshAll() {
|
void _refreshAll() {
|
||||||
ref.read(conversationsProvider.notifier).refresh();
|
ref.read(conversationsProvider.notifier).refresh();
|
||||||
ref.read(calendarProvider.notifier).refresh();
|
ref.read(calendarProvider.notifier).refresh();
|
||||||
ref.read(newsProvider.notifier).refresh();
|
|
||||||
ref.read(knowledgeProvider.notifier).refresh();
|
ref.read(knowledgeProvider.notifier).refresh();
|
||||||
// briefingProvider is an AsyncNotifier family; invalidating is safe
|
// journalProvider is an AsyncNotifier; invalidating is safe even if
|
||||||
// even if no conversation is open — it doesn't cause flicker since
|
// the journal screen isn't currently mounted.
|
||||||
// the briefing screen isn't a list view.
|
ref.invalidate(journalProvider);
|
||||||
ref.invalidate(briefingProvider);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh only the provider backing the given shell tab route.
|
/// Refresh only the provider backing the given shell tab route.
|
||||||
void _refreshTab(String route) {
|
void _refreshTab(String route) {
|
||||||
if (route == Routes.briefing) {
|
if (route == Routes.journal) {
|
||||||
ref.invalidate(briefingProvider);
|
ref.invalidate(journalProvider);
|
||||||
} else if (route == Routes.knowledge) {
|
} else if (route == Routes.knowledge) {
|
||||||
ref.read(knowledgeProvider.notifier).refresh();
|
ref.read(knowledgeProvider.notifier).refresh();
|
||||||
} else if (route == Routes.conversations) {
|
} else if (route == Routes.conversations) {
|
||||||
ref.read(conversationsProvider.notifier).refresh();
|
ref.read(conversationsProvider.notifier).refresh();
|
||||||
} else if (route == Routes.news) {
|
|
||||||
ref.read(newsProvider.notifier).refresh();
|
|
||||||
} else if (route == Routes.calendar) {
|
} else if (route == Routes.calendar) {
|
||||||
ref.read(calendarProvider.notifier).refresh();
|
ref.read(calendarProvider.notifier).refresh();
|
||||||
}
|
}
|
||||||
@@ -314,15 +303,6 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
context.push(Routes.projects);
|
context.push(Routes.projects);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (ref.read(rssEnabledProvider))
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.newspaper_outlined),
|
|
||||||
title: const Text('News'),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
context.push(Routes.news);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.calendar_month_outlined),
|
leading: const Icon(Icons.calendar_month_outlined),
|
||||||
title: const Text('Calendar'),
|
title: const Text('Calendar'),
|
||||||
@@ -360,8 +340,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
.addPostFrameCallback((_) => _showUpdateSnackbar(next));
|
.addPostFrameCallback((_) => _showUpdateSnackbar(next));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
final rssEnabled = ref.watch(rssEnabledProvider);
|
final tabs = _tabs();
|
||||||
final tabs = _tabs(rssEnabled);
|
|
||||||
final location = GoRouterState.of(context).matchedLocation;
|
final location = GoRouterState.of(context).matchedLocation;
|
||||||
final index = _tabIndex(location, tabs);
|
final index = _tabIndex(location, tabs);
|
||||||
|
|
||||||
@@ -389,34 +368,28 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
selectedIndex: index,
|
selectedIndex: index,
|
||||||
onDestinationSelected: (i) => context.go(tabs[i]),
|
onDestinationSelected: (i) => context.go(tabs[i]),
|
||||||
labelType: NavigationRailLabelType.all,
|
labelType: NavigationRailLabelType.all,
|
||||||
destinations: [
|
destinations: const [
|
||||||
const NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
icon: Icon(Icons.wb_sunny_outlined),
|
|
||||||
selectedIcon: Icon(Icons.wb_sunny),
|
|
||||||
label: Text('Briefing'),
|
|
||||||
),
|
|
||||||
const NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.menu_book_outlined),
|
icon: Icon(Icons.menu_book_outlined),
|
||||||
selectedIcon: Icon(Icons.menu_book),
|
selectedIcon: Icon(Icons.menu_book),
|
||||||
|
label: Text('Journal'),
|
||||||
|
),
|
||||||
|
NavigationRailDestination(
|
||||||
|
icon: Icon(Icons.lightbulb_outline),
|
||||||
|
selectedIcon: Icon(Icons.lightbulb),
|
||||||
label: Text('Knowledge'),
|
label: Text('Knowledge'),
|
||||||
),
|
),
|
||||||
const NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
icon: Icon(Icons.chat_bubble_outline),
|
icon: Icon(Icons.chat_bubble_outline),
|
||||||
selectedIcon: Icon(Icons.chat_bubble),
|
selectedIcon: Icon(Icons.chat_bubble),
|
||||||
label: Text('Chat'),
|
label: Text('Chat'),
|
||||||
),
|
),
|
||||||
const NavigationRailDestination(
|
NavigationRailDestination(
|
||||||
icon: Icon(Icons.folder_outlined),
|
icon: Icon(Icons.folder_outlined),
|
||||||
selectedIcon: Icon(Icons.folder),
|
selectedIcon: Icon(Icons.folder),
|
||||||
label: Text('Projects'),
|
label: Text('Projects'),
|
||||||
),
|
),
|
||||||
if (ref.watch(rssEnabledProvider))
|
NavigationRailDestination(
|
||||||
const NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.newspaper_outlined),
|
|
||||||
selectedIcon: Icon(Icons.newspaper),
|
|
||||||
label: Text('News'),
|
|
||||||
),
|
|
||||||
const NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.calendar_month_outlined),
|
icon: Icon(Icons.calendar_month_outlined),
|
||||||
selectedIcon: Icon(Icons.calendar_month),
|
selectedIcon: Icon(Icons.calendar_month),
|
||||||
label: Text('Calendar'),
|
label: Text('Calendar'),
|
||||||
@@ -458,14 +431,14 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
destinations: const [
|
destinations: const [
|
||||||
NavigationDestination(
|
|
||||||
icon: Icon(Icons.wb_sunny_outlined),
|
|
||||||
selectedIcon: Icon(Icons.wb_sunny),
|
|
||||||
label: 'Briefing',
|
|
||||||
),
|
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.menu_book_outlined),
|
icon: Icon(Icons.menu_book_outlined),
|
||||||
selectedIcon: Icon(Icons.menu_book),
|
selectedIcon: Icon(Icons.menu_book),
|
||||||
|
label: 'Journal',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.lightbulb_outline),
|
||||||
|
selectedIcon: Icon(Icons.lightbulb),
|
||||||
label: 'Knowledge',
|
label: 'Knowledge',
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ abstract class Routes {
|
|||||||
static const chat = '/chat/:id';
|
static const chat = '/chat/:id';
|
||||||
static const quickCapture = '/quick-capture';
|
static const quickCapture = '/quick-capture';
|
||||||
static const settings = '/settings';
|
static const settings = '/settings';
|
||||||
static const briefing = '/briefing';
|
static const journal = '/journal';
|
||||||
static const news = '/news';
|
|
||||||
static const calendar = '/calendar';
|
static const calendar = '/calendar';
|
||||||
static const projectTasks = '/projects/:id/tasks';
|
static const projectTasks = '/projects/:id/tasks';
|
||||||
}
|
}
|
||||||
|
|||||||
+143
-29
@@ -2,35 +2,142 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
// ── Colour constants ──────────────────────────────────────────────────────────
|
// ── Colour constants ──────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Mirrors the web frontend's design system (`docs/design-system.md` in
|
||||||
|
// fabledscribe). Foundation pass shipped on web 2026-04-27 in `7a9a8b7`;
|
||||||
|
// this is the equivalent palette swap for Flutter. Per-screen "surface
|
||||||
|
// phase" reclassification (button Hybrid rule, border audit, etc.) is
|
||||||
|
// deferred — most widgets read `colorScheme.primary` so the palette flip
|
||||||
|
// alone covers a large surface.
|
||||||
|
|
||||||
const _darkBackground = Color(0xFF0F0F14);
|
// Dark mode — Obsidian / Iron / Pewter / Parchment (FabledSword baseline)
|
||||||
const _darkSurface = Color(0xFF16161F);
|
const _darkBackground = Color(0xFF14171A); // Obsidian
|
||||||
const _darkSurfaceVar = Color(0xFF1A1A24);
|
const _darkSurface = Color(0xFF1E2228); // Iron
|
||||||
const _darkPrimary = Color(0xFF7C3AED);
|
const _darkSurfaceVar = Color(0xFF2C313A); // Slate
|
||||||
const _darkOnSurface = Color(0xFFE4E4F0);
|
const _darkPrimary = Color(0xFF5B4A8A); // dusty violet (Scribe accent)
|
||||||
const _darkOnSurfaceVar = Color(0xFF8888A8);
|
const _darkPrimaryDeep = Color(0xFF3F3560); // gradient stop
|
||||||
const _darkOutline = Color(0xFF2A2A3A);
|
const _darkOnSurface = Color(0xFFE8E4D8); // Parchment
|
||||||
|
const _darkOnSurfaceVar = Color(0xFFC2BFB4); // Vellum
|
||||||
|
const _darkOutline = Color(0xFF3F4651); // Pewter
|
||||||
|
|
||||||
const _lightBackground = Color(0xFFF5F5FB);
|
// Light mode — warm parchment (Scribe iteration)
|
||||||
const _lightSurface = Color(0xFFFFFFFF);
|
const _lightBackground = Color(0xFFF5F1E8); // warm cream page
|
||||||
const _lightSurfaceVar = Color(0xFFF0F0F8);
|
const _lightSurface = Color(0xFFFBF8F0); // near-white card
|
||||||
const _lightPrimary = Color(0xFF7C3AED);
|
const _lightSurfaceVar = Color(0xFFEFEAE0); // inset / hover surface
|
||||||
const _lightOnSurface = Color(0xFF1A1A1A);
|
const _lightPrimary = Color(0xFF5B4A8A); // dusty violet (same on both modes)
|
||||||
const _lightOnSurfaceVar = Color(0xFF666666);
|
const _lightOnSurface = Color(0xFF14171A); // deep ink (Obsidian inverted)
|
||||||
const _lightOutline = Color(0xFFDDDDE8);
|
const _lightOnSurfaceVar = Color(0xFF5A5852); // warm mid grey
|
||||||
|
const _lightOutline = Color(0xFFD9D6CE); // warm light pewter
|
||||||
|
|
||||||
|
// Semantic — identical across themes
|
||||||
|
const _semanticError = Color(0xFFC04A1F); // terracotta — validation/error
|
||||||
|
const _semanticErrorBg = Color(0xFF7E2A1F); // Oxblood-hover for dark error container
|
||||||
|
const _semanticErrorFg = Color(0xFFFEE2E2); // light text on dark error container
|
||||||
|
|
||||||
|
// Action tokens — Hybrid rule per the doc. These don't fit ColorScheme
|
||||||
|
// natively (Material's primary/secondary/tertiary slots all carry the
|
||||||
|
// brand accent), so they live on a ThemeExtension exposed below.
|
||||||
|
const _actionPrimary = Color(0xFF4A5D3F); // Moss
|
||||||
|
const _actionPrimaryHover = Color(0xFF5A6F4D);
|
||||||
|
const _actionSecondary = Color(0xFF8B7355); // Bronze
|
||||||
|
const _actionSecondaryHover = Color(0xFFA0876A);
|
||||||
|
const _actionDestructive = Color(0xFF6B2118); // Oxblood
|
||||||
|
const _actionDestructiveHover = Color(0xFF7E2A1F);
|
||||||
|
const _actionGhostBorder = Color(0xFF3F4651); // Pewter (same as outline)
|
||||||
|
|
||||||
|
// ── Action ThemeExtension ─────────────────────────────────────────────────────
|
||||||
|
// Read with: Theme.of(context).extension<ActionColors>()!.primary
|
||||||
|
//
|
||||||
|
// Flutter's ColorScheme has primary/secondary/tertiary all conceptually
|
||||||
|
// "branded", whereas the doc's Hybrid rule reserves accent for brand
|
||||||
|
// moments (Send, empty-state CTAs) and routes action buttons through a
|
||||||
|
// separate Moss/Bronze/Oxblood/Pewter palette. This extension carries
|
||||||
|
// those tokens without polluting ColorScheme.
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class ActionColors extends ThemeExtension<ActionColors> {
|
||||||
|
final Color primary; // Moss — Save / Confirm
|
||||||
|
final Color primaryHover;
|
||||||
|
final Color secondary; // Bronze — Cancel / alternate paths
|
||||||
|
final Color secondaryHover;
|
||||||
|
final Color destructive; // Oxblood — Delete / irreversible
|
||||||
|
final Color destructiveHover;
|
||||||
|
final Color ghostBorder; // Pewter — tertiary / "later" / "skip"
|
||||||
|
|
||||||
|
const ActionColors({
|
||||||
|
required this.primary,
|
||||||
|
required this.primaryHover,
|
||||||
|
required this.secondary,
|
||||||
|
required this.secondaryHover,
|
||||||
|
required this.destructive,
|
||||||
|
required this.destructiveHover,
|
||||||
|
required this.ghostBorder,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const _kStandard = ActionColors(
|
||||||
|
primary: _actionPrimary,
|
||||||
|
primaryHover: _actionPrimaryHover,
|
||||||
|
secondary: _actionSecondary,
|
||||||
|
secondaryHover: _actionSecondaryHover,
|
||||||
|
destructive: _actionDestructive,
|
||||||
|
destructiveHover: _actionDestructiveHover,
|
||||||
|
ghostBorder: _actionGhostBorder,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ActionColors copyWith({
|
||||||
|
Color? primary,
|
||||||
|
Color? primaryHover,
|
||||||
|
Color? secondary,
|
||||||
|
Color? secondaryHover,
|
||||||
|
Color? destructive,
|
||||||
|
Color? destructiveHover,
|
||||||
|
Color? ghostBorder,
|
||||||
|
}) {
|
||||||
|
return ActionColors(
|
||||||
|
primary: primary ?? this.primary,
|
||||||
|
primaryHover: primaryHover ?? this.primaryHover,
|
||||||
|
secondary: secondary ?? this.secondary,
|
||||||
|
secondaryHover: secondaryHover ?? this.secondaryHover,
|
||||||
|
destructive: destructive ?? this.destructive,
|
||||||
|
destructiveHover: destructiveHover ?? this.destructiveHover,
|
||||||
|
ghostBorder: ghostBorder ?? this.ghostBorder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ActionColors lerp(ThemeExtension<ActionColors>? other, double t) {
|
||||||
|
if (other is! ActionColors) return this;
|
||||||
|
return ActionColors(
|
||||||
|
primary: Color.lerp(primary, other.primary, t)!,
|
||||||
|
primaryHover: Color.lerp(primaryHover, other.primaryHover, t)!,
|
||||||
|
secondary: Color.lerp(secondary, other.secondary, t)!,
|
||||||
|
secondaryHover: Color.lerp(secondaryHover, other.secondaryHover, t)!,
|
||||||
|
destructive: Color.lerp(destructive, other.destructive, t)!,
|
||||||
|
destructiveHover: Color.lerp(destructiveHover, other.destructiveHover, t)!,
|
||||||
|
ghostBorder: Color.lerp(ghostBorder, other.ghostBorder, t)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Typography ─────────────────────────────────────────────────────────────────
|
// ── Typography ─────────────────────────────────────────────────────────────────
|
||||||
|
// Inter for body / labels / titleMedium-and-below.
|
||||||
|
// Fraunces for display / headline / titleLarge — only at ≥18px per the doc.
|
||||||
|
// JetBrains Mono available via GoogleFonts.jetBrainsMono() at call sites for
|
||||||
|
// code blocks (Flutter's TextTheme has no dedicated mono slot).
|
||||||
|
|
||||||
TextTheme _buildTextTheme(TextTheme base) {
|
TextTheme _buildTextTheme(TextTheme base) {
|
||||||
|
final inter = GoogleFonts.interTextTheme(base);
|
||||||
final fraunces = GoogleFonts.frauncesTextTheme(base);
|
final fraunces = GoogleFonts.frauncesTextTheme(base);
|
||||||
return base.copyWith(
|
return inter.copyWith(
|
||||||
// Headings / titles use Fraunces
|
displayLarge: fraunces.displayLarge,
|
||||||
|
displayMedium: fraunces.displayMedium,
|
||||||
|
displaySmall: fraunces.displaySmall,
|
||||||
headlineLarge: fraunces.headlineLarge,
|
headlineLarge: fraunces.headlineLarge,
|
||||||
headlineMedium: fraunces.headlineMedium,
|
headlineMedium: fraunces.headlineMedium,
|
||||||
headlineSmall: fraunces.headlineSmall,
|
headlineSmall: fraunces.headlineSmall,
|
||||||
titleLarge: fraunces.titleLarge,
|
titleLarge: fraunces.titleLarge,
|
||||||
titleMedium: fraunces.titleMedium,
|
// titleMedium / titleSmall / body* / label* stay Inter
|
||||||
// Body / labels remain system default
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +148,7 @@ ThemeData fabledDarkTheme() {
|
|||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
primary: _darkPrimary,
|
primary: _darkPrimary,
|
||||||
onPrimary: Colors.white,
|
onPrimary: Colors.white,
|
||||||
primaryContainer: const Color(0xFF5B21B6),
|
primaryContainer: _darkPrimaryDeep,
|
||||||
onPrimaryContainer: _darkOnSurface,
|
onPrimaryContainer: _darkOnSurface,
|
||||||
secondary: _darkPrimary,
|
secondary: _darkPrimary,
|
||||||
onSecondary: Colors.white,
|
onSecondary: Colors.white,
|
||||||
@@ -51,10 +158,10 @@ ThemeData fabledDarkTheme() {
|
|||||||
onTertiary: Colors.white,
|
onTertiary: Colors.white,
|
||||||
tertiaryContainer: _darkSurfaceVar,
|
tertiaryContainer: _darkSurfaceVar,
|
||||||
onTertiaryContainer: _darkOnSurface,
|
onTertiaryContainer: _darkOnSurface,
|
||||||
error: const Color(0xFFEF4444),
|
error: _semanticError,
|
||||||
onError: Colors.white,
|
onError: Colors.white,
|
||||||
errorContainer: const Color(0xFF7F1D1D),
|
errorContainer: _semanticErrorBg,
|
||||||
onErrorContainer: const Color(0xFFFEE2E2),
|
onErrorContainer: _semanticErrorFg,
|
||||||
surface: _darkSurface,
|
surface: _darkSurface,
|
||||||
onSurface: _darkOnSurface,
|
onSurface: _darkOnSurface,
|
||||||
surfaceContainerHighest: _darkSurfaceVar,
|
surfaceContainerHighest: _darkSurfaceVar,
|
||||||
@@ -73,6 +180,7 @@ ThemeData fabledDarkTheme() {
|
|||||||
colorScheme: cs,
|
colorScheme: cs,
|
||||||
scaffoldBackgroundColor: _darkBackground,
|
scaffoldBackgroundColor: _darkBackground,
|
||||||
textTheme: _buildTextTheme(ThemeData.dark().textTheme),
|
textTheme: _buildTextTheme(ThemeData.dark().textTheme),
|
||||||
|
extensions: const [ActionColors._kStandard],
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
color: _darkSurface,
|
color: _darkSurface,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
@@ -118,7 +226,9 @@ ThemeData fabledLightTheme() {
|
|||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
primary: _lightPrimary,
|
primary: _lightPrimary,
|
||||||
onPrimary: Colors.white,
|
onPrimary: Colors.white,
|
||||||
primaryContainer: const Color(0xFFEDE5FF),
|
// Warm parchment-tinted primary container, replacing the prior cool
|
||||||
|
// indigo `#EDE5FF`. Used for chip/badge backgrounds at low alpha.
|
||||||
|
primaryContainer: const Color(0xFFEDE9F4),
|
||||||
onPrimaryContainer: _lightOnSurface,
|
onPrimaryContainer: _lightOnSurface,
|
||||||
secondary: _lightPrimary,
|
secondary: _lightPrimary,
|
||||||
onSecondary: Colors.white,
|
onSecondary: Colors.white,
|
||||||
@@ -128,10 +238,10 @@ ThemeData fabledLightTheme() {
|
|||||||
onTertiary: Colors.white,
|
onTertiary: Colors.white,
|
||||||
tertiaryContainer: _lightSurfaceVar,
|
tertiaryContainer: _lightSurfaceVar,
|
||||||
onTertiaryContainer: _lightOnSurface,
|
onTertiaryContainer: _lightOnSurface,
|
||||||
error: const Color(0xFFDC2626),
|
error: _semanticError,
|
||||||
onError: Colors.white,
|
onError: Colors.white,
|
||||||
errorContainer: const Color(0xFFFEE2E2),
|
errorContainer: const Color(0xFFFEE2E2),
|
||||||
onErrorContainer: const Color(0xFF7F1D1D),
|
onErrorContainer: _semanticErrorBg,
|
||||||
surface: _lightSurface,
|
surface: _lightSurface,
|
||||||
onSurface: _lightOnSurface,
|
onSurface: _lightOnSurface,
|
||||||
surfaceContainerHighest: _lightSurfaceVar,
|
surfaceContainerHighest: _lightSurfaceVar,
|
||||||
@@ -150,6 +260,7 @@ ThemeData fabledLightTheme() {
|
|||||||
colorScheme: cs,
|
colorScheme: cs,
|
||||||
scaffoldBackgroundColor: _lightBackground,
|
scaffoldBackgroundColor: _lightBackground,
|
||||||
textTheme: _buildTextTheme(ThemeData.light().textTheme),
|
textTheme: _buildTextTheme(ThemeData.light().textTheme),
|
||||||
|
extensions: const [ActionColors._kStandard],
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
color: _lightSurface,
|
color: _lightSurface,
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
@@ -191,7 +302,10 @@ ThemeData fabledLightTheme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── GradientButton ─────────────────────────────────────────────────────────────
|
// ── GradientButton ─────────────────────────────────────────────────────────────
|
||||||
// Use wherever the web app uses the indigo gradient button (send, primary actions).
|
// Brand-moment CTA equivalent of the web's `--gradient-cta` — chat send,
|
||||||
|
// journal send, primary "Scribe-feature" actions. Reserve for those moments
|
||||||
|
// per the doc's Hybrid rule; use FilledButton with ActionColors.primary
|
||||||
|
// (Moss) for everything else.
|
||||||
|
|
||||||
class GradientButton extends StatelessWidget {
|
class GradientButton extends StatelessWidget {
|
||||||
final VoidCallback? onPressed;
|
final VoidCallback? onPressed;
|
||||||
@@ -218,15 +332,15 @@ class GradientButton extends StatelessWidget {
|
|||||||
: const LinearGradient(
|
: const LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF7C3AED), Color(0xFF5B21B6)],
|
colors: [_darkPrimary, _darkPrimaryDeep],
|
||||||
),
|
),
|
||||||
color: disabled ? const Color(0xFF7C3AED) : null,
|
color: disabled ? _darkPrimary : null,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
boxShadow: disabled
|
boxShadow: disabled
|
||||||
? null
|
? null
|
||||||
: [
|
: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: const Color(0xFF7C3AED).withValues(alpha: 0.45),
|
color: _darkPrimary.withValues(alpha: 0.45),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 3),
|
offset: const Offset(0, 3),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/briefing/rss-reactions body: {rss_item_id, reaction: "up"|"down"}
|
|
||||||
Future<void> postRssReaction(int rssItemId, String reaction) async {
|
|
||||||
try {
|
|
||||||
await _dio.post('/api/briefing/rss-reactions',
|
|
||||||
data: {'rss_item_id': rssItemId, 'reaction': reaction});
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw dioToApp(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/briefing/articles/{itemId}/discuss body: {"conv_id": convId}
|
|
||||||
/// Injects the article as context and triggers LLM generation.
|
|
||||||
/// Returns the assistant_message_id of the generating placeholder.
|
|
||||||
Future<int> discussArticle(int convId, int itemId) async {
|
|
||||||
try {
|
|
||||||
final response = await _dio.post(
|
|
||||||
'/api/briefing/articles/$itemId/discuss',
|
|
||||||
data: {'conv_id': convId},
|
|
||||||
);
|
|
||||||
final data = response.data as Map<String, dynamic>;
|
|
||||||
return data['assistant_message_id'] as int;
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw dioToApp(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// DELETE /api/briefing/rss-reactions/{rssItemId}
|
|
||||||
Future<void> deleteRssReaction(int rssItemId) async {
|
|
||||||
try {
|
|
||||||
await _dio.delete('/api/briefing/rss-reactions/$rssItemId');
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw dioToApp(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -164,17 +164,4 @@ class ChatApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/chat/from-article/{itemId}
|
|
||||||
/// Creates or retrieves a chat conversation seeded with the article.
|
|
||||||
/// Returns the conversation_id.
|
|
||||||
Future<int> openArticleInChat(int itemId) async {
|
|
||||||
try {
|
|
||||||
final response =
|
|
||||||
await _dio.post('/api/chat/from-article/$itemId', data: <String, dynamic>{});
|
|
||||||
final data = response.data as Map<String, dynamic>;
|
|
||||||
return data['conversation_id'] as int;
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw dioToApp(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../models/journal_day.dart';
|
||||||
|
import 'api_client.dart';
|
||||||
|
|
||||||
|
class JournalApi {
|
||||||
|
final Dio _dio;
|
||||||
|
const JournalApi(this._dio);
|
||||||
|
|
||||||
|
/// GET /api/journal/today
|
||||||
|
/// Creates today's journal conversation + daily prep on demand if absent,
|
||||||
|
/// then returns the day payload.
|
||||||
|
Future<JournalDay> getToday() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/journal/today');
|
||||||
|
return JournalDay.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/journal/day/<iso_date>
|
||||||
|
Future<JournalDay> getDay(String isoDate) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/journal/day/$isoDate');
|
||||||
|
return JournalDay.fromJson(response.data as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/journal/days — list of dates with journal content, newest first.
|
||||||
|
Future<List<String>> getDays() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/journal/days');
|
||||||
|
final data = response.data as Map<String, dynamic>;
|
||||||
|
final list = data['days'] as List<dynamic>;
|
||||||
|
return list.map((e) => e as String).toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/journal/trigger-prep — force-regenerate today's daily prep
|
||||||
|
/// (or a specific day if [isoDate] is given).
|
||||||
|
Future<void> triggerPrep({String? isoDate}) async {
|
||||||
|
try {
|
||||||
|
final body = <String, dynamic>{};
|
||||||
|
if (isoDate != null) body['date'] = isoDate;
|
||||||
|
await _dio.post('/api/journal/trigger-prep', data: body);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import 'package:dio/dio.dart';
|
|
||||||
|
|
||||||
import '../models/briefing_feed.dart';
|
|
||||||
import '../models/news_item.dart';
|
|
||||||
import 'api_client.dart';
|
|
||||||
|
|
||||||
class NewsApi {
|
|
||||||
final Dio _dio;
|
|
||||||
const NewsApi(this._dio);
|
|
||||||
|
|
||||||
/// GET /api/briefing/news
|
|
||||||
/// Returns up to [limit] items starting at [offset], optionally filtered by [feedId].
|
|
||||||
Future<List<NewsItem>> getNewsItems({
|
|
||||||
int days = 90,
|
|
||||||
int limit = 40,
|
|
||||||
int offset = 0,
|
|
||||||
int? feedId,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
final params = <String, dynamic>{
|
|
||||||
'days': days,
|
|
||||||
'limit': limit,
|
|
||||||
'offset': offset,
|
|
||||||
if (feedId != null) 'feed_id': feedId,
|
|
||||||
};
|
|
||||||
final response = await _dio.get(
|
|
||||||
'/api/briefing/news',
|
|
||||||
queryParameters: params,
|
|
||||||
);
|
|
||||||
final data = response.data as Map<String, dynamic>;
|
|
||||||
final list = data['items'] as List<dynamic>;
|
|
||||||
return list
|
|
||||||
.map((e) => NewsItem.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw dioToApp(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/briefing/feeds
|
|
||||||
Future<List<BriefingFeed>> getFeeds() async {
|
|
||||||
try {
|
|
||||||
final response = await _dio.get('/api/briefing/feeds');
|
|
||||||
final list = response.data as List<dynamic>;
|
|
||||||
return list
|
|
||||||
.map((e) => BriefingFeed.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw dioToApp(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
class BriefingFeed {
|
|
||||||
final int id;
|
|
||||||
final String title;
|
|
||||||
final String url;
|
|
||||||
final String? category;
|
|
||||||
|
|
||||||
const BriefingFeed({
|
|
||||||
required this.id,
|
|
||||||
required this.title,
|
|
||||||
required this.url,
|
|
||||||
this.category,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory BriefingFeed.fromJson(Map<String, dynamic> json) => BriefingFeed(
|
|
||||||
id: json['id'] as int,
|
|
||||||
title: json['title'] as String? ?? '',
|
|
||||||
url: json['url'] as String? ?? '',
|
|
||||||
category: json['category'] as String?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import 'message.dart';
|
||||||
|
|
||||||
|
/// Lightweight conversation header for a journal day — just enough to drive
|
||||||
|
/// navigation and labels. The full message list comes alongside in [JournalDay].
|
||||||
|
class JournalConversation {
|
||||||
|
final int id;
|
||||||
|
final String title;
|
||||||
|
final String conversationType;
|
||||||
|
final String? dayDate; // YYYY-MM-DD or null
|
||||||
|
|
||||||
|
const JournalConversation({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.conversationType,
|
||||||
|
this.dayDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory JournalConversation.fromJson(Map<String, dynamic> json) =>
|
||||||
|
JournalConversation(
|
||||||
|
id: json['id'] as int,
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
conversationType:
|
||||||
|
json['conversation_type'] as String? ?? 'journal',
|
||||||
|
dayDate: json['day_date'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payload returned by GET /api/journal/today and /api/journal/day/<iso>.
|
||||||
|
/// `conversation` is null on a day with no journal content yet (rare —
|
||||||
|
/// the today endpoint creates it on demand).
|
||||||
|
class JournalDay {
|
||||||
|
final String dayDate;
|
||||||
|
final JournalConversation? conversation;
|
||||||
|
final List<Message> messages;
|
||||||
|
|
||||||
|
const JournalDay({
|
||||||
|
required this.dayDate,
|
||||||
|
required this.conversation,
|
||||||
|
required this.messages,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory JournalDay.fromJson(Map<String, dynamic> json) {
|
||||||
|
final convRaw = json['conversation'] as Map<String, dynamic>?;
|
||||||
|
final rawMessages = json['messages'] as List<dynamic>? ?? [];
|
||||||
|
return JournalDay(
|
||||||
|
dayDate: json['day_date'] as String,
|
||||||
|
conversation:
|
||||||
|
convRaw == null ? null : JournalConversation.fromJson(convRaw),
|
||||||
|
messages: rawMessages
|
||||||
|
.map((e) => Message.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
JournalDay copyWith({List<Message>? messages}) => JournalDay(
|
||||||
|
dayDate: dayDate,
|
||||||
|
conversation: conversation,
|
||||||
|
messages: messages ?? this.messages,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
class NewsItem {
|
|
||||||
final int id;
|
|
||||||
final String title;
|
|
||||||
final String url;
|
|
||||||
final String snippet;
|
|
||||||
final String source;
|
|
||||||
final DateTime? publishedAt;
|
|
||||||
final List<String> topics;
|
|
||||||
final String? reaction;
|
|
||||||
|
|
||||||
const NewsItem({
|
|
||||||
required this.id,
|
|
||||||
required this.title,
|
|
||||||
required this.url,
|
|
||||||
required this.snippet,
|
|
||||||
required this.source,
|
|
||||||
this.publishedAt,
|
|
||||||
required this.topics,
|
|
||||||
this.reaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory NewsItem.fromJson(Map<String, dynamic> json) => NewsItem(
|
|
||||||
id: json['id'] as int,
|
|
||||||
title: json['title'] as String? ?? '',
|
|
||||||
url: json['url'] as String? ?? '',
|
|
||||||
snippet: json['snippet'] as String? ?? '',
|
|
||||||
source: json['source'] as String? ?? '',
|
|
||||||
publishedAt: json['published_at'] != null
|
|
||||||
? DateTime.tryParse(json['published_at'] as String)
|
|
||||||
: null,
|
|
||||||
topics: (json['topics'] as List<dynamic>?)
|
|
||||||
?.cast<String>()
|
|
||||||
.toList() ??
|
|
||||||
[],
|
|
||||||
reaction: json['reaction'] as String?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,13 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../data/api/api_client.dart';
|
import '../data/api/api_client.dart';
|
||||||
import '../data/api/auth_api.dart';
|
import '../data/api/auth_api.dart';
|
||||||
import '../data/api/briefing_api.dart';
|
|
||||||
import '../data/api/chat_api.dart';
|
import '../data/api/chat_api.dart';
|
||||||
|
import '../data/api/journal_api.dart';
|
||||||
import '../data/api/knowledge_api.dart';
|
import '../data/api/knowledge_api.dart';
|
||||||
import '../data/api/voice_api.dart';
|
import '../data/api/voice_api.dart';
|
||||||
import '../data/api/milestones_api.dart';
|
import '../data/api/milestones_api.dart';
|
||||||
import '../data/api/events_api.dart';
|
import '../data/api/events_api.dart';
|
||||||
import '../data/api/news_api.dart';
|
|
||||||
import '../data/api/notes_api.dart';
|
import '../data/api/notes_api.dart';
|
||||||
import '../data/api/projects_api.dart';
|
import '../data/api/projects_api.dart';
|
||||||
import '../data/api/quick_capture_api.dart';
|
import '../data/api/quick_capture_api.dart';
|
||||||
@@ -97,8 +96,8 @@ final knowledgeRepositoryProvider = Provider<KnowledgeRepository>((ref) {
|
|||||||
return KnowledgeRepository(ref.watch(knowledgeApiProvider));
|
return KnowledgeRepository(ref.watch(knowledgeApiProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final briefingApiProvider = Provider<BriefingApi>((ref) {
|
final journalApiProvider = Provider<JournalApi>((ref) {
|
||||||
return BriefingApi(ref.watch(dioProvider));
|
return JournalApi(ref.watch(dioProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final settingsApiProvider = Provider<SettingsApi>((ref) {
|
final settingsApiProvider = Provider<SettingsApi>((ref) {
|
||||||
@@ -113,10 +112,6 @@ final voiceRepositoryProvider = Provider<VoiceRepository>((ref) {
|
|||||||
return VoiceRepository(ref.watch(voiceApiProvider));
|
return VoiceRepository(ref.watch(voiceApiProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final newsApiProvider = Provider<NewsApi>((ref) {
|
|
||||||
return NewsApi(ref.watch(dioProvider));
|
|
||||||
});
|
|
||||||
|
|
||||||
final eventsApiProvider = Provider<EventsApi>((ref) {
|
final eventsApiProvider = Provider<EventsApi>((ref) {
|
||||||
return EventsApi(ref.watch(dioProvider));
|
return EventsApi(ref.watch(dioProvider));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import 'dart:async';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../data/api/chat_api.dart';
|
import '../data/api/chat_api.dart';
|
||||||
import '../data/models/briefing_conversation.dart';
|
import '../data/models/journal_day.dart';
|
||||||
import '../data/models/message.dart';
|
import '../data/models/message.dart';
|
||||||
import 'api_client_provider.dart';
|
import 'api_client_provider.dart';
|
||||||
|
|
||||||
/// Drives the loading indicator in BriefingScreen's reply area.
|
/// Drives the loading indicator in JournalScreen's reply area.
|
||||||
final isBriefingStreamingProvider =
|
final isJournalStreamingProvider =
|
||||||
NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new);
|
NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new);
|
||||||
|
|
||||||
class _BoolNotifier extends Notifier<bool> {
|
class _BoolNotifier extends Notifier<bool> {
|
||||||
@@ -16,23 +16,22 @@ class _BoolNotifier extends Notifier<bool> {
|
|||||||
bool build() => false;
|
bool build() => false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final briefingProvider =
|
final journalProvider =
|
||||||
AsyncNotifierProvider<BriefingNotifier, BriefingConversation>(
|
AsyncNotifierProvider<JournalNotifier, JournalDay>(JournalNotifier.new);
|
||||||
BriefingNotifier.new);
|
|
||||||
|
|
||||||
class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
class JournalNotifier extends AsyncNotifier<JournalDay> {
|
||||||
@override
|
@override
|
||||||
Future<BriefingConversation> build() async {
|
Future<JournalDay> build() async {
|
||||||
return ref.read(briefingApiProvider).getToday();
|
return ref.read(journalApiProvider).getToday();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Silently fetch the latest briefing and patch state without triggering
|
/// Silently fetch today's journal and patch state without triggering
|
||||||
/// AsyncLoading — existing content stays visible while the fetch is in flight.
|
/// AsyncLoading — existing content stays visible while the fetch is in flight.
|
||||||
Future<void> silentRefresh() async {
|
Future<void> silentRefresh() async {
|
||||||
final current = state.value;
|
final current = state.value;
|
||||||
if (current == null) return;
|
if (current == null) return;
|
||||||
try {
|
try {
|
||||||
final fresh = await ref.read(briefingApiProvider).getToday();
|
final fresh = await ref.read(journalApiProvider).getToday();
|
||||||
final curLast = current.messages.isNotEmpty ? current.messages.last : null;
|
final curLast = current.messages.isNotEmpty ? current.messages.last : null;
|
||||||
final newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null;
|
final newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null;
|
||||||
if (fresh.messages.length != current.messages.length ||
|
if (fresh.messages.length != current.messages.length ||
|
||||||
@@ -40,26 +39,25 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
|||||||
state = AsyncData(fresh);
|
state = AsyncData(fresh);
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Network hiccup — silently ignore, keep existing content
|
// Network hiccup — silently ignore, keep existing content.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trigger a briefing slot (e.g. "compilation") then reload.
|
/// Force-regenerate today's daily prep then reload.
|
||||||
Future<void> refresh(String slot) async {
|
Future<void> regeneratePrep() async {
|
||||||
await ref.read(briefingApiProvider).triggerSlot(slot);
|
await ref.read(journalApiProvider).triggerPrep();
|
||||||
ref.invalidateSelf();
|
ref.invalidateSelf();
|
||||||
await future;
|
await future;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-fetch the current briefing conversation and unfreeze a stuck
|
/// Re-fetch today's journal and unfreeze a stuck streaming state if the
|
||||||
/// streaming state if the server-side message is already complete.
|
/// server-side message is already complete.
|
||||||
///
|
///
|
||||||
/// Same role as MessagesNotifier.refresh() in chat_provider: when an SSE
|
/// Same role as MessagesNotifier.refresh() in chat_provider: when an SSE
|
||||||
/// socket dies silently (mobile network handoff, app backgrounded mid-stream,
|
/// socket dies silently the send loop never observes close and
|
||||||
/// reverse proxy dropping idle sockets) the send loop never observes close
|
/// [isJournalStreamingProvider] stays stuck true. This is the manual
|
||||||
/// and [isBriefingStreamingProvider] stays stuck true. This is the manual
|
/// recovery path hit by pull-to-refresh, the AppBar refresh button, and
|
||||||
/// recovery path hit by pull-to-refresh, the AppBar refresh button, and the
|
/// the lifecycle-resume hook.
|
||||||
/// lifecycle-resume hook.
|
|
||||||
Future<void> refreshMessages() async {
|
Future<void> refreshMessages() async {
|
||||||
final current = state.value;
|
final current = state.value;
|
||||||
if (current == null) {
|
if (current == null) {
|
||||||
@@ -67,7 +65,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final fresh = await ref.read(briefingApiProvider).getToday();
|
final fresh = await ref.read(journalApiProvider).getToday();
|
||||||
state = AsyncData(fresh);
|
state = AsyncData(fresh);
|
||||||
final messages = fresh.messages;
|
final messages = fresh.messages;
|
||||||
Message? lastAssistant;
|
Message? lastAssistant;
|
||||||
@@ -78,46 +76,14 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (lastAssistant != null && lastAssistant.status != 'generating') {
|
if (lastAssistant != null && lastAssistant.status != 'generating') {
|
||||||
ref.read(isBriefingStreamingProvider.notifier).state = false;
|
ref.read(isJournalStreamingProvider.notifier).state = false;
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Network hiccup — keep existing state; user can retry.
|
// Network hiccup — keep existing state; user can retry.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inject a news article as context and trigger generation.
|
/// Send a reply to today's journal conversation.
|
||||||
///
|
|
||||||
/// Mirrors sendReply() but calls the /discuss endpoint instead of
|
|
||||||
/// /messages so the backend injects article content before generating.
|
|
||||||
Future<void> discussArticle(int convId, int itemId) async {
|
|
||||||
final conv = state.value;
|
|
||||||
if (conv == null) return;
|
|
||||||
final chatApi = ref.read(chatApiProvider);
|
|
||||||
final briefingApi = ref.read(briefingApiProvider);
|
|
||||||
|
|
||||||
final previous = conv.messages;
|
|
||||||
final placeholder = Message(
|
|
||||||
conversationId: convId,
|
|
||||||
role: MessageRole.assistant,
|
|
||||||
content: '',
|
|
||||||
status: 'generating',
|
|
||||||
);
|
|
||||||
state = AsyncData(conv.copyWith(messages: [...previous, placeholder]));
|
|
||||||
ref.read(isBriefingStreamingProvider.notifier).state = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await briefingApi.discussArticle(convId, itemId);
|
|
||||||
} catch (e) {
|
|
||||||
state = AsyncData(conv.copyWith(messages: previous));
|
|
||||||
ref.read(isBriefingStreamingProvider.notifier).state = false;
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
|
|
||||||
final streamedContent = await _consumeStream(chatApi.streamGeneration(convId));
|
|
||||||
await _pollUntilComplete(convId, streamedContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a reply to today's briefing conversation.
|
|
||||||
///
|
///
|
||||||
/// Mirrors MessagesNotifier.sendMessage() in chat_provider with the same
|
/// Mirrors MessagesNotifier.sendMessage() in chat_provider with the same
|
||||||
/// stall-watchdog pattern:
|
/// stall-watchdog pattern:
|
||||||
@@ -126,12 +92,14 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
|||||||
/// 3. SSE stream with per-event timeout (stall watchdog)
|
/// 3. SSE stream with per-event timeout (stall watchdog)
|
||||||
/// 4. Poll until complete
|
/// 4. Poll until complete
|
||||||
Future<void> sendReply(String content) async {
|
Future<void> sendReply(String content) async {
|
||||||
final conv = state.value;
|
final day = state.value;
|
||||||
|
if (day == null) return;
|
||||||
|
final conv = day.conversation;
|
||||||
if (conv == null) return;
|
if (conv == null) return;
|
||||||
final convId = conv.id;
|
final convId = conv.id;
|
||||||
final chatApi = ref.read(chatApiProvider);
|
final chatApi = ref.read(chatApiProvider);
|
||||||
|
|
||||||
final previous = conv.messages;
|
final previous = day.messages;
|
||||||
final userMsg = Message(
|
final userMsg = Message(
|
||||||
conversationId: convId,
|
conversationId: convId,
|
||||||
role: MessageRole.user,
|
role: MessageRole.user,
|
||||||
@@ -143,14 +111,14 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
|||||||
content: '',
|
content: '',
|
||||||
status: 'generating',
|
status: 'generating',
|
||||||
);
|
);
|
||||||
state = AsyncData(conv.copyWith(messages: [...previous, userMsg, placeholder]));
|
state = AsyncData(day.copyWith(messages: [...previous, userMsg, placeholder]));
|
||||||
ref.read(isBriefingStreamingProvider.notifier).state = true;
|
ref.read(isJournalStreamingProvider.notifier).state = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await chatApi.sendMessage(convId, content);
|
await chatApi.sendMessage(convId, content);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = AsyncData(conv.copyWith(messages: previous));
|
state = AsyncData(day.copyWith(messages: previous));
|
||||||
ref.read(isBriefingStreamingProvider.notifier).state = false;
|
ref.read(isJournalStreamingProvider.notifier).state = false;
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,19 +126,15 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
|||||||
await _pollUntilComplete(convId, streamedContent);
|
await _pollUntilComplete(convId, streamedContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consume an SSE stream into the current briefing conversation state.
|
/// Consume an SSE stream into the current journal day state.
|
||||||
///
|
///
|
||||||
/// Uses a StreamIterator with a per-event timeout as a stall watchdog —
|
/// Uses a StreamIterator with a per-event timeout as a stall watchdog —
|
||||||
/// same rationale as MessagesNotifier.sendMessage() in chat_provider.dart.
|
/// same rationale as MessagesNotifier.sendMessage() in chat_provider.dart.
|
||||||
/// Mobile networks occasionally drop SSE sockets silently: the TCP
|
/// Mobile networks occasionally drop SSE sockets silently. If no event
|
||||||
/// connection is half-closed, Dio never sees the close, and `await for`
|
/// arrives within the watchdog window we bail out and let polling
|
||||||
/// hangs forever with [isBriefingStreamingProvider] stuck true. If no event
|
/// reconcile state from the server.
|
||||||
/// arrives within the watchdog window we bail out and let the polling pass
|
|
||||||
/// below reconcile state from the server.
|
|
||||||
///
|
///
|
||||||
/// Returns whether any text content was actually streamed (the polling
|
/// Returns whether any text content was actually streamed.
|
||||||
/// pass uses this to decide whether it's safe to overwrite with a possibly
|
|
||||||
/// empty server-side row).
|
|
||||||
Future<bool> _consumeStream(Stream<ChatStreamEvent> stream) async {
|
Future<bool> _consumeStream(Stream<ChatStreamEvent> stream) async {
|
||||||
const stallTimeout = Duration(seconds: 45);
|
const stallTimeout = Duration(seconds: 45);
|
||||||
bool streamedContent = false;
|
bool streamedContent = false;
|
||||||
@@ -208,24 +172,25 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
|||||||
return streamedContent;
|
return streamedContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll /api/briefing messages until the last assistant row is complete,
|
/// Poll today's journal until the last assistant row is complete. Always
|
||||||
/// same reconcile pattern as MessagesNotifier.sendMessage(). Always clears
|
/// clears [isJournalStreamingProvider] at the end so the input can't stay
|
||||||
/// [isBriefingStreamingProvider] at the end so the input can't stay locked.
|
/// locked.
|
||||||
Future<void> _pollUntilComplete(int convId, bool streamedContent) async {
|
Future<void> _pollUntilComplete(int convId, bool streamedContent) async {
|
||||||
final briefingApi = ref.read(briefingApiProvider);
|
final journalApi = ref.read(journalApiProvider);
|
||||||
try {
|
try {
|
||||||
for (var attempt = 0; attempt < 20; attempt++) {
|
for (var attempt = 0; attempt < 20; attempt++) {
|
||||||
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
|
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
|
||||||
final fresh = await briefingApi.getMessages(convId);
|
final fresh = await journalApi.getToday();
|
||||||
final done = fresh.any(
|
final freshMsgs = fresh.messages;
|
||||||
|
final done = freshMsgs.any(
|
||||||
(m) => m.role == MessageRole.assistant && m.status != 'generating',
|
(m) => m.role == MessageRole.assistant && m.status != 'generating',
|
||||||
);
|
);
|
||||||
final hasContent = fresh.any(
|
final hasContent = freshMsgs.any(
|
||||||
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
|
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
|
||||||
);
|
);
|
||||||
final current = state.value;
|
final current = state.value;
|
||||||
if (current != null && (!streamedContent || done || hasContent)) {
|
if (current != null && (!streamedContent || done || hasContent)) {
|
||||||
state = AsyncData(current.copyWith(messages: fresh));
|
state = AsyncData(current.copyWith(messages: freshMsgs));
|
||||||
}
|
}
|
||||||
if (done) break;
|
if (done) break;
|
||||||
}
|
}
|
||||||
@@ -241,7 +206,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
ref.read(isBriefingStreamingProvider.notifier).state = false;
|
ref.read(isJournalStreamingProvider.notifier).state = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
import '../data/models/briefing_feed.dart';
|
|
||||||
import '../data/models/news_item.dart';
|
|
||||||
import 'api_client_provider.dart';
|
|
||||||
|
|
||||||
// ─── NewsState ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class NewsState {
|
|
||||||
final List<NewsItem> items;
|
|
||||||
final int offset;
|
|
||||||
final bool hasMore;
|
|
||||||
final bool loadingMore;
|
|
||||||
final int? selectedFeedId;
|
|
||||||
final Map<int, String?> reactions;
|
|
||||||
|
|
||||||
const NewsState({
|
|
||||||
required this.items,
|
|
||||||
required this.offset,
|
|
||||||
required this.hasMore,
|
|
||||||
required this.loadingMore,
|
|
||||||
required this.selectedFeedId,
|
|
||||||
required this.reactions,
|
|
||||||
});
|
|
||||||
|
|
||||||
NewsState copyWith({
|
|
||||||
List<NewsItem>? items,
|
|
||||||
int? offset,
|
|
||||||
bool? hasMore,
|
|
||||||
bool? loadingMore,
|
|
||||||
Object? selectedFeedId = _sentinel,
|
|
||||||
Map<int, String?>? reactions,
|
|
||||||
}) {
|
|
||||||
return NewsState(
|
|
||||||
items: items ?? this.items,
|
|
||||||
offset: offset ?? this.offset,
|
|
||||||
hasMore: hasMore ?? this.hasMore,
|
|
||||||
loadingMore: loadingMore ?? this.loadingMore,
|
|
||||||
selectedFeedId: selectedFeedId == _sentinel
|
|
||||||
? this.selectedFeedId
|
|
||||||
: selectedFeedId as int?,
|
|
||||||
reactions: reactions ?? this.reactions,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const _sentinel = Object();
|
|
||||||
|
|
||||||
// ─── NewsNotifier ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
final newsProvider =
|
|
||||||
AsyncNotifierProvider<NewsNotifier, NewsState>(NewsNotifier.new);
|
|
||||||
|
|
||||||
class NewsNotifier extends AsyncNotifier<NewsState> {
|
|
||||||
static const _limit = 40;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<NewsState> build() async {
|
|
||||||
final items = await ref.watch(newsApiProvider).getNewsItems(
|
|
||||||
days: 90,
|
|
||||||
limit: _limit,
|
|
||||||
offset: 0,
|
|
||||||
);
|
|
||||||
return NewsState(
|
|
||||||
items: items,
|
|
||||||
offset: items.length,
|
|
||||||
hasMore: items.length == _limit,
|
|
||||||
loadingMore: false,
|
|
||||||
selectedFeedId: null,
|
|
||||||
reactions: {for (final item in items) item.id: item.reaction},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-fetch the first page without clearing state (no flicker).
|
|
||||||
Future<void> refresh() async {
|
|
||||||
final current = state.value;
|
|
||||||
final items = await ref.read(newsApiProvider).getNewsItems(
|
|
||||||
days: 90,
|
|
||||||
limit: _limit,
|
|
||||||
offset: 0,
|
|
||||||
feedId: current?.selectedFeedId,
|
|
||||||
);
|
|
||||||
state = AsyncData(NewsState(
|
|
||||||
items: items,
|
|
||||||
offset: items.length,
|
|
||||||
hasMore: items.length == _limit,
|
|
||||||
loadingMore: false,
|
|
||||||
selectedFeedId: current?.selectedFeedId,
|
|
||||||
reactions: {for (final item in items) item.id: item.reaction},
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> loadMore() async {
|
|
||||||
final current = state.value;
|
|
||||||
if (current == null || current.loadingMore || !current.hasMore) return;
|
|
||||||
state = AsyncData(current.copyWith(loadingMore: true));
|
|
||||||
try {
|
|
||||||
final items = await ref.read(newsApiProvider).getNewsItems(
|
|
||||||
days: 90,
|
|
||||||
limit: _limit,
|
|
||||||
offset: current.offset,
|
|
||||||
feedId: current.selectedFeedId,
|
|
||||||
);
|
|
||||||
final updatedReactions = Map<int, String?>.from(current.reactions);
|
|
||||||
for (final item in items) {
|
|
||||||
updatedReactions.putIfAbsent(item.id, () => item.reaction);
|
|
||||||
}
|
|
||||||
state = AsyncData(current.copyWith(
|
|
||||||
items: [...current.items, ...items],
|
|
||||||
offset: current.offset + items.length,
|
|
||||||
hasMore: items.length == _limit,
|
|
||||||
loadingMore: false,
|
|
||||||
reactions: updatedReactions,
|
|
||||||
));
|
|
||||||
} catch (e) {
|
|
||||||
final recovered = state.value ?? current;
|
|
||||||
state = AsyncData(recovered.copyWith(loadingMore: false));
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setFeed(int? feedId) async {
|
|
||||||
state = const AsyncLoading();
|
|
||||||
try {
|
|
||||||
final items = await ref.read(newsApiProvider).getNewsItems(
|
|
||||||
days: 90,
|
|
||||||
limit: _limit,
|
|
||||||
offset: 0,
|
|
||||||
feedId: feedId,
|
|
||||||
);
|
|
||||||
state = AsyncData(NewsState(
|
|
||||||
items: items,
|
|
||||||
offset: items.length,
|
|
||||||
hasMore: items.length == _limit,
|
|
||||||
loadingMore: false,
|
|
||||||
selectedFeedId: feedId,
|
|
||||||
reactions: {for (final item in items) item.id: item.reaction},
|
|
||||||
));
|
|
||||||
} catch (e, st) {
|
|
||||||
state = AsyncError(e, st);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void toggleReaction(int itemId, String reaction) {
|
|
||||||
final current = state.value;
|
|
||||||
if (current == null) return;
|
|
||||||
final prev = current.reactions[itemId];
|
|
||||||
final next = prev == reaction ? null : reaction;
|
|
||||||
state = AsyncData(current.copyWith(
|
|
||||||
reactions: {...current.reactions, itemId: next},
|
|
||||||
));
|
|
||||||
final briefingApi = ref.read(briefingApiProvider);
|
|
||||||
final future = next == null
|
|
||||||
? briefingApi.deleteRssReaction(itemId)
|
|
||||||
: briefingApi.postRssReaction(itemId, next);
|
|
||||||
future.catchError((_) {
|
|
||||||
final s = state.value;
|
|
||||||
if (s != null) {
|
|
||||||
state = AsyncData(s.copyWith(
|
|
||||||
reactions: {...s.reactions, itemId: prev},
|
|
||||||
));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── FeedsNotifier ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
final feedsProvider =
|
|
||||||
AsyncNotifierProvider<FeedsNotifier, List<BriefingFeed>>(FeedsNotifier.new);
|
|
||||||
|
|
||||||
class FeedsNotifier extends AsyncNotifier<List<BriefingFeed>> {
|
|
||||||
@override
|
|
||||||
Future<List<BriefingFeed>> build() async {
|
|
||||||
return ref.watch(newsApiProvider).getFeeds();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -125,17 +125,7 @@ class ServerSettingsNotifier extends AsyncNotifier<Map<String, dynamic>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get rssEnabled {
|
|
||||||
final data = state.value ?? {};
|
|
||||||
return data['rss_enabled']?.toString().toLowerCase() == 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
state = AsyncData(await ref.read(settingsApiProvider).getAll());
|
state = AsyncData(await ref.read(settingsApiProvider).getAll());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final rssEnabledProvider = Provider<bool>((ref) {
|
|
||||||
final settings = ref.watch(serverSettingsProvider).value ?? {};
|
|
||||||
return settings['rss_enabled']?.toString().toLowerCase() == 'true';
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
_usernameController.text.trim(),
|
_usernameController.text.trim(),
|
||||||
_passwordController.text,
|
_passwordController.text,
|
||||||
);
|
);
|
||||||
if (mounted) context.go(Routes.briefing);
|
if (mounted) context.go(Routes.journal);
|
||||||
} on AuthException catch (e) {
|
} on AuthException catch (e) {
|
||||||
setState(() => _error = e.message);
|
setState(() => _error = e.message);
|
||||||
} on AppException catch (e) {
|
} on AppException catch (e) {
|
||||||
@@ -90,7 +90,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
cookieJar: ref.read(cookieJarProvider),
|
cookieJar: ref.read(cookieJarProvider),
|
||||||
onSuccess: () async {
|
onSuccess: () async {
|
||||||
await ref.read(authProvider.notifier).verify();
|
await ref.read(authProvider.notifier).verify();
|
||||||
if (mounted) context.go(Routes.briefing);
|
if (mounted) context.go(Routes.journal);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
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,85 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../data/models/journal_day.dart';
|
||||||
|
import '../../providers/api_client_provider.dart';
|
||||||
|
import '../../widgets/chat_message_bubble.dart';
|
||||||
|
|
||||||
|
class JournalHistoryScreen extends ConsumerWidget {
|
||||||
|
const JournalHistoryScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final daysAsync = ref.watch(_journalDaysProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Past Days')),
|
||||||
|
body: daysAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (_, _) =>
|
||||||
|
const Center(child: Text('Could not load journal history.')),
|
||||||
|
data: (days) {
|
||||||
|
if (days.isEmpty) {
|
||||||
|
return const Center(child: Text('No past days.'));
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: days.length,
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final isoDate = days[i];
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.menu_book_outlined),
|
||||||
|
title: Text(isoDate),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => _JournalDayDetailScreen(isoDate: isoDate),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _JournalDayDetailScreen extends ConsumerWidget {
|
||||||
|
final String isoDate;
|
||||||
|
const _JournalDayDetailScreen({required this.isoDate});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final dayAsync = ref.watch(_journalDayProvider(isoDate));
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text(isoDate)),
|
||||||
|
body: dayAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (_, _) =>
|
||||||
|
const Center(child: Text('Could not load that day.')),
|
||||||
|
data: (day) {
|
||||||
|
if (day.messages.isEmpty) {
|
||||||
|
return const Center(child: Text('No messages.'));
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||||
|
itemCount: day.messages.length,
|
||||||
|
itemBuilder: (_, i) => ChatMessageBubble(message: day.messages[i]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private providers (scoped to this file) ──────────────────────────────────
|
||||||
|
|
||||||
|
final _journalDaysProvider = FutureProvider<List<String>>((ref) async {
|
||||||
|
return ref.watch(journalApiProvider).getDays();
|
||||||
|
});
|
||||||
|
|
||||||
|
final _journalDayProvider =
|
||||||
|
FutureProvider.family<JournalDay, String>((ref, isoDate) async {
|
||||||
|
return ref.watch(journalApiProvider).getDay(isoDate);
|
||||||
|
});
|
||||||
+85
-161
@@ -5,30 +5,25 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../core/exceptions.dart';
|
import '../../core/exceptions.dart';
|
||||||
import '../../data/models/message.dart';
|
import '../../data/models/message.dart';
|
||||||
import '../../providers/briefing_provider.dart';
|
import '../../providers/journal_provider.dart';
|
||||||
import '../../providers/api_client_provider.dart';
|
|
||||||
import '../../widgets/chat_message_bubble.dart';
|
|
||||||
import '../../widgets/weather_card.dart';
|
|
||||||
import '../../widgets/news_card.dart';
|
|
||||||
import 'briefing_history_screen.dart';
|
|
||||||
import '../../providers/settings_provider.dart';
|
|
||||||
import '../../providers/voice_provider.dart';
|
import '../../providers/voice_provider.dart';
|
||||||
|
import '../../widgets/chat_message_bubble.dart';
|
||||||
import '../../widgets/voice_mic_button.dart';
|
import '../../widgets/voice_mic_button.dart';
|
||||||
|
import '../../widgets/weather_card.dart';
|
||||||
|
import 'journal_history_screen.dart';
|
||||||
|
|
||||||
class BriefingScreen extends ConsumerStatefulWidget {
|
class JournalScreen extends ConsumerStatefulWidget {
|
||||||
const BriefingScreen({super.key});
|
const JournalScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<BriefingScreen> createState() => _BriefingScreenState();
|
ConsumerState<JournalScreen> createState() => _JournalScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
class _JournalScreenState extends ConsumerState<JournalScreen>
|
||||||
with WidgetsBindingObserver {
|
with WidgetsBindingObserver {
|
||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
bool _refreshing = false;
|
bool _refreshing = false;
|
||||||
// rss_item_id -> 'up' | 'down' | null
|
|
||||||
final Map<int, String?> _reactions = {};
|
|
||||||
|
|
||||||
Timer? _pollTimer;
|
Timer? _pollTimer;
|
||||||
bool _appInForeground = true;
|
bool _appInForeground = true;
|
||||||
@@ -37,31 +32,29 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_pollTimer = Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently());
|
_pollTimer =
|
||||||
|
Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
final wasBackground = !_appInForeground;
|
final wasBackground = !_appInForeground;
|
||||||
_appInForeground = state == AppLifecycleState.resumed;
|
_appInForeground = state == AppLifecycleState.resumed;
|
||||||
// On resume, force a message refresh. This also unfreezes a stuck
|
|
||||||
// streaming state if the SSE socket died while the app was backgrounded
|
|
||||||
// — silentRefresh won't do that because it guards on isStreaming.
|
|
||||||
if (_appInForeground && wasBackground && mounted) {
|
if (_appInForeground && wasBackground && mounted) {
|
||||||
ref.read(briefingProvider.notifier).refreshMessages();
|
ref.read(journalProvider.notifier).refreshMessages();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _pollSilently() {
|
void _pollSilently() {
|
||||||
if (!_appInForeground || !mounted) return;
|
if (!_appInForeground || !mounted) return;
|
||||||
final isStreaming = ref.read(isBriefingStreamingProvider);
|
final isStreaming = ref.read(isJournalStreamingProvider);
|
||||||
if (isStreaming) return;
|
if (isStreaming) return;
|
||||||
ref.read(briefingProvider.notifier).silentRefresh();
|
ref.read(journalProvider.notifier).silentRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pullToRefresh() async {
|
Future<void> _pullToRefresh() async {
|
||||||
try {
|
try {
|
||||||
await ref.read(briefingProvider.notifier).refreshMessages();
|
await ref.read(journalProvider.notifier).refreshMessages();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -97,7 +90,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
if (text.isEmpty) return;
|
if (text.isEmpty) return;
|
||||||
_controller.clear();
|
_controller.clear();
|
||||||
try {
|
try {
|
||||||
await ref.read(briefingProvider.notifier).sendReply(text);
|
await ref.read(journalProvider.notifier).sendReply(text);
|
||||||
} on AppException catch (e) {
|
} on AppException catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context)
|
ScaffoldMessenger.of(context)
|
||||||
@@ -112,39 +105,6 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleDiscuss(int convId, int itemId) async {
|
|
||||||
try {
|
|
||||||
await ref.read(briefingProvider.notifier).discussArticle(convId, itemId);
|
|
||||||
} 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 start discussion.')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleReaction(int itemId, String reaction) async {
|
|
||||||
final current = _reactions[itemId];
|
|
||||||
final next = current == reaction ? null : reaction;
|
|
||||||
setState(() => _reactions[itemId] = next);
|
|
||||||
final api = ref.read(briefingApiProvider);
|
|
||||||
try {
|
|
||||||
if (next == null) {
|
|
||||||
await api.deleteRssReaction(itemId);
|
|
||||||
} else {
|
|
||||||
await api.postRssReaction(itemId, reaction);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
setState(() => _reactions[itemId] = current);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _toggleVoiceMode() async {
|
Future<void> _toggleVoiceMode() async {
|
||||||
final voice = ref.read(voiceProvider);
|
final voice = ref.read(voiceProvider);
|
||||||
if (voice.voiceModeActive) {
|
if (voice.voiceModeActive) {
|
||||||
@@ -153,7 +113,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
}
|
}
|
||||||
await ref.read(voiceProvider.notifier).enterVoiceMode(
|
await ref.read(voiceProvider.notifier).enterVoiceMode(
|
||||||
onTranscript: (transcript) async {
|
onTranscript: (transcript) async {
|
||||||
await ref.read(briefingProvider.notifier).sendReply(transcript);
|
await ref.read(journalProvider.notifier).sendReply(transcript);
|
||||||
},
|
},
|
||||||
enableTts: true,
|
enableTts: true,
|
||||||
onError: (msg) {
|
onError: (msg) {
|
||||||
@@ -168,11 +128,11 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
Future<void> _refresh() async {
|
Future<void> _refresh() async {
|
||||||
setState(() => _refreshing = true);
|
setState(() => _refreshing = true);
|
||||||
try {
|
try {
|
||||||
await ref.read(briefingProvider.notifier).refresh('compilation');
|
await ref.read(journalProvider.notifier).regeneratePrep();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Could not generate briefing.')),
|
const SnackBar(content: Text('Could not regenerate prep.')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -182,20 +142,19 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final briefingAsync = ref.watch(briefingProvider);
|
final journalAsync = ref.watch(journalProvider);
|
||||||
final isStreaming = ref.watch(isBriefingStreamingProvider);
|
final isStreaming = ref.watch(isJournalStreamingProvider);
|
||||||
final voiceState = ref.watch(voiceProvider);
|
final voiceState = ref.watch(voiceProvider);
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Scroll to bottom when messages change
|
ref.listen(journalProvider, (prev, next) => _scrollToBottom());
|
||||||
ref.listen(briefingProvider, (prev, next) => _scrollToBottom());
|
|
||||||
|
|
||||||
// Feed streaming assistant content to VoiceNotifier for TTS.
|
// Feed streaming assistant content to VoiceNotifier for TTS.
|
||||||
ref.listen(briefingProvider, (prev, next) {
|
ref.listen(journalProvider, (prev, next) {
|
||||||
if (!voiceState.voiceModeActive) return;
|
if (!voiceState.voiceModeActive) return;
|
||||||
final conv = next.value;
|
final day = next.value;
|
||||||
if (conv == null || conv.messages.isEmpty) return;
|
if (day == null || day.messages.isEmpty) return;
|
||||||
final last = conv.messages.last;
|
final last = day.messages.last;
|
||||||
if (last.role != MessageRole.assistant) return;
|
if (last.role != MessageRole.assistant) return;
|
||||||
final isComplete = last.status != 'generating';
|
final isComplete = last.status != 'generating';
|
||||||
ref
|
ref
|
||||||
@@ -208,7 +167,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
title: Column(
|
title: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Briefing', style: Theme.of(context).textTheme.titleLarge),
|
Text('Journal', style: Theme.of(context).textTheme.titleLarge),
|
||||||
Text(
|
Text(
|
||||||
_todayLabel(),
|
_todayLabel(),
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
@@ -230,42 +189,42 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
else
|
else
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh_outlined),
|
icon: const Icon(Icons.refresh_outlined),
|
||||||
tooltip: 'Generate briefing',
|
tooltip: 'Regenerate prep',
|
||||||
onPressed: _refresh,
|
onPressed: _refresh,
|
||||||
),
|
),
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
onSelected: (value) {
|
onSelected: (value) {
|
||||||
if (value == 'history') {
|
if (value == 'history') {
|
||||||
Navigator.of(context).push(MaterialPageRoute(
|
Navigator.of(context).push(MaterialPageRoute(
|
||||||
builder: (_) => const BriefingHistoryScreen(),
|
builder: (_) => const JournalHistoryScreen(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (_) => const [
|
itemBuilder: (_) => const [
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'history',
|
value: 'history',
|
||||||
child: Text('View past briefings'),
|
child: Text('Past days'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: briefingAsync.when(
|
body: journalAsync.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (err, stack) => Center(
|
error: (err, stack) => Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Text("Could not load today's briefing."),
|
const Text("Could not load today's journal."),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FilledButton.tonal(
|
FilledButton.tonal(
|
||||||
onPressed: () => ref.invalidate(briefingProvider),
|
onPressed: () => ref.invalidate(journalProvider),
|
||||||
child: const Text('Retry'),
|
child: const Text('Retry'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
data: (conv) {
|
data: (day) {
|
||||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||||
Widget body = Column(
|
Widget body = Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -274,64 +233,49 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
onRefresh: _pullToRefresh,
|
onRefresh: _pullToRefresh,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
// AlwaysScrollable so pull-to-refresh fires even when
|
|
||||||
// the briefing is empty or shorter than the viewport.
|
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
slivers: [
|
slivers: [
|
||||||
if (conv.messages.isEmpty)
|
if (day.messages.isEmpty)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'No briefing yet today.',
|
'No prep yet today.',
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.bodyMedium
|
.bodyMedium
|
||||||
?.copyWith(color: scheme.onSurfaceVariant),
|
?.copyWith(color: scheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FilledButton.tonal(
|
FilledButton.tonal(
|
||||||
onPressed: _refresh,
|
onPressed: _refresh,
|
||||||
child: const Text('Generate now'),
|
child: const Text('Generate now'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8, vertical: 8),
|
||||||
|
sliver: SliverList.builder(
|
||||||
|
itemCount: day.messages.length,
|
||||||
|
itemBuilder: (_, i) =>
|
||||||
|
_JournalMessageItem(message: day.messages[i]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
else
|
|
||||||
SliverPadding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8, vertical: 8),
|
|
||||||
sliver: SliverList.builder(
|
|
||||||
itemCount: conv.messages.length,
|
|
||||||
itemBuilder: (_, i) {
|
|
||||||
final msg = conv.messages[i];
|
|
||||||
return _BriefingMessageItem(
|
|
||||||
message: msg,
|
|
||||||
convId: conv.id,
|
|
||||||
reactions: _reactions,
|
|
||||||
onReaction: _handleReaction,
|
|
||||||
onDiscuss: _handleDiscuss,
|
|
||||||
rssEnabled: ref.watch(rssEnabledProvider),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Progress bar while streaming
|
|
||||||
if (isStreaming)
|
if (isStreaming)
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
minHeight: 2,
|
minHeight: 2,
|
||||||
color: scheme.primary,
|
color: scheme.primary,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Voice mode banner
|
|
||||||
if (voiceState.voiceModeActive)
|
if (voiceState.voiceModeActive)
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -346,7 +290,6 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Reply bar
|
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
SafeArea(
|
SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -359,7 +302,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: voiceState.voiceModeActive
|
hintText: voiceState.voiceModeActive
|
||||||
? 'Listening…'
|
? 'Listening…'
|
||||||
: 'Reply to your briefing…',
|
: 'Tell your journal…',
|
||||||
hintStyle: voiceState.voiceModeActive
|
hintStyle: voiceState.voiceModeActive
|
||||||
? const TextStyle(fontStyle: FontStyle.italic)
|
? const TextStyle(fontStyle: FontStyle.italic)
|
||||||
: null,
|
: null,
|
||||||
@@ -422,65 +365,46 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders a single briefing message with optional WeatherCard above it
|
/// Renders a single journal message. For the daily-prep assistant message,
|
||||||
/// and RSS reaction buttons below it (for assistant messages with metadata).
|
/// also renders a WeatherCard above the bubble (weather lives in the
|
||||||
class _BriefingMessageItem extends StatelessWidget {
|
/// nested metadata.sections.weather payload).
|
||||||
|
class _JournalMessageItem extends StatelessWidget {
|
||||||
final Message message;
|
final Message message;
|
||||||
final int convId;
|
|
||||||
final Map<int, String?> reactions;
|
|
||||||
final void Function(int itemId, String reaction) onReaction;
|
|
||||||
final void Function(int convId, int itemId) onDiscuss;
|
|
||||||
final bool rssEnabled;
|
|
||||||
|
|
||||||
const _BriefingMessageItem({
|
const _JournalMessageItem({required this.message});
|
||||||
required this.message,
|
|
||||||
required this.convId,
|
|
||||||
required this.reactions,
|
|
||||||
required this.onReaction,
|
|
||||||
required this.onDiscuss,
|
|
||||||
this.rssEnabled = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final meta = message.metadata;
|
final meta = message.metadata;
|
||||||
final isAssistant = message.role == MessageRole.assistant;
|
final isAssistant = message.role == MessageRole.assistant;
|
||||||
|
final isPrep = isAssistant &&
|
||||||
|
meta != null &&
|
||||||
|
meta['kind'] == 'daily_prep';
|
||||||
|
|
||||||
// Weather: show card above when metadata.weather key is present (even if null value)
|
Map<String, dynamic>? weatherData;
|
||||||
final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather');
|
if (isPrep) {
|
||||||
final weatherData = hasWeatherKey ? meta['weather'] as Map<String, dynamic>? : null;
|
// The journal prep stores its structured data under metadata.sections.
|
||||||
|
final sections = meta['sections'] as Map<String, dynamic>?;
|
||||||
// RSS news cards — cap at 3 (only when RSS is enabled)
|
final weather = sections?['weather'];
|
||||||
final rssItems = <RssItemMeta>[];
|
if (weather is List && weather.isNotEmpty) {
|
||||||
if (rssEnabled && isAssistant && meta != null) {
|
// Show the first location's weather card. The widget expects a
|
||||||
final raw = (meta['rss_items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [];
|
// single location dict; we pass the first one through.
|
||||||
rssItems.addAll(raw.map(RssItemMeta.fromJson).take(3));
|
weatherData = weather.first as Map<String, dynamic>?;
|
||||||
|
} else if (weather is Map) {
|
||||||
|
weatherData = weather as Map<String, dynamic>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (hasWeatherKey) WeatherCard(weather: weatherData),
|
if (weatherData != null) WeatherCard(weather: weatherData),
|
||||||
ChatMessageBubble(message: message),
|
ChatMessageBubble(message: message),
|
||||||
if (rssItems.isNotEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: rssItems.map((item) => NewsCard(
|
|
||||||
item: item,
|
|
||||||
reaction: reactions[item.id],
|
|
||||||
onReaction: onReaction,
|
|
||||||
onDiscuss: () => onDiscuss(convId, item.id),
|
|
||||||
)).toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class _GradientSendButton extends StatelessWidget {
|
class _GradientSendButton extends StatelessWidget {
|
||||||
final VoidCallback? onPressed;
|
final VoidCallback? onPressed;
|
||||||
final bool isStreaming;
|
final bool isStreaming;
|
||||||
@@ -1,236 +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/briefing_feed.dart';
|
|
||||||
import '../../providers/api_client_provider.dart';
|
|
||||||
import '../../providers/news_provider.dart';
|
|
||||||
import '../../widgets/news_card.dart';
|
|
||||||
|
|
||||||
class NewsScreen extends ConsumerStatefulWidget {
|
|
||||||
const NewsScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<NewsScreen> createState() => _NewsScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NewsScreenState extends ConsumerState<NewsScreen> {
|
|
||||||
final Set<int> _openingChat = {};
|
|
||||||
|
|
||||||
Future<void> _handleDiscuss(int itemId) async {
|
|
||||||
if (_openingChat.contains(itemId)) return;
|
|
||||||
setState(() => _openingChat.add(itemId));
|
|
||||||
try {
|
|
||||||
final conversationId =
|
|
||||||
await ref.read(chatApiProvider).openArticleInChat(itemId);
|
|
||||||
if (mounted) {
|
|
||||||
context.push(Routes.chat.replaceFirst(':id', '$conversationId'));
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Failed to open article in chat.')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _openingChat.remove(itemId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadMore() async {
|
|
||||||
try {
|
|
||||||
await ref.read(newsProvider.notifier).loadMore();
|
|
||||||
} catch (_) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Failed to load more articles.')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildNewsItem(NewsState news, int i, {int cols = 1}) {
|
|
||||||
if (i >= news.items.length) {
|
|
||||||
if (!news.hasMore) return const SizedBox.shrink();
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
child: Center(
|
|
||||||
child: news.loadingMore
|
|
||||||
? const CircularProgressIndicator()
|
|
||||||
: FilledButton.tonal(
|
|
||||||
onPressed: _loadMore,
|
|
||||||
child: const Text('Load more'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final item = news.items[i];
|
|
||||||
return NewsCard(
|
|
||||||
item: RssItemMeta.fromNewsItem(item),
|
|
||||||
reaction: news.reactions[item.id],
|
|
||||||
snippetMaxLines: cols > 1 ? 5 : 2,
|
|
||||||
onReaction: (itemId, reaction) =>
|
|
||||||
ref.read(newsProvider.notifier).toggleReaction(itemId, reaction),
|
|
||||||
onDiscuss: _openingChat.contains(item.id)
|
|
||||||
? null
|
|
||||||
: () => _handleDiscuss(item.id),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final newsAsync = ref.watch(newsProvider);
|
|
||||||
final feedsAsync = ref.watch(feedsProvider);
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text('News', style: Theme.of(context).textTheme.titleLarge),
|
|
||||||
Text(
|
|
||||||
'Last 90 days',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: scheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: newsAsync.when(
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
|
||||||
error: (e, _) => Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Text('Could not load news.'),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
FilledButton.tonal(
|
|
||||||
onPressed: () => ref.invalidate(newsProvider),
|
|
||||||
child: const Text('Retry'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
data: (news) => Column(
|
|
||||||
children: [
|
|
||||||
_FeedFilter(
|
|
||||||
feeds: feedsAsync.value ?? [],
|
|
||||||
selectedFeedId: news.selectedFeedId,
|
|
||||||
onChanged: (feedId) =>
|
|
||||||
ref.read(newsProvider.notifier).setFeed(feedId),
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
Expanded(
|
|
||||||
child: RefreshIndicator(
|
|
||||||
onRefresh: () => ref.read(newsProvider.notifier).refresh(),
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final cols = constraints.maxWidth >= 900
|
|
||||||
? 3
|
|
||||||
: constraints.maxWidth >= 600
|
|
||||||
? 2
|
|
||||||
: 1;
|
|
||||||
if (cols == 1) {
|
|
||||||
return ListView.builder(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8, vertical: 8),
|
|
||||||
itemCount: news.items.length + 1,
|
|
||||||
itemBuilder: (_, i) =>
|
|
||||||
_buildNewsItem(news, i),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return CustomScrollView(
|
|
||||||
slivers: [
|
|
||||||
SliverPadding(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
sliver: SliverGrid(
|
|
||||||
gridDelegate:
|
|
||||||
SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: cols,
|
|
||||||
crossAxisSpacing: 8,
|
|
||||||
mainAxisSpacing: 8,
|
|
||||||
childAspectRatio: 1.6,
|
|
||||||
),
|
|
||||||
delegate: SliverChildBuilderDelegate(
|
|
||||||
(_, i) => _buildNewsItem(news, i, cols: cols),
|
|
||||||
childCount: news.items.length,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (news.hasMore)
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Padding(
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
child: Center(
|
|
||||||
child: news.loadingMore
|
|
||||||
? const CircularProgressIndicator()
|
|
||||||
: FilledButton.tonal(
|
|
||||||
onPressed: _loadMore,
|
|
||||||
child: const Text('Load more'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FeedFilter extends StatelessWidget {
|
|
||||||
final List<BriefingFeed> feeds;
|
|
||||||
final int? selectedFeedId;
|
|
||||||
final void Function(int? feedId) onChanged;
|
|
||||||
|
|
||||||
const _FeedFilter({
|
|
||||||
required this.feeds,
|
|
||||||
required this.selectedFeedId,
|
|
||||||
required this.onChanged,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Feed:',
|
|
||||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
DropdownButton<int?>(
|
|
||||||
value: selectedFeedId,
|
|
||||||
underline: const SizedBox.shrink(),
|
|
||||||
items: [
|
|
||||||
const DropdownMenuItem<int?>(
|
|
||||||
value: null,
|
|
||||||
child: Text('All feeds'),
|
|
||||||
),
|
|
||||||
...feeds.map(
|
|
||||||
(f) => DropdownMenuItem<int?>(
|
|
||||||
value: f.id,
|
|
||||||
child: Text(f.title),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onChanged: (v) => onChanged(v),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,11 +31,11 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
|
|||||||
final status = ref.read(authProvider);
|
final status = ref.read(authProvider);
|
||||||
final hasEverLoggedIn = ref.read(hasEverLoggedInProvider);
|
final hasEverLoggedIn = ref.read(hasEverLoggedInProvider);
|
||||||
if (status == AuthStatus.authenticated) {
|
if (status == AuthStatus.authenticated) {
|
||||||
context.go(Routes.briefing);
|
context.go(Routes.journal);
|
||||||
} else if (status == AuthStatus.offline && hasEverLoggedIn) {
|
} else if (status == AuthStatus.offline && hasEverLoggedIn) {
|
||||||
// Server unreachable but this user has logged in before — land them on
|
// Server unreachable but this user has logged in before — land them on
|
||||||
// the briefing with the offline banner rather than the login screen.
|
// the briefing with the offline banner rather than the login screen.
|
||||||
context.go(Routes.briefing);
|
context.go(Routes.journal);
|
||||||
} else {
|
} else {
|
||||||
context.go(Routes.login);
|
context.go(Routes.login);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,25 @@ import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
|||||||
|
|
||||||
import '../data/models/message.dart';
|
import '../data/models/message.dart';
|
||||||
|
|
||||||
class BriefingDigestCard extends StatefulWidget {
|
class JournalPrepCard extends StatefulWidget {
|
||||||
/// The first assistant message from today's briefing, or null if none yet.
|
/// The first assistant message from today's journal — the daily prep
|
||||||
|
/// (LLM-generated briefing-style opener), or null if not yet generated.
|
||||||
final Message? message;
|
final Message? message;
|
||||||
|
|
||||||
/// Called when the user taps "Generate now".
|
/// Called when the user taps "Generate now".
|
||||||
final VoidCallback? onGenerateNow;
|
final VoidCallback? onGenerateNow;
|
||||||
|
|
||||||
const BriefingDigestCard({
|
const JournalPrepCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.message,
|
required this.message,
|
||||||
this.onGenerateNow,
|
this.onGenerateNow,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BriefingDigestCard> createState() => _BriefingDigestCardState();
|
State<JournalPrepCard> createState() => _JournalPrepCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BriefingDigestCardState extends State<BriefingDigestCard> {
|
class _JournalPrepCardState extends State<JournalPrepCard> {
|
||||||
bool _expanded = false;
|
bool _expanded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -43,10 +44,9 @@ class _BriefingDigestCardState extends State<BriefingDigestCard> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Header row
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.wb_sunny_outlined, size: 18, color: scheme.primary),
|
Icon(Icons.menu_book_outlined, size: 18, color: scheme.primary),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
_todayLabel(),
|
_todayLabel(),
|
||||||
@@ -57,11 +57,9 @@ class _BriefingDigestCardState extends State<BriefingDigestCard> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
// Body
|
|
||||||
if (widget.message == null) ...[
|
if (widget.message == null) ...[
|
||||||
Text(
|
Text(
|
||||||
'No briefing yet today.',
|
'No prep yet today.',
|
||||||
style: textTheme.bodyMedium?.copyWith(
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
color: scheme.onSurfaceVariant,
|
color: scheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
|
|
||||||
import '../data/models/news_item.dart';
|
|
||||||
|
|
||||||
class RssItemMeta {
|
|
||||||
final int id;
|
|
||||||
final String title;
|
|
||||||
final String url;
|
|
||||||
final String source;
|
|
||||||
final String snippet;
|
|
||||||
final DateTime? publishedAt;
|
|
||||||
|
|
||||||
const RssItemMeta({
|
|
||||||
required this.id,
|
|
||||||
required this.title,
|
|
||||||
required this.url,
|
|
||||||
required this.source,
|
|
||||||
required this.snippet,
|
|
||||||
this.publishedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory RssItemMeta.fromJson(Map<String, dynamic> json) => RssItemMeta(
|
|
||||||
id: json['id'] as int,
|
|
||||||
title: json['title'] as String? ?? '',
|
|
||||||
url: json['url'] as String? ?? '',
|
|
||||||
source: json['source'] as String? ?? '',
|
|
||||||
snippet: json['snippet'] as String? ?? '',
|
|
||||||
publishedAt: json['published_at'] != null
|
|
||||||
? DateTime.tryParse(json['published_at'] as String)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
factory RssItemMeta.fromNewsItem(NewsItem item) => RssItemMeta(
|
|
||||||
id: item.id,
|
|
||||||
title: item.title,
|
|
||||||
url: item.url,
|
|
||||||
source: item.source,
|
|
||||||
snippet: item.snippet,
|
|
||||||
publishedAt: item.publishedAt,
|
|
||||||
);
|
|
||||||
|
|
||||||
String get relativeDate {
|
|
||||||
if (publishedAt == null) return '';
|
|
||||||
final diff = DateTime.now().difference(publishedAt!);
|
|
||||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
|
||||||
if (diff.inHours < 48) return 'Yesterday';
|
|
||||||
return '${publishedAt!.month}/${publishedAt!.day}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class NewsCard extends StatelessWidget {
|
|
||||||
final RssItemMeta item;
|
|
||||||
final String? reaction; // 'up' | 'down' | null
|
|
||||||
final void Function(int itemId, String reaction) onReaction;
|
|
||||||
final VoidCallback? onDiscuss;
|
|
||||||
final int snippetMaxLines;
|
|
||||||
|
|
||||||
const NewsCard({
|
|
||||||
super.key,
|
|
||||||
required this.item,
|
|
||||||
required this.reaction,
|
|
||||||
required this.onReaction,
|
|
||||||
this.onDiscuss,
|
|
||||||
this.snippetMaxLines = 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<void> _openUrl() async {
|
|
||||||
if (item.url.isEmpty) return;
|
|
||||||
final uri = Uri.tryParse(item.url);
|
|
||||||
if (uri != null && await canLaunchUrl(uri)) {
|
|
||||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
final textTheme = Theme.of(context).textTheme;
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
elevation: 0,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
side: BorderSide(color: scheme.outlineVariant),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Source + date row
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
if (item.source.isNotEmpty)
|
|
||||||
Text(
|
|
||||||
item.source.toUpperCase(),
|
|
||||||
style: textTheme.labelSmall?.copyWith(
|
|
||||||
color: scheme.primary,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: 0.6,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (item.source.isNotEmpty && item.relativeDate.isNotEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 8),
|
|
||||||
child: Text(
|
|
||||||
item.relativeDate,
|
|
||||||
style: textTheme.labelSmall?.copyWith(
|
|
||||||
color: scheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
// Title — tappable if URL present. Text stays in onSurface for
|
|
||||||
// contrast; the primary-colored underline carries the "this is a
|
|
||||||
// link" signal without tanking readability.
|
|
||||||
GestureDetector(
|
|
||||||
onTap: item.url.isNotEmpty ? _openUrl : null,
|
|
||||||
child: Text(
|
|
||||||
item.title,
|
|
||||||
style: textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: scheme.onSurface,
|
|
||||||
height: 1.3,
|
|
||||||
decoration: item.url.isNotEmpty ? TextDecoration.underline : null,
|
|
||||||
decorationColor: scheme.primary.withValues(alpha: 0.7),
|
|
||||||
decorationThickness: 1.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Snippet
|
|
||||||
if (item.snippet.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
item.snippet,
|
|
||||||
maxLines: snippetMaxLines,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: textTheme.bodySmall?.copyWith(
|
|
||||||
color: scheme.onSurfaceVariant,
|
|
||||||
height: 1.45,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
// Actions row: reactions + discuss
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
_ReactionButton(
|
|
||||||
emoji: '👍',
|
|
||||||
active: reaction == 'up',
|
|
||||||
onTap: () => onReaction(item.id, 'up'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
_ReactionButton(
|
|
||||||
emoji: '👎',
|
|
||||||
active: reaction == 'down',
|
|
||||||
onTap: () => onReaction(item.id, 'down'),
|
|
||||||
),
|
|
||||||
if (onDiscuss != null) ...[
|
|
||||||
const Spacer(),
|
|
||||||
_DiscussButton(onTap: onDiscuss!),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DiscussButton extends StatelessWidget {
|
|
||||||
final VoidCallback onTap;
|
|
||||||
const _DiscussButton({required this.onTap});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: scheme.primary.withValues(alpha: 0.5)),
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'Discuss',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: scheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ReactionButton extends StatelessWidget {
|
|
||||||
final String emoji;
|
|
||||||
final bool active;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
const _ReactionButton({
|
|
||||||
required this.emoji,
|
|
||||||
required this.active,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 150),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: active ? scheme.primary.withValues(alpha: 0.12) : Colors.transparent,
|
|
||||||
border: Border.all(
|
|
||||||
color: active ? scheme.primary : scheme.outlineVariant,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
child: Text(emoji, style: const TextStyle(fontSize: 14)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,6 @@ import 'package:fabled_app/data/api/voice_api.dart';
|
|||||||
import 'package:fabled_app/providers/voice_provider.dart';
|
import 'package:fabled_app/providers/voice_provider.dart';
|
||||||
import 'package:fabled_app/data/models/knowledge_item.dart';
|
import 'package:fabled_app/data/models/knowledge_item.dart';
|
||||||
import 'package:fabled_app/data/models/note.dart';
|
import 'package:fabled_app/data/models/note.dart';
|
||||||
import 'package:fabled_app/data/models/news_item.dart';
|
|
||||||
import 'package:fabled_app/data/models/briefing_feed.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -150,47 +148,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('NewsItem.fromJson', () {
|
|
||||||
test('parses all fields', () {
|
|
||||||
final json = {
|
|
||||||
'id': 42,
|
|
||||||
'title': 'Big news',
|
|
||||||
'url': 'https://example.com/article',
|
|
||||||
'snippet': 'A short summary.',
|
|
||||||
'source': 'Example News',
|
|
||||||
'published_at': '2026-01-15T10:00:00',
|
|
||||||
'topics': ['tech', 'ai'],
|
|
||||||
'reaction': 'up',
|
|
||||||
};
|
|
||||||
final item = NewsItem.fromJson(json);
|
|
||||||
expect(item.id, equals(42));
|
|
||||||
expect(item.title, equals('Big news'));
|
|
||||||
expect(item.url, equals('https://example.com/article'));
|
|
||||||
expect(item.snippet, equals('A short summary.'));
|
|
||||||
expect(item.source, equals('Example News'));
|
|
||||||
expect(item.publishedAt, equals(DateTime.parse('2026-01-15T10:00:00')));
|
|
||||||
expect(item.topics, equals(['tech', 'ai']));
|
|
||||||
expect(item.reaction, equals('up'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles null published_at and reaction', () {
|
|
||||||
final json = {
|
|
||||||
'id': 1,
|
|
||||||
'title': '',
|
|
||||||
'url': '',
|
|
||||||
'snippet': '',
|
|
||||||
'source': '',
|
|
||||||
'published_at': null,
|
|
||||||
'topics': <dynamic>[],
|
|
||||||
'reaction': null,
|
|
||||||
};
|
|
||||||
final item = NewsItem.fromJson(json);
|
|
||||||
expect(item.publishedAt, isNull);
|
|
||||||
expect(item.reaction, isNull);
|
|
||||||
expect(item.topics, isEmpty);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('CalendarEvent.fromJson', () {
|
group('CalendarEvent.fromJson', () {
|
||||||
test('parses all fields', () {
|
test('parses all fields', () {
|
||||||
final json = {
|
final json = {
|
||||||
@@ -250,30 +207,4 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('BriefingFeed.fromJson', () {
|
|
||||||
test('parses all fields', () {
|
|
||||||
final json = {
|
|
||||||
'id': 7,
|
|
||||||
'title': 'Hacker News',
|
|
||||||
'url': 'https://news.ycombinator.com/rss',
|
|
||||||
'category': 'tech',
|
|
||||||
};
|
|
||||||
final feed = BriefingFeed.fromJson(json);
|
|
||||||
expect(feed.id, equals(7));
|
|
||||||
expect(feed.title, equals('Hacker News'));
|
|
||||||
expect(feed.url, equals('https://news.ycombinator.com/rss'));
|
|
||||||
expect(feed.category, equals('tech'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles null category', () {
|
|
||||||
final json = {
|
|
||||||
'id': 8,
|
|
||||||
'title': 'Feed',
|
|
||||||
'url': 'https://example.com/rss',
|
|
||||||
'category': null,
|
|
||||||
};
|
|
||||||
final feed = BriefingFeed.fromJson(json);
|
|
||||||
expect(feed.category, isNull);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user