diff --git a/lib/app.dart b/lib/app.dart index 135d78f..2164a41 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:flutter_timezone/flutter_timezone.dart'; + import 'core/constants.dart'; import 'core/exceptions.dart'; import 'core/theme.dart'; @@ -159,19 +161,29 @@ class _ShellState extends ConsumerState<_Shell> { @override void initState() { super.initState(); - // Silent update check — only if we haven't already checked this session. - // Skipping when status is not idle/error prevents the dialog from - // re-appearing every time the shell re-mounts (e.g. after visiting settings). WidgetsBinding.instance.addPostFrameCallback((_) { + // Silent update check — only if we haven't already checked this session. final repoUrl = ref.read(forgejoRepoUrlProvider); - if (repoUrl == null || repoUrl.isEmpty) return; - final status = ref.read(updateProvider).status; - if (status == UpdateStatus.idle || status == UpdateStatus.error) { - ref.read(updateProvider.notifier).check(repoUrl); + if (repoUrl != null && repoUrl.isNotEmpty) { + final status = ref.read(updateProvider).status; + if (status == UpdateStatus.idle || status == UpdateStatus.error) { + ref.read(updateProvider.notifier).check(repoUrl); + } } + // Sync device timezone to backend so briefing and chat use local time. + _syncTimezone(); }); } + Future _syncTimezone() async { + try { + final tzInfo = await FlutterTimezone.getLocalTimezone(); + await ref.read(settingsApiProvider).syncTimezone(tzInfo.identifier); + } catch (_) { + // Best-effort — failure is non-critical. + } + } + int _tabIndex(String location) { for (var i = 0; i < _tabs.length; i++) { if (location.startsWith(_tabs[i])) return i; diff --git a/lib/data/api/briefing_api.dart b/lib/data/api/briefing_api.dart index c39df8e..e28b600 100644 --- a/lib/data/api/briefing_api.dart +++ b/lib/data/api/briefing_api.dart @@ -59,4 +59,23 @@ class BriefingApi { throw dioToApp(e); } } + + /// POST /api/briefing/rss-reactions body: {rss_item_id, reaction: "up"|"down"} + Future 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); + } + } + + /// DELETE /api/briefing/rss-reactions/{rssItemId} + Future deleteRssReaction(int rssItemId) async { + try { + await _dio.delete('/api/briefing/rss-reactions/$rssItemId'); + } on DioException catch (e) { + throw dioToApp(e); + } + } } diff --git a/lib/data/api/settings_api.dart b/lib/data/api/settings_api.dart new file mode 100644 index 0000000..8d30c5f --- /dev/null +++ b/lib/data/api/settings_api.dart @@ -0,0 +1,13 @@ +import 'package:dio/dio.dart'; + +class SettingsApi { + const SettingsApi(this._dio); + final Dio _dio; + + Future syncTimezone(String ianaTimezone) async { + await _dio.put( + '/api/settings', + data: {'user_timezone': ianaTimezone}, + ); + } +} diff --git a/lib/data/models/message.dart b/lib/data/models/message.dart index 3df0a21..be48d10 100644 --- a/lib/data/models/message.dart +++ b/lib/data/models/message.dart @@ -7,6 +7,7 @@ class Message { final String content; final String status; // "complete" | "generating" final DateTime? createdAt; + final Map? metadata; const Message({ this.id, @@ -15,6 +16,7 @@ class Message { required this.content, this.status = 'complete', this.createdAt, + this.metadata, }); factory Message.fromJson(Map json) => Message( @@ -26,6 +28,7 @@ class Message { createdAt: json['created_at'] != null ? DateTime.parse(json['created_at'] as String) : null, + metadata: json['metadata'] as Map?, ); Message copyWith({String? content, String? status}) => Message( @@ -35,5 +38,6 @@ class Message { content: content ?? this.content, status: status ?? this.status, createdAt: createdAt, + metadata: metadata, ); } diff --git a/lib/providers/api_client_provider.dart b/lib/providers/api_client_provider.dart index de7be00..2aba0ce 100644 --- a/lib/providers/api_client_provider.dart +++ b/lib/providers/api_client_provider.dart @@ -10,6 +10,7 @@ import '../data/api/milestones_api.dart'; import '../data/api/notes_api.dart'; import '../data/api/projects_api.dart'; import '../data/api/quick_capture_api.dart'; +import '../data/api/settings_api.dart'; import '../data/api/tasks_api.dart'; import '../data/repositories/auth_repository.dart'; import '../data/repositories/chat_repository.dart'; @@ -85,3 +86,7 @@ final milestonesRepositoryProvider = Provider((ref) { final briefingApiProvider = Provider((ref) { return BriefingApi(ref.watch(dioProvider)); }); + +final settingsApiProvider = Provider((ref) { + return SettingsApi(ref.watch(dioProvider)); +}); diff --git a/lib/providers/briefing_provider.dart b/lib/providers/briefing_provider.dart index 40786f1..42d76be 100644 --- a/lib/providers/briefing_provider.dart +++ b/lib/providers/briefing_provider.dart @@ -23,6 +23,24 @@ class BriefingNotifier extends AsyncNotifier { return ref.read(briefingApiProvider).getToday(); } + /// Silently fetch the latest briefing and patch state without triggering + /// AsyncLoading — existing content stays visible while the fetch is in flight. + Future silentRefresh() async { + final current = state.value; + if (current == null) return; + try { + final fresh = await ref.read(briefingApiProvider).getToday(); + final curLast = current.messages.isNotEmpty ? current.messages.last : null; + final newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null; + if (fresh.messages.length != current.messages.length || + newLast?.content != curLast?.content) { + state = AsyncData(fresh); + } + } catch (_) { + // Network hiccup — silently ignore, keep existing content + } + } + /// Trigger a briefing slot (e.g. "compilation") then reload. Future refresh(String slot) async { await ref.read(briefingApiProvider).triggerSlot(slot); diff --git a/lib/screens/briefing/briefing_screen.dart b/lib/screens/briefing/briefing_screen.dart index 23abd5b..dd36df0 100644 --- a/lib/screens/briefing/briefing_screen.dart +++ b/lib/screens/briefing/briefing_screen.dart @@ -1,9 +1,14 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/exceptions.dart'; +import '../../data/models/message.dart'; import '../../providers/briefing_provider.dart'; +import '../../providers/api_client_provider.dart'; import '../../widgets/chat_message_bubble.dart'; +import '../../widgets/weather_card.dart'; import 'briefing_history_screen.dart'; class BriefingScreen extends ConsumerStatefulWidget { @@ -13,13 +18,40 @@ class BriefingScreen extends ConsumerStatefulWidget { ConsumerState createState() => _BriefingScreenState(); } -class _BriefingScreenState extends ConsumerState { +class _BriefingScreenState extends ConsumerState + with WidgetsBindingObserver { final _controller = TextEditingController(); final _scrollController = ScrollController(); bool _refreshing = false; + // rss_item_id -> 'up' | 'down' | null + final Map _reactions = {}; + + Timer? _pollTimer; + bool _appInForeground = true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _pollTimer = Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently()); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _appInForeground = state == AppLifecycleState.resumed; + } + + void _pollSilently() { + if (!_appInForeground || !mounted) return; + final isStreaming = ref.read(isBriefingStreamingProvider); + if (isStreaming) return; + ref.read(briefingProvider.notifier).silentRefresh(); + } @override void dispose() { + _pollTimer?.cancel(); + WidgetsBinding.instance.removeObserver(this); _controller.dispose(); _scrollController.dispose(); super.dispose(); @@ -57,6 +89,22 @@ class _BriefingScreenState extends ConsumerState { } } + Future _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 _refresh() async { setState(() => _refreshing = true); try { @@ -178,8 +226,14 @@ class _BriefingScreenState extends ConsumerState { horizontal: 8, vertical: 8), sliver: SliverList.builder( itemCount: conv.messages.length, - itemBuilder: (_, i) => - ChatMessageBubble(message: conv.messages[i]), + itemBuilder: (_, i) { + final msg = conv.messages[i]; + return _BriefingMessageItem( + message: msg, + reactions: _reactions, + onReaction: _handleReaction, + ); + }, ), ), ], @@ -246,6 +300,118 @@ class _BriefingScreenState extends ConsumerState { } } +/// Renders a single briefing message with optional WeatherCard above it +/// and RSS reaction buttons below it (for assistant messages with metadata). +class _BriefingMessageItem extends StatelessWidget { + final Message message; + final Map reactions; + final void Function(int itemId, String reaction) onReaction; + + const _BriefingMessageItem({ + required this.message, + required this.reactions, + required this.onReaction, + }); + + @override + Widget build(BuildContext context) { + final meta = message.metadata; + final isAssistant = message.role == MessageRole.assistant; + + // Weather: show card above when metadata.weather key is present (even if null value) + final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather'); + final weatherData = hasWeatherKey ? meta['weather'] as Map? : null; + + // RSS reactions + final rssItemIds = isAssistant && meta != null + ? (meta['rss_item_ids'] as List?)?.cast() ?? [] + : []; + + final scheme = Theme.of(context).colorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (hasWeatherKey) WeatherCard(weather: weatherData), + ChatMessageBubble(message: message), + if (rssItemIds.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 8, bottom: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: rssItemIds.asMap().entries.map((entry) { + final index = entry.key; + final itemId = entry.value; + final current = reactions[itemId]; + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Story ${index + 1}', + style: TextStyle( + fontSize: 12, + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + _ReactionButton( + emoji: '👍', + active: current == 'up', + onTap: () => onReaction(itemId, 'up'), + ), + const SizedBox(width: 4), + _ReactionButton( + emoji: '👎', + active: current == 'down', + onTap: () => onReaction(itemId, 'down'), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ); + } +} + +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)), + ), + ); + } +} + class _GradientSendButton extends StatelessWidget { final VoidCallback? onPressed; final bool isStreaming; diff --git a/lib/widgets/weather_card.dart b/lib/widgets/weather_card.dart new file mode 100644 index 0000000..93cf8a8 --- /dev/null +++ b/lib/widgets/weather_card.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; + +class WeatherCard extends StatelessWidget { + final Map? weather; + + const WeatherCard({super.key, required this.weather}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + if (weather == null) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: scheme.outlineVariant), + ), + child: Text( + 'Weather data unavailable — will retry at next slot.', + style: TextStyle( + color: scheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + fontSize: 13, + ), + ), + ); + } + + final w = weather!; + final location = w['location'] as String? ?? ''; + final currentTemp = w['current_temp']; + final condition = w['condition'] as String? ?? ''; + final todayHigh = w['today_high']; + final todayLow = w['today_low']; + final yesterdayHigh = w['yesterday_high']; + final fetchedAt = w['fetched_at'] as String?; + final forecast = (w['forecast'] as List? ?? []) + .cast>(); + + String? tempDelta; + if (todayHigh != null && yesterdayHigh != null) { + final diff = (todayHigh as num) - (yesterdayHigh as num); + if (diff.abs() < 1) { + tempDelta = 'Same as yesterday'; + } else { + final dir = diff > 0 ? 'warmer' : 'cooler'; + tempDelta = '${diff.abs().round()}° $dir than yesterday'; + } + } + + String? fetchedLabel; + if (fetchedAt != null) { + try { + final dt = DateTime.parse(fetchedAt).toLocal(); + final h = dt.hour % 12 == 0 ? 12 : dt.hour % 12; + final m = dt.minute.toString().padLeft(2, '0'); + final period = dt.hour < 12 ? 'AM' : 'PM'; + fetchedLabel = '$h:$m $period'; + } catch (_) {} + } + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: scheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: location + fetched time + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + location, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: scheme.onSurface, + ), + ), + if (fetchedLabel != null) + Text( + 'as of $fetchedLabel', + style: TextStyle( + fontSize: 12, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 8), + // Current temp + condition + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '$currentTemp°', + style: TextStyle( + fontSize: 36, + fontWeight: FontWeight.w700, + color: scheme.onSurface, + height: 1, + ), + ), + const SizedBox(width: 10), + Text( + condition, + style: TextStyle( + fontSize: 14, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + // Today high/low + delta + if (todayHigh != null) ...[ + const SizedBox(height: 8), + Text( + 'Today: $todayHigh° / $todayLow°' + '${tempDelta != null ? ' · $tempDelta' : ''}', + style: TextStyle(fontSize: 13, color: scheme.onSurfaceVariant), + ), + ], + // Forecast strip + if (forecast.isNotEmpty) ...[ + const SizedBox(height: 12), + const Divider(height: 1), + const SizedBox(height: 12), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + spacing: 8, + children: forecast.map((day) { + return SizedBox( + width: 64, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + day['day'] as String? ?? '', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), + ), + const SizedBox(height: 3), + Text( + day['condition'] as String? ?? '', + style: TextStyle( + fontSize: 11, + color: scheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 3), + Text( + '${day['high']}° / ${day['low']}°', + style: TextStyle( + fontSize: 12, + color: scheme.onSurface, + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 308afa8..bfc5ee8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -169,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" fake_async: dependency: transitive description: @@ -299,6 +307,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_timezone: + dependency: "direct main" + description: + name: flutter_timezone + sha256: e8d63f50f2806a3a71a08697286a0369e1d8f0902961327810459871c0bb01c2 + url: "https://pub.dev" + source: hosted + version: "5.0.2" flutter_web_plugins: dependency: transitive description: flutter @@ -640,6 +656,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3830495..50c50e3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: flutter_inappwebview: ^6.1.5 flutter_markdown_plus: ^1.0.7 google_fonts: ^8.0.2 + flutter_timezone: ^5.0.2 dev_dependencies: flutter_test: