Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/lib/core/routing/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,15 @@ class ChatScreen extends ConsumerStatefulWidget {
this.skillOrchestrator,
this.enableAiInitialization = true,
this.initialMessages,
this.initialDraft,
});

final AssistantRuntimeService? assistantRuntimeService;
final LocalRuntimePreloaderService? localRuntimePreloader;
final AgentSkillOrchestrator? skillOrchestrator;
final bool enableAiInitialization;
final List<ChatMessage>? initialMessages;
final String? initialDraft;

@override
ConsumerState<ChatScreen> createState() => _ChatScreenState();
Expand Down Expand Up @@ -100,7 +102,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
@override
void initState() {
super.initState();
_messageController = TextEditingController();
_messageController = TextEditingController(text: widget.initialDraft ?? '');
_moveComposerCursorToEnd();
_assistantRuntime =
widget.assistantRuntimeService ??
AssistantRuntimeService(geminiNano: _geminiNano, liteRtLm: _liteRtLm);
Expand Down Expand Up @@ -243,6 +246,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
super.dispose();
}

void _moveComposerCursorToEnd() {
_messageController.selection = TextSelection.collapsed(
offset: _messageController.text.length,
);
}

@override
Widget build(BuildContext context) {
final selectedAssistantModelId = ref.watch(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<NotificationsScreen> createState() => _NotificationsScreenState();
}

class _NotificationsScreenState extends State<NotificationsScreen> {
final LocalAgentNotificationScheduler _scheduler =
LocalAgentNotificationScheduler.instance;
late final AgentNotificationSchedulingService _scheduler;
late Future<List<ScheduledAgentNotification>> _notificationsFuture;

@override
void initState() {
super.initState();
_scheduler = widget._scheduler ?? LocalAgentNotificationScheduler.instance;
_notificationsFuture = _scheduler.getScheduledNotifications();
}

Expand Down Expand Up @@ -79,6 +81,7 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
notification: notifications[index],
onDelete: () => _deleteNotification(notifications[index].id),
onComplete: () => _completeNotification(notifications[index].id),
onOpenInChat: () => _openNotificationInChat(notifications[index]),
);
},
);
Expand All @@ -99,18 +102,29 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
_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 {
const _NotificationCard({
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) {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 = <String>[
notification.title.trim(),
notification.message.trim(),
].where((part) => part.isNotEmpty).toList();

final context = <String>[
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(', ')}).';
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextField>(
find.byKey(const Key('agent_chat_input')),
);
expect(input.controller?.text, 'Follow up on my reminder');
});
}

Future<void> _pumpChatScreen(
WidgetTester tester, {
required List<ChatMessage> initialMessages,
String? initialDraft,
}) async {
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1200, 1000);
Expand All @@ -190,6 +206,7 @@ Future<void> _pumpChatScreen(
home: ChatScreen(
enableAiInitialization: false,
initialMessages: initialMessages,
initialDraft: initialDraft,
),
),
),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TextField>(
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<ScheduledAgentNotification> _notifications;

@override
Future<void> cancelNotification(int id) async {
_notifications.removeWhere((notification) => notification.id == id);
}

@override
Future<List<ScheduledAgentNotification>> getScheduledNotifications() async {
return List<ScheduledAgentNotification>.from(_notifications);
}

@override
Future<ScheduledAgentNotification?> markNotificationComplete(int id) async {
for (final notification in _notifications) {
if (notification.id == id) {
return notification;
}
}
return null;
}

@override
Future<ScheduledAgentNotification> scheduleNotification(
ScheduleAgentNotificationRequest request,
) {
throw UnimplementedError();
}
}
Loading