Skip to content

Commit 5fe5e91

Browse files
ucguy4uuday chauhan
andauthored
fix(agent-chat): prefill chat from reminder cards (#541)
- add an Open in chat action for scheduled reminder cards - pass reminder context into Mind chat through the route prefill contract - cover composer prefill and notification navigation with widget tests Delivers the in-app notification deep-link slice for issue #281 while keeping the broader skill-pack issue open for remaining work. Co-authored-by: uday chauhan <udaychauhan@udays-MacBook-Air-2.local>
1 parent f0c8b3d commit 5fe5e91

5 files changed

Lines changed: 209 additions & 5 deletions

File tree

app/lib/core/routing/app_router.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,9 @@ class AppRouter {
160160
GoRoute(
161161
path: 'chat',
162162
name: 'mind_chat',
163-
builder: (context, state) => const ChatScreen(),
163+
builder: (context, state) => ChatScreen(
164+
initialDraft: state.uri.queryParameters['prefill'],
165+
),
164166
),
165167
GoRoute(
166168
path: 'notifications',

app/lib/features/agent_chat/presentation/screens/chat_screen.dart

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,15 @@ class ChatScreen extends ConsumerStatefulWidget {
5959
this.skillOrchestrator,
6060
this.enableAiInitialization = true,
6161
this.initialMessages,
62+
this.initialDraft,
6263
});
6364

6465
final AssistantRuntimeService? assistantRuntimeService;
6566
final LocalRuntimePreloaderService? localRuntimePreloader;
6667
final AgentSkillOrchestrator? skillOrchestrator;
6768
final bool enableAiInitialization;
6869
final List<ChatMessage>? initialMessages;
70+
final String? initialDraft;
6971

7072
@override
7173
ConsumerState<ChatScreen> createState() => _ChatScreenState();
@@ -100,7 +102,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
100102
@override
101103
void initState() {
102104
super.initState();
103-
_messageController = TextEditingController();
105+
_messageController = TextEditingController(text: widget.initialDraft ?? '');
106+
_moveComposerCursorToEnd();
104107
_assistantRuntime =
105108
widget.assistantRuntimeService ??
106109
AssistantRuntimeService(geminiNano: _geminiNano, liteRtLm: _liteRtLm);
@@ -243,6 +246,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
243246
super.dispose();
244247
}
245248

249+
void _moveComposerCursorToEnd() {
250+
_messageController.selection = TextSelection.collapsed(
251+
offset: _messageController.text.length,
252+
);
253+
}
254+
246255
@override
247256
Widget build(BuildContext context) {
248257
final selectedAssistantModelId = ref.watch(

app/lib/features/agent_chat/presentation/screens/notifications_screen.dart

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,22 @@ import 'package:go_router/go_router.dart';
44
import '../../data/services/agent_notification_scheduler.dart';
55

66
class NotificationsScreen extends StatefulWidget {
7-
const NotificationsScreen({super.key});
7+
const NotificationsScreen({super.key, this._scheduler});
8+
9+
final AgentNotificationSchedulingService? _scheduler;
810

911
@override
1012
State<NotificationsScreen> createState() => _NotificationsScreenState();
1113
}
1214

1315
class _NotificationsScreenState extends State<NotificationsScreen> {
14-
final LocalAgentNotificationScheduler _scheduler =
15-
LocalAgentNotificationScheduler.instance;
16+
late final AgentNotificationSchedulingService _scheduler;
1617
late Future<List<ScheduledAgentNotification>> _notificationsFuture;
1718

1819
@override
1920
void initState() {
2021
super.initState();
22+
_scheduler = widget._scheduler ?? LocalAgentNotificationScheduler.instance;
2123
_notificationsFuture = _scheduler.getScheduledNotifications();
2224
}
2325

@@ -79,6 +81,7 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
7981
notification: notifications[index],
8082
onDelete: () => _deleteNotification(notifications[index].id),
8183
onComplete: () => _completeNotification(notifications[index].id),
84+
onOpenInChat: () => _openNotificationInChat(notifications[index]),
8285
);
8386
},
8487
);
@@ -99,18 +102,29 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
99102
_notificationsFuture = _scheduler.getScheduledNotifications();
100103
});
101104
}
105+
106+
void _openNotificationInChat(ScheduledAgentNotification notification) {
107+
final prompt = buildNotificationChatPrefill(notification);
108+
final uri = Uri(
109+
path: '/mind/chat',
110+
queryParameters: prompt.isEmpty ? null : {'prefill': prompt},
111+
);
112+
context.push(uri.toString());
113+
}
102114
}
103115

104116
class _NotificationCard extends StatelessWidget {
105117
const _NotificationCard({
106118
required this.notification,
107119
required this.onDelete,
108120
required this.onComplete,
121+
required this.onOpenInChat,
109122
});
110123

111124
final ScheduledAgentNotification notification;
112125
final VoidCallback onDelete;
113126
final VoidCallback onComplete;
127+
final VoidCallback onOpenInChat;
114128

115129
@override
116130
Widget build(BuildContext context) {
@@ -176,6 +190,12 @@ class _NotificationCard extends StatelessWidget {
176190
Row(
177191
mainAxisAlignment: MainAxisAlignment.end,
178192
children: [
193+
OutlinedButton.icon(
194+
onPressed: onOpenInChat,
195+
icon: const Icon(Icons.chat_bubble_outline, size: 18),
196+
label: const Text('Open in chat'),
197+
),
198+
const SizedBox(width: 8),
179199
OutlinedButton.icon(
180200
onPressed: onDelete,
181201
icon: const Icon(Icons.delete_outline, size: 18),
@@ -222,3 +242,25 @@ bool _completedToday(ScheduledAgentNotification notification) {
222242
'${now.day.toString().padLeft(2, '0')}';
223243
return notification.completedDates.contains(today);
224244
}
245+
246+
String buildNotificationChatPrefill(ScheduledAgentNotification notification) {
247+
final parts = <String>[
248+
notification.title.trim(),
249+
notification.message.trim(),
250+
].where((part) => part.isNotEmpty).toList();
251+
252+
final context = <String>[
253+
if (notification.repeatDaily)
254+
'daily at ${_formatTime(notification.hour, notification.minute)}'
255+
else if (notification.date != null && notification.date!.isNotEmpty)
256+
'on ${notification.date} at ${_formatTime(notification.hour, notification.minute)}'
257+
else
258+
'at ${_formatTime(notification.hour, notification.minute)}',
259+
];
260+
261+
if (parts.isEmpty) {
262+
return '';
263+
}
264+
265+
return 'Help me with this reminder: ${parts.join(' - ')} (${context.join(', ')}).';
266+
}

app/test/features/agent_chat/presentation/screens/chat_screen_metadata_test.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,27 @@ void main() {
162162
expect(find.byKey(const Key('agent_chat_metadata_button')), findsNothing);
163163
},
164164
);
165+
166+
testWidgets('chat screen prefills the composer draft when provided', (
167+
tester,
168+
) async {
169+
await _pumpChatScreen(
170+
tester,
171+
initialMessages: [ChatMessage(text: 'Hello from Airo', isUser: false)],
172+
initialDraft: 'Follow up on my reminder',
173+
);
174+
175+
final input = tester.widget<TextField>(
176+
find.byKey(const Key('agent_chat_input')),
177+
);
178+
expect(input.controller?.text, 'Follow up on my reminder');
179+
});
165180
}
166181

167182
Future<void> _pumpChatScreen(
168183
WidgetTester tester, {
169184
required List<ChatMessage> initialMessages,
185+
String? initialDraft,
170186
}) async {
171187
tester.view.devicePixelRatio = 1.0;
172188
tester.view.physicalSize = const Size(1200, 1000);
@@ -190,6 +206,7 @@ Future<void> _pumpChatScreen(
190206
home: ChatScreen(
191207
enableAiInitialization: false,
192208
initialMessages: initialMessages,
209+
initialDraft: initialDraft,
193210
),
194211
),
195212
),
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import 'package:airo_app/features/agent_chat/application/assistant_model_preferences.dart';
2+
import 'package:airo_app/features/agent_chat/data/services/agent_notification_scheduler.dart';
3+
import 'package:airo_app/features/agent_chat/domain/models/assistant_runtime_ids.dart';
4+
import 'package:airo_app/features/agent_chat/presentation/screens/chat_screen.dart';
5+
import 'package:airo_app/features/agent_chat/presentation/screens/notifications_screen.dart';
6+
import 'package:flutter/material.dart';
7+
import 'package:flutter_riverpod/flutter_riverpod.dart';
8+
import 'package:flutter_test/flutter_test.dart';
9+
import 'package:go_router/go_router.dart';
10+
import 'package:shared_preferences/shared_preferences.dart';
11+
12+
void main() {
13+
test('buildNotificationChatPrefill includes reminder context', () {
14+
final notification = ScheduledAgentNotification(
15+
id: 1,
16+
title: 'Medicine reminder',
17+
message: 'Take vitamin D',
18+
hour: 8,
19+
minute: 30,
20+
repeatDaily: true,
21+
scheduledAt: DateTime(2026, 6, 29, 8, 30),
22+
createdAt: DateTime(2026, 6, 29, 7, 0),
23+
);
24+
25+
expect(
26+
buildNotificationChatPrefill(notification),
27+
'Help me with this reminder: Medicine reminder - Take vitamin D (daily at 8:30 AM).',
28+
);
29+
});
30+
31+
testWidgets('notification card opens chat with a prefilled composer', (
32+
tester,
33+
) async {
34+
SharedPreferences.setMockInitialValues({
35+
'selected_assistant_model_id': geminiNanoAssistantModelId,
36+
});
37+
38+
final router = GoRouter(
39+
initialLocation: '/mind/notifications',
40+
routes: [
41+
GoRoute(
42+
path: '/mind/notifications',
43+
builder: (context, state) => NotificationsScreen(
44+
scheduler: _FakeNotificationScheduler(
45+
notifications: [
46+
ScheduledAgentNotification(
47+
id: 1,
48+
title: 'Pay rent',
49+
message: 'Check July invoice before paying.',
50+
hour: 9,
51+
minute: 0,
52+
repeatDaily: false,
53+
date: '2026-07-01',
54+
scheduledAt: DateTime(2026, 7, 1, 9),
55+
createdAt: DateTime(2026, 6, 29, 12),
56+
),
57+
],
58+
),
59+
),
60+
),
61+
GoRoute(
62+
path: '/mind/chat',
63+
builder: (context, state) => ChatScreen(
64+
enableAiInitialization: false,
65+
initialDraft: state.uri.queryParameters['prefill'],
66+
),
67+
),
68+
],
69+
);
70+
71+
await tester.pumpWidget(
72+
ProviderScope(
73+
overrides: [
74+
selectedAssistantModelIdProvider.overrideWith(
75+
(ref) => _SelectedAssistantModelNotifier(),
76+
),
77+
],
78+
child: MaterialApp.router(routerConfig: router),
79+
),
80+
);
81+
await tester.pumpAndSettle();
82+
83+
expect(find.text('Pay rent'), findsOneWidget);
84+
await tester.tap(find.text('Open in chat'));
85+
await tester.pumpAndSettle();
86+
87+
final input = tester.widget<TextField>(
88+
find.byKey(const Key('agent_chat_input')),
89+
);
90+
expect(
91+
input.controller?.text,
92+
'Help me with this reminder: Pay rent - Check July invoice before paying. (on 2026-07-01 at 9:00 AM).',
93+
);
94+
});
95+
}
96+
97+
class _SelectedAssistantModelNotifier extends SelectedAssistantModelNotifier {
98+
_SelectedAssistantModelNotifier() {
99+
state = geminiNanoAssistantModelId;
100+
}
101+
}
102+
103+
class _FakeNotificationScheduler implements AgentNotificationSchedulingService {
104+
_FakeNotificationScheduler({required this._notifications});
105+
106+
final List<ScheduledAgentNotification> _notifications;
107+
108+
@override
109+
Future<void> cancelNotification(int id) async {
110+
_notifications.removeWhere((notification) => notification.id == id);
111+
}
112+
113+
@override
114+
Future<List<ScheduledAgentNotification>> getScheduledNotifications() async {
115+
return List<ScheduledAgentNotification>.from(_notifications);
116+
}
117+
118+
@override
119+
Future<ScheduledAgentNotification?> markNotificationComplete(int id) async {
120+
for (final notification in _notifications) {
121+
if (notification.id == id) {
122+
return notification;
123+
}
124+
}
125+
return null;
126+
}
127+
128+
@override
129+
Future<ScheduledAgentNotification> scheduleNotification(
130+
ScheduleAgentNotificationRequest request,
131+
) {
132+
throw UnimplementedError();
133+
}
134+
}

0 commit comments

Comments
 (0)