diff --git a/app/lib/core/routing/app_router.dart b/app/lib/core/routing/app_router.dart index 27785692..5de44c5b 100644 --- a/app/lib/core/routing/app_router.dart +++ b/app/lib/core/routing/app_router.dart @@ -160,7 +160,9 @@ class AppRouter { GoRoute( path: 'chat', name: 'mind_chat', - builder: (context, state) => const ChatScreen(), + builder: (context, state) => ChatScreen( + initialDraft: state.uri.queryParameters['prefill'], + ), ), GoRoute( path: 'notifications', diff --git a/app/lib/features/agent_chat/presentation/screens/chat_screen.dart b/app/lib/features/agent_chat/presentation/screens/chat_screen.dart index a2b3085d..992af20d 100644 --- a/app/lib/features/agent_chat/presentation/screens/chat_screen.dart +++ b/app/lib/features/agent_chat/presentation/screens/chat_screen.dart @@ -59,6 +59,7 @@ class ChatScreen extends ConsumerStatefulWidget { this.skillOrchestrator, this.enableAiInitialization = true, this.initialMessages, + this.initialDraft, }); final AssistantRuntimeService? assistantRuntimeService; @@ -66,6 +67,7 @@ class ChatScreen extends ConsumerStatefulWidget { final AgentSkillOrchestrator? skillOrchestrator; final bool enableAiInitialization; final List? initialMessages; + final String? initialDraft; @override ConsumerState createState() => _ChatScreenState(); @@ -100,7 +102,8 @@ class _ChatScreenState extends ConsumerState { @override void initState() { super.initState(); - _messageController = TextEditingController(); + _messageController = TextEditingController(text: widget.initialDraft ?? ''); + _moveComposerCursorToEnd(); _assistantRuntime = widget.assistantRuntimeService ?? AssistantRuntimeService(geminiNano: _geminiNano, liteRtLm: _liteRtLm); @@ -243,6 +246,12 @@ class _ChatScreenState extends ConsumerState { super.dispose(); } + void _moveComposerCursorToEnd() { + _messageController.selection = TextSelection.collapsed( + offset: _messageController.text.length, + ); + } + @override Widget build(BuildContext context) { final selectedAssistantModelId = ref.watch( diff --git a/app/lib/features/agent_chat/presentation/screens/notifications_screen.dart b/app/lib/features/agent_chat/presentation/screens/notifications_screen.dart index 4d7f77fd..1520a3e6 100644 --- a/app/lib/features/agent_chat/presentation/screens/notifications_screen.dart +++ b/app/lib/features/agent_chat/presentation/screens/notifications_screen.dart @@ -4,20 +4,22 @@ import 'package:go_router/go_router.dart'; import '../../data/services/agent_notification_scheduler.dart'; class NotificationsScreen extends StatefulWidget { - const NotificationsScreen({super.key}); + const NotificationsScreen({super.key, this._scheduler}); + + final AgentNotificationSchedulingService? _scheduler; @override State createState() => _NotificationsScreenState(); } class _NotificationsScreenState extends State { - final LocalAgentNotificationScheduler _scheduler = - LocalAgentNotificationScheduler.instance; + late final AgentNotificationSchedulingService _scheduler; late Future> _notificationsFuture; @override void initState() { super.initState(); + _scheduler = widget._scheduler ?? LocalAgentNotificationScheduler.instance; _notificationsFuture = _scheduler.getScheduledNotifications(); } @@ -79,6 +81,7 @@ class _NotificationsScreenState extends State { notification: notifications[index], onDelete: () => _deleteNotification(notifications[index].id), onComplete: () => _completeNotification(notifications[index].id), + onOpenInChat: () => _openNotificationInChat(notifications[index]), ); }, ); @@ -99,6 +102,15 @@ class _NotificationsScreenState extends State { _notificationsFuture = _scheduler.getScheduledNotifications(); }); } + + void _openNotificationInChat(ScheduledAgentNotification notification) { + final prompt = buildNotificationChatPrefill(notification); + final uri = Uri( + path: '/mind/chat', + queryParameters: prompt.isEmpty ? null : {'prefill': prompt}, + ); + context.push(uri.toString()); + } } class _NotificationCard extends StatelessWidget { @@ -106,11 +118,13 @@ class _NotificationCard extends StatelessWidget { required this.notification, required this.onDelete, required this.onComplete, + required this.onOpenInChat, }); final ScheduledAgentNotification notification; final VoidCallback onDelete; final VoidCallback onComplete; + final VoidCallback onOpenInChat; @override Widget build(BuildContext context) { @@ -176,6 +190,12 @@ class _NotificationCard extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.end, children: [ + OutlinedButton.icon( + onPressed: onOpenInChat, + icon: const Icon(Icons.chat_bubble_outline, size: 18), + label: const Text('Open in chat'), + ), + const SizedBox(width: 8), OutlinedButton.icon( onPressed: onDelete, icon: const Icon(Icons.delete_outline, size: 18), @@ -222,3 +242,25 @@ bool _completedToday(ScheduledAgentNotification notification) { '${now.day.toString().padLeft(2, '0')}'; return notification.completedDates.contains(today); } + +String buildNotificationChatPrefill(ScheduledAgentNotification notification) { + final parts = [ + notification.title.trim(), + notification.message.trim(), + ].where((part) => part.isNotEmpty).toList(); + + final context = [ + if (notification.repeatDaily) + 'daily at ${_formatTime(notification.hour, notification.minute)}' + else if (notification.date != null && notification.date!.isNotEmpty) + 'on ${notification.date} at ${_formatTime(notification.hour, notification.minute)}' + else + 'at ${_formatTime(notification.hour, notification.minute)}', + ]; + + if (parts.isEmpty) { + return ''; + } + + return 'Help me with this reminder: ${parts.join(' - ')} (${context.join(', ')}).'; +} diff --git a/app/test/features/agent_chat/presentation/screens/chat_screen_metadata_test.dart b/app/test/features/agent_chat/presentation/screens/chat_screen_metadata_test.dart index 5189df2a..ece14b5b 100644 --- a/app/test/features/agent_chat/presentation/screens/chat_screen_metadata_test.dart +++ b/app/test/features/agent_chat/presentation/screens/chat_screen_metadata_test.dart @@ -162,11 +162,27 @@ void main() { expect(find.byKey(const Key('agent_chat_metadata_button')), findsNothing); }, ); + + testWidgets('chat screen prefills the composer draft when provided', ( + tester, + ) async { + await _pumpChatScreen( + tester, + initialMessages: [ChatMessage(text: 'Hello from Airo', isUser: false)], + initialDraft: 'Follow up on my reminder', + ); + + final input = tester.widget( + find.byKey(const Key('agent_chat_input')), + ); + expect(input.controller?.text, 'Follow up on my reminder'); + }); } Future _pumpChatScreen( WidgetTester tester, { required List initialMessages, + String? initialDraft, }) async { tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(1200, 1000); @@ -190,6 +206,7 @@ Future _pumpChatScreen( home: ChatScreen( enableAiInitialization: false, initialMessages: initialMessages, + initialDraft: initialDraft, ), ), ), diff --git a/app/test/features/agent_chat/presentation/screens/notifications_screen_test.dart b/app/test/features/agent_chat/presentation/screens/notifications_screen_test.dart new file mode 100644 index 00000000..7a3f62e4 --- /dev/null +++ b/app/test/features/agent_chat/presentation/screens/notifications_screen_test.dart @@ -0,0 +1,134 @@ +import 'package:airo_app/features/agent_chat/application/assistant_model_preferences.dart'; +import 'package:airo_app/features/agent_chat/data/services/agent_notification_scheduler.dart'; +import 'package:airo_app/features/agent_chat/domain/models/assistant_runtime_ids.dart'; +import 'package:airo_app/features/agent_chat/presentation/screens/chat_screen.dart'; +import 'package:airo_app/features/agent_chat/presentation/screens/notifications_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + test('buildNotificationChatPrefill includes reminder context', () { + final notification = ScheduledAgentNotification( + id: 1, + title: 'Medicine reminder', + message: 'Take vitamin D', + hour: 8, + minute: 30, + repeatDaily: true, + scheduledAt: DateTime(2026, 6, 29, 8, 30), + createdAt: DateTime(2026, 6, 29, 7, 0), + ); + + expect( + buildNotificationChatPrefill(notification), + 'Help me with this reminder: Medicine reminder - Take vitamin D (daily at 8:30 AM).', + ); + }); + + testWidgets('notification card opens chat with a prefilled composer', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({ + 'selected_assistant_model_id': geminiNanoAssistantModelId, + }); + + final router = GoRouter( + initialLocation: '/mind/notifications', + routes: [ + GoRoute( + path: '/mind/notifications', + builder: (context, state) => NotificationsScreen( + scheduler: _FakeNotificationScheduler( + notifications: [ + ScheduledAgentNotification( + id: 1, + title: 'Pay rent', + message: 'Check July invoice before paying.', + hour: 9, + minute: 0, + repeatDaily: false, + date: '2026-07-01', + scheduledAt: DateTime(2026, 7, 1, 9), + createdAt: DateTime(2026, 6, 29, 12), + ), + ], + ), + ), + ), + GoRoute( + path: '/mind/chat', + builder: (context, state) => ChatScreen( + enableAiInitialization: false, + initialDraft: state.uri.queryParameters['prefill'], + ), + ), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + selectedAssistantModelIdProvider.overrideWith( + (ref) => _SelectedAssistantModelNotifier(), + ), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Pay rent'), findsOneWidget); + await tester.tap(find.text('Open in chat')); + await tester.pumpAndSettle(); + + final input = tester.widget( + find.byKey(const Key('agent_chat_input')), + ); + expect( + input.controller?.text, + 'Help me with this reminder: Pay rent - Check July invoice before paying. (on 2026-07-01 at 9:00 AM).', + ); + }); +} + +class _SelectedAssistantModelNotifier extends SelectedAssistantModelNotifier { + _SelectedAssistantModelNotifier() { + state = geminiNanoAssistantModelId; + } +} + +class _FakeNotificationScheduler implements AgentNotificationSchedulingService { + _FakeNotificationScheduler({required this._notifications}); + + final List _notifications; + + @override + Future cancelNotification(int id) async { + _notifications.removeWhere((notification) => notification.id == id); + } + + @override + Future> getScheduledNotifications() async { + return List.from(_notifications); + } + + @override + Future markNotificationComplete(int id) async { + for (final notification in _notifications) { + if (notification.id == id) { + return notification; + } + } + return null; + } + + @override + Future scheduleNotification( + ScheduleAgentNotificationRequest request, + ) { + throw UnimplementedError(); + } +}