From d129c6b69362a48d83b533db681ef24855ac39ff Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Wed, 8 Jul 2026 09:16:06 +0000 Subject: [PATCH 1/4] feat: add proactive home feed snapshot --- flutter_app/lib/src/app_router.dart | 2 +- flutter_app/lib/src/app_shell_controller.dart | 2 +- flutter_app/lib/src/feed_summary.dart | 282 +++++++++++++++--- flutter_app/lib/src/views/home_view.dart | 37 +-- .../src/widgets/proactive_feed_section.dart | 218 ++++++++++++++ flutter_app/test/feed_summary_test.dart | 62 +++- flutter_app/test/navigation_test.dart | 32 +- 7 files changed, 533 insertions(+), 102 deletions(-) create mode 100644 flutter_app/lib/src/widgets/proactive_feed_section.dart diff --git a/flutter_app/lib/src/app_router.dart b/flutter_app/lib/src/app_router.dart index 7d7e005..a90a737 100644 --- a/flutter_app/lib/src/app_router.dart +++ b/flutter_app/lib/src/app_router.dart @@ -216,7 +216,7 @@ class _HomeRoute extends StatelessWidget { builder: (context, _) => HomeView( profile: controller.profile, config: controller.agentConfig, - briefing: controller.dailyBriefing, + snapshot: controller.homeFeedSnapshot, memoryText: controller.memoryText, timetable: controller.timetable, onOpenMail: () => context.go('/mail'), diff --git a/flutter_app/lib/src/app_shell_controller.dart b/flutter_app/lib/src/app_shell_controller.dart index 3205fdc..5ebd44a 100644 --- a/flutter_app/lib/src/app_shell_controller.dart +++ b/flutter_app/lib/src/app_shell_controller.dart @@ -91,7 +91,7 @@ class AppShellController extends ChangeNotifier { AgentConfig get agentConfig => _agentConfig; String get memoryText => _memoryText; TimetableSnapshot? get timetable => _timetable; - DailyBriefingState get dailyBriefing => DailyBriefingState.fromLocalState( + HomeFeedSnapshot get homeFeedSnapshot => HomeFeedSnapshot.fromLocalState( profile: _profile, timetable: _timetable, memoryText: _memoryText, diff --git a/flutter_app/lib/src/feed_summary.dart b/flutter_app/lib/src/feed_summary.dart index b9c074a..66b8343 100644 --- a/flutter_app/lib/src/feed_summary.dart +++ b/flutter_app/lib/src/feed_summary.dart @@ -1,6 +1,135 @@ import 'student_profile.dart'; import 'timetable_models.dart'; +enum HomeFeedSourceStatus { fresh, stale, unavailable } + +enum UrgentItemSeverity { info, warning } + +class DailySummary { + const DailySummary({required this.title, required this.body}); + + final String title; + final String body; +} + +class NextAction { + const NextAction({ + required this.title, + required this.body, + required this.label, + }); + + final String title; + final String body; + final String label; +} + +class UrgentItem { + const UrgentItem({ + required this.title, + required this.body, + this.severity = UrgentItemSeverity.info, + }); + + final String title; + final String body; + final UrgentItemSeverity severity; +} + +class HomeFeedSourceFreshness { + const HomeFeedSourceFreshness({ + required this.label, + required this.status, + this.fetchedAt, + }); + + final String label; + final HomeFeedSourceStatus status; + final DateTime? fetchedAt; + + String get statusLabel { + return switch (status) { + HomeFeedSourceStatus.fresh => 'Fresh', + HomeFeedSourceStatus.stale => 'Stale', + HomeFeedSourceStatus.unavailable => 'Unavailable', + }; + } +} + +class HomeFeedSnapshot { + const HomeFeedSnapshot({ + required this.summary, + required this.nextAction, + required this.urgentItems, + required this.sources, + required this.generatedAt, + }); + + factory HomeFeedSnapshot.fromLocalState({ + required OnboardingProfile? profile, + required TimetableSnapshot? timetable, + required String memoryText, + required DateTime now, + }) { + final nextLecture = timetable?.nextLectureAt(now); + final todayCount = timetable?.eventsOn(now).length ?? 0; + final urgentItems = []; + + if (nextLecture != null) { + final startsIn = nextLecture.start.difference(now); + if (!startsIn.isNegative && startsIn.inMinutes <= 30) { + urgentItems.add( + UrgentItem( + title: 'Lecture soon', + body: + '${nextLecture.title} ${nextLecture.relativeTimeLabel(now)}' + '${nextLecture.location == null ? '' : ' in ${nextLecture.location}'}', + severity: UrgentItemSeverity.warning, + ), + ); + } + } + + final summary = DailySummary( + title: _summaryTitle(profile), + body: _summaryBody( + profile: profile, + nextLecture: nextLecture, + todayCount: todayCount, + hasMemory: memoryText.trim().isNotEmpty, + now: now, + ), + ); + + return HomeFeedSnapshot( + summary: summary, + nextAction: _nextActionFor( + profile: profile, + timetable: timetable, + nextLecture: nextLecture, + memoryText: memoryText, + now: now, + ), + urgentItems: List.unmodifiable(urgentItems), + sources: List.unmodifiable( + _sourcesFor(timetable: timetable, memoryText: memoryText, now: now), + ), + generatedAt: now, + ); + } + + final DailySummary summary; + final NextAction nextAction; + final List urgentItems; + final List sources; + final DateTime generatedAt; + + bool get hasUrgentItems => urgentItems.isNotEmpty; + bool get isStale => + sources.any((source) => source.status == HomeFeedSourceStatus.stale); + String get generatedAtLabel => _time(generatedAt); +} + class FeedMessage { const FeedMessage({required this.title, required this.body}); @@ -21,49 +150,33 @@ class DailyBriefingState { required String memoryText, required DateTime now, }) { - final messages = []; - final nextLecture = timetable?.nextLectureAt(now); - if (nextLecture != null) { - messages.add( - FeedMessage( - title: 'Next lecture', - body: - '${nextLecture.title} ${nextLecture.relativeTimeLabel(now)}' - '${nextLecture.location == null ? '' : ' in ${nextLecture.location}'}', - ), - ); + final snapshot = HomeFeedSnapshot.fromLocalState( + profile: profile, + timetable: timetable, + memoryText: memoryText, + now: now, + ); + final messages = [ + FeedMessage(title: snapshot.summary.title, body: snapshot.summary.body), + ]; + final nextAction = snapshot.nextAction; + messages.add(FeedMessage(title: nextAction.title, body: nextAction.body)); + for (final item in snapshot.urgentItems) { + messages.add(FeedMessage(title: item.title, body: item.body)); } - final todayCount = timetable?.eventsOn(now).length ?? 0; - if (todayCount > 0) { + if (messages.isEmpty) { messages.add( FeedMessage( - title: 'Daily plan', - body: todayCount == 1 - ? 'You have 1 lecture on the schedule today.' - : 'You have $todayCount lectures on the schedule today.', - ), - ); - } - if (memoryText.trim().isNotEmpty) { - messages.add( - const FeedMessage( - title: 'Notes', - body: 'Saved study notes are available for assistant context.', + title: 'Nothing from my side right now.', + body: profile == null + ? 'No assistant update is available yet. Pull down to refresh or ask StudyOS about mail, schedules, campus places, or saved notes.' + : 'No assistant update is available for your profile yet. Pull down to refresh or ask StudyOS about mail, schedules, campus places, or saved notes.', ), ); } return DailyBriefingState( headline: 'Assistant summary', - messages: messages.isEmpty - ? [ - FeedMessage( - title: 'Nothing from my side right now.', - body: profile == null - ? 'No assistant update is available yet. Pull down to refresh or ask StudyOS about mail, schedules, campus places, or saved notes.' - : 'No assistant update is available for your profile yet. Pull down to refresh or ask StudyOS about mail, schedules, campus places, or saved notes.', - ), - ] - : List.unmodifiable(messages), + messages: List.unmodifiable(messages), generatedAt: now, ); } @@ -72,3 +185,102 @@ class DailyBriefingState { final List messages; final DateTime generatedAt; } + +String _summaryTitle(OnboardingProfile? profile) { + return profile == null ? 'Set up your StudyOS' : 'Today at a glance'; +} + +String _summaryBody({ + required OnboardingProfile? profile, + required LectureEvent? nextLecture, + required int todayCount, + required bool hasMemory, + required DateTime now, +}) { + if (profile == null) { + return 'Connect your profile to unlock timetable, mail, campus, and route-aware suggestions.'; + } + if (nextLecture != null) { + final count = todayCount == 1 ? '1 lecture' : '$todayCount lectures'; + return 'You have $count today. Next: ${nextLecture.title} ${nextLecture.relativeTimeLabel(now)}.'; + } + if (hasMemory) { + return 'No lecture needs attention right now. Your saved notes are ready for assistant context.'; + } + return 'Nothing urgent from my side right now. Ask StudyOS about schedule, mail, campus, or saved notes.'; +} + +NextAction _nextActionFor({ + required OnboardingProfile? profile, + required TimetableSnapshot? timetable, + required LectureEvent? nextLecture, + required String memoryText, + required DateTime now, +}) { + if (profile == null) { + return const NextAction( + title: 'Complete profile', + body: 'Tell StudyOS your degree and campus preferences to personalize the home feed.', + label: 'Open settings', + ); + } + if (timetable == null || _isTimetableStale(timetable, now)) { + return const NextAction( + title: 'Refresh timetable', + body: 'Sync your latest schedule so the feed can rank lectures and route hints.', + label: 'Refresh', + ); + } + if (nextLecture != null) { + return NextAction( + title: 'Prepare for next lecture', + body: + '${nextLecture.title} ${nextLecture.relativeTimeLabel(now)}' + '${nextLecture.location == null ? '' : ' in ${nextLecture.location}'}', + label: 'Open schedule', + ); + } + if (memoryText.trim().isEmpty) { + return const NextAction( + title: 'Add study context', + body: 'Save notes or preferences so the assistant can personalize future suggestions.', + label: 'Open notes', + ); + } + return const NextAction( + title: 'Ask StudyOS', + body: 'No automatic action is urgent. Start with a question or plan your next study block.', + label: 'Open chat', + ); +} + +List _sourcesFor({ + required TimetableSnapshot? timetable, + required String memoryText, + required DateTime now, +}) { + return [ + HomeFeedSourceFreshness( + label: 'Timetable', + status: timetable == null + ? HomeFeedSourceStatus.unavailable + : _isTimetableStale(timetable, now) + ? HomeFeedSourceStatus.stale + : HomeFeedSourceStatus.fresh, + fetchedAt: timetable?.refreshedAt, + ), + HomeFeedSourceFreshness( + label: 'Notes', + status: memoryText.trim().isEmpty + ? HomeFeedSourceStatus.unavailable + : HomeFeedSourceStatus.fresh, + ), + ]; +} + +bool _isTimetableStale(TimetableSnapshot timetable, DateTime now) { + return now.difference(timetable.refreshedAt) > const Duration(hours: 4); +} + +String _time(DateTime value) => + '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}'; diff --git a/flutter_app/lib/src/views/home_view.dart b/flutter_app/lib/src/views/home_view.dart index 4d93ba4..15bd38d 100644 --- a/flutter_app/lib/src/views/home_view.dart +++ b/flutter_app/lib/src/views/home_view.dart @@ -3,12 +3,13 @@ import 'package:flutter/material.dart'; import '../assistant_copy.dart'; import '../models.dart'; import '../studyos_theme.dart'; +import '../widgets/proactive_feed_section.dart'; class HomeView extends StatelessWidget { const HomeView({ required this.profile, required this.config, - required this.briefing, + required this.snapshot, required this.memoryText, required this.timetable, required this.onOpenMail, @@ -21,7 +22,7 @@ class HomeView extends StatelessWidget { final OnboardingProfile? profile; final AgentConfig config; - final DailyBriefingState briefing; + final HomeFeedSnapshot snapshot; final String memoryText; final TimetableSnapshot? timetable; final VoidCallback onOpenMail; @@ -41,7 +42,7 @@ class HomeView extends StatelessWidget { children: [ _HomeHeader(profile: profile), const SizedBox(height: StudyOsSpacing.lg), - _DailyBriefingSection(briefing: briefing), + ProactiveFeedSection(snapshot: snapshot, onRefresh: onRefresh), const SizedBox(height: StudyOsSpacing.xl), _StatusGrid( items: <_HomeStatusItem>[ @@ -180,36 +181,6 @@ class _HomeHeader extends StatelessWidget { } } -class _DailyBriefingSection extends StatelessWidget { - const _DailyBriefingSection({required this.briefing}); - - final DailyBriefingState briefing; - - @override - Widget build(BuildContext context) { - return Semantics( - header: true, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - briefing.headline, - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: StudyOsSpacing.md), - for (final message in briefing.messages) ...[ - Text(message.title, style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: StudyOsSpacing.xs), - Text(message.body, style: Theme.of(context).textTheme.bodyMedium), - if (message != briefing.messages.last) - const SizedBox(height: StudyOsSpacing.md), - ], - ], - ), - ); - } -} - class _StatusGrid extends StatelessWidget { const _StatusGrid({required this.items}); diff --git a/flutter_app/lib/src/widgets/proactive_feed_section.dart b/flutter_app/lib/src/widgets/proactive_feed_section.dart new file mode 100644 index 0000000..d5f81ae --- /dev/null +++ b/flutter_app/lib/src/widgets/proactive_feed_section.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; + +import '../models.dart'; +import '../studyos_theme.dart'; + +class ProactiveFeedSection extends StatelessWidget { + const ProactiveFeedSection({ + required this.snapshot, + required this.onRefresh, + super.key, + }); + + final HomeFeedSnapshot snapshot; + final Future Function() onRefresh; + + @override + Widget build(BuildContext context) { + final nextAction = snapshot.nextAction; + return Material( + color: StudyOsColors.surfaceRaised, + shape: RoundedRectangleBorder( + side: BorderSide( + color: snapshot.isStale ? StudyOsColors.warning : StudyOsColors.border, + ), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.auto_awesome, color: StudyOsColors.accent), + const SizedBox(width: StudyOsSpacing.sm), + Expanded( + child: Text( + snapshot.summary.title, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + _TimeLeftPill( + label: snapshot.isStale + ? 'Stale' + : 'Updated ${snapshot.generatedAtLabel}', + ), + ], + ), + const SizedBox(height: StudyOsSpacing.md), + Text( + snapshot.summary.body, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: StudyOsSpacing.lg), + _NextActionTile(action: nextAction, onRefresh: onRefresh), + if (snapshot.hasUrgentItems) ...[ + const SizedBox(height: StudyOsSpacing.md), + for (final item in snapshot.urgentItems) + _UrgentItemTile(item: item), + ], + const SizedBox(height: StudyOsSpacing.md), + Wrap( + spacing: StudyOsSpacing.sm, + runSpacing: StudyOsSpacing.sm, + children: [ + for (final source in snapshot.sources) + _SourceFreshnessPill(source: source), + ], + ), + ], + ), + ), + ); + } +} + +class _NextActionTile extends StatelessWidget { + const _NextActionTile({required this.action, required this.onRefresh}); + + final NextAction action; + final Future Function() onRefresh; + + @override + Widget build(BuildContext context) { + final isRefresh = action.label == 'Refresh'; + return Material( + color: StudyOsColors.surface, + shape: RoundedRectangleBorder( + side: const BorderSide(color: StudyOsColors.border), + borderRadius: BorderRadius.circular(StudyOsRadii.sm), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: Row( + children: [ + const Icon(Icons.arrow_forward_rounded, color: StudyOsColors.accent), + const SizedBox(width: StudyOsSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + action.title, + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: StudyOsSpacing.xs), + Text( + action.body, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + if (isRefresh) ...[ + const SizedBox(width: StudyOsSpacing.sm), + IconButton( + tooltip: action.label, + onPressed: () => onRefresh(), + icon: const Icon(Icons.refresh_rounded), + ), + ], + ], + ), + ), + ); + } +} + +class _UrgentItemTile extends StatelessWidget { + const _UrgentItemTile({required this.item}); + + final UrgentItem item; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: StudyOsSpacing.sm), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + item.severity == UrgentItemSeverity.warning + ? Icons.warning_amber_rounded + : Icons.info_outline_rounded, + color: item.severity == UrgentItemSeverity.warning + ? StudyOsColors.warning + : StudyOsColors.accent, + ), + const SizedBox(width: StudyOsSpacing.sm), + Expanded( + child: Text( + '${item.title}: ${item.body}', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ); + } +} + +class _SourceFreshnessPill extends StatelessWidget { + const _SourceFreshnessPill({required this.source}); + + final HomeFeedSourceFreshness source; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: StudyOsColors.border), + borderRadius: BorderRadius.circular(StudyOsRadii.sm), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.sm, + vertical: StudyOsSpacing.xs, + ), + child: Text( + '${source.label}: ${source.statusLabel}', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ); + } +} + +class _TimeLeftPill extends StatelessWidget { + const _TimeLeftPill({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: StudyOsColors.accent.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(999), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.sm, + vertical: StudyOsSpacing.xs, + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: StudyOsColors.accent, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } +} + diff --git a/flutter_app/test/feed_summary_test.dart b/flutter_app/test/feed_summary_test.dart index 80bd0ad..a176184 100644 --- a/flutter_app/test/feed_summary_test.dart +++ b/flutter_app/test/feed_summary_test.dart @@ -2,25 +2,26 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:studyos_agent/src/models.dart'; void main() { - test('daily briefing falls back when no local state needs attention', () { - final briefing = DailyBriefingState.fromLocalState( + test('home feed snapshot falls back when no local state needs attention', () { + final snapshot = HomeFeedSnapshot.fromLocalState( profile: null, timetable: null, memoryText: '', now: DateTime(2026, 7, 1, 9), ); - expect(briefing.headline, 'Assistant summary'); - expect(briefing.messages.single.title, 'Nothing from my side right now.'); + expect(snapshot.summary.title, 'Set up your StudyOS'); expect( - briefing.messages.single.body, - contains('No assistant update is available yet.'), + snapshot.summary.body, + contains('Connect your profile'), ); + expect(snapshot.nextAction.title, 'Complete profile'); + expect(snapshot.sources.first.status, HomeFeedSourceStatus.unavailable); }); - test('daily briefing summarizes next lecture and today count', () { + test('home feed snapshot summarizes next lecture and freshness', () { final now = DateTime(2026, 7, 1, 9); - final snapshot = TimetableSnapshot( + final timetable = TimetableSnapshot( refreshedAt: now, sourceTerm: 'Summer 2026', events: [ @@ -34,7 +35,7 @@ void main() { ], ); - final briefing = DailyBriefingState.fromLocalState( + final snapshot = HomeFeedSnapshot.fromLocalState( profile: const OnboardingProfile( displayName: 'Ada', username: 'ada42', @@ -43,18 +44,47 @@ void main() { semester: 2, livesInTuebingen: true, ), - timetable: snapshot, + timetable: timetable, memoryText: '', now: now, ); - expect(briefing.headline, 'Assistant summary'); - expect(briefing.messages.first.title, 'Next lecture'); - expect(briefing.messages.first.body, 'Machine Learning in 1 h in Room A'); - expect( - briefing.messages.last.body, - 'You have 1 lecture on the schedule today.', + expect(snapshot.summary.title, 'Today at a glance'); + expect(snapshot.summary.body, contains('Machine Learning in 1 h')); + expect(snapshot.nextAction.title, 'Prepare for next lecture'); + expect(snapshot.nextAction.body, 'Machine Learning in 1 h in Room A'); + expect(snapshot.sources.first.status, HomeFeedSourceStatus.fresh); + }); + + test('home feed marks soon lectures as urgent', () { + final now = DateTime(2026, 7, 1, 9); + final snapshot = HomeFeedSnapshot.fromLocalState( + profile: const OnboardingProfile( + displayName: 'Ada', + username: 'ada42', + email: null, + degreeProgram: 'M.Sc. AI', + semester: 2, + livesInTuebingen: true, + ), + timetable: TimetableSnapshot( + refreshedAt: now, + sourceTerm: 'Summer 2026', + events: [ + LectureEvent( + id: 'seminar', + title: 'Seminar', + start: now.add(const Duration(minutes: 20)), + end: now.add(const Duration(hours: 2)), + ), + ], + ), + memoryText: '', + now: now, ); + + expect(snapshot.hasUrgentItems, isTrue); + expect(snapshot.urgentItems.single.title, 'Lecture soon'); }); test('lecture relative time labels cover upcoming and terminal states', () { diff --git a/flutter_app/test/navigation_test.dart b/flutter_app/test/navigation_test.dart index cf6e6ba..9ec9df9 100644 --- a/flutter_app/test/navigation_test.dart +++ b/flutter_app/test/navigation_test.dart @@ -87,7 +87,7 @@ void main() { foodPreference: FoodPreference.vegan, ), config: const AgentConfig.defaults(), - briefing: _testBriefing(), + snapshot: _testSnapshot(), memoryText: '', timetable: null, onOpenMail: () => selectedTarget = 'mail', @@ -124,7 +124,7 @@ void main() { body: HomeView( profile: null, config: const AgentConfig.defaults(), - briefing: _testBriefing(), + snapshot: _testSnapshot(), memoryText: '', timetable: null, onOpenMail: () => selectedTarget = 'mail', @@ -159,7 +159,7 @@ void main() { body: HomeView( profile: null, config: const AgentConfig.defaults(), - briefing: _testBriefing(), + snapshot: _testSnapshot(), memoryText: '', timetable: null, onOpenMail: () => selectedTarget = 'mail', @@ -183,7 +183,7 @@ void main() { expect(selectedTarget, 'maps'); }); - testWidgets('home renders fallback daily briefing before cards', ( + testWidgets('home renders proactive snapshot before cards', ( WidgetTester tester, ) async { await tester.pumpWidget( @@ -193,7 +193,7 @@ void main() { body: HomeView( profile: null, config: const AgentConfig.defaults(), - briefing: DailyBriefingState.fromLocalState( + snapshot: HomeFeedSnapshot.fromLocalState( profile: null, timetable: null, memoryText: '', @@ -211,17 +211,18 @@ void main() { ), ); - expect(find.text('Nothing from my side right now.'), findsOneWidget); + expect(find.text('Set up your StudyOS'), findsOneWidget); expect( - find.textContaining('No assistant update is available'), + find.textContaining('Connect your profile'), findsOneWidget, ); + expect(find.text('Timetable: Unavailable'), findsOneWidget); expect(find.byType(RefreshIndicator), findsOneWidget); }); - test('daily briefing summarizes the next lecture from structured state', () { + test('home feed snapshot summarizes the next lecture from structured state', () { final now = DateTime(2026, 7, 1, 9); - final briefing = DailyBriefingState.fromLocalState( + final snapshot = HomeFeedSnapshot.fromLocalState( profile: const OnboardingProfile( displayName: 'Ada', username: 'ada42', @@ -247,11 +248,10 @@ void main() { now: now, ); - expect(briefing.headline, 'Assistant summary'); - expect(briefing.messages.first.title, 'Next lecture'); - expect(briefing.messages.first.body, contains('Algorithms')); - expect(briefing.messages.first.body, contains('in 45 min')); - expect(briefing.messages.last.title, 'Daily plan'); + expect(snapshot.summary.title, 'Today at a glance'); + expect(snapshot.summary.body, contains('Algorithms')); + expect(snapshot.summary.body, contains('in 45 min')); + expect(snapshot.nextAction.title, 'Prepare for next lecture'); }); testWidgets('shell swipes between primary tabs and updates location', ( @@ -367,8 +367,8 @@ void main() { }); } -DailyBriefingState _testBriefing() { - return DailyBriefingState.fromLocalState( +HomeFeedSnapshot _testSnapshot() { + return HomeFeedSnapshot.fromLocalState( profile: null, timetable: null, memoryText: '', From 656d1da8a18ff4ba21c3293f34a76b10fe558f22 Mon Sep 17 00:00:00 2001 From: SebastianBoehler <27767932+SebastianBoehler@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:06:49 +0200 Subject: [PATCH 2/4] fix(home): avoid duplicate lecture urgency --- flutter_app/lib/src/feed_summary.dart | 43 ++++++++++++++++++------- flutter_app/test/feed_summary_test.dart | 39 +++++++++++++++++++--- 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/flutter_app/lib/src/feed_summary.dart b/flutter_app/lib/src/feed_summary.dart index 66b8343..d8f3c2e 100644 --- a/flutter_app/lib/src/feed_summary.dart +++ b/flutter_app/lib/src/feed_summary.dart @@ -73,9 +73,21 @@ class HomeFeedSnapshot { }) { final nextLecture = timetable?.nextLectureAt(now); final todayCount = timetable?.eventsOn(now).length ?? 0; + final nextAction = _nextActionFor( + profile: profile, + timetable: timetable, + nextLecture: nextLecture, + memoryText: memoryText, + now: now, + ); final urgentItems = []; - if (nextLecture != null) { + if (nextLecture != null && + !_nextActionAlreadyCoversLecture( + profile: profile, + timetable: timetable, + now: now, + )) { final startsIn = nextLecture.start.difference(now); if (!startsIn.isNegative && startsIn.inMinutes <= 30) { urgentItems.add( @@ -103,13 +115,7 @@ class HomeFeedSnapshot { return HomeFeedSnapshot( summary: summary, - nextAction: _nextActionFor( - profile: profile, - timetable: timetable, - nextLecture: nextLecture, - memoryText: memoryText, - now: now, - ), + nextAction: nextAction, urgentItems: List.unmodifiable(urgentItems), sources: List.unmodifiable( _sourcesFor(timetable: timetable, memoryText: memoryText, now: now), @@ -220,14 +226,16 @@ NextAction _nextActionFor({ if (profile == null) { return const NextAction( title: 'Complete profile', - body: 'Tell StudyOS your degree and campus preferences to personalize the home feed.', + body: + 'Tell StudyOS your degree and campus preferences to personalize the home feed.', label: 'Open settings', ); } if (timetable == null || _isTimetableStale(timetable, now)) { return const NextAction( title: 'Refresh timetable', - body: 'Sync your latest schedule so the feed can rank lectures and route hints.', + body: + 'Sync your latest schedule so the feed can rank lectures and route hints.', label: 'Refresh', ); } @@ -243,13 +251,15 @@ NextAction _nextActionFor({ if (memoryText.trim().isEmpty) { return const NextAction( title: 'Add study context', - body: 'Save notes or preferences so the assistant can personalize future suggestions.', + body: + 'Save notes or preferences so the assistant can personalize future suggestions.', label: 'Open notes', ); } return const NextAction( title: 'Ask StudyOS', - body: 'No automatic action is urgent. Start with a question or plan your next study block.', + body: + 'No automatic action is urgent. Start with a question or plan your next study block.', label: 'Open chat', ); } @@ -282,5 +292,14 @@ bool _isTimetableStale(TimetableSnapshot timetable, DateTime now) { return now.difference(timetable.refreshedAt) > const Duration(hours: 4); } +bool _nextActionAlreadyCoversLecture({ + required OnboardingProfile? profile, + required TimetableSnapshot? timetable, + required DateTime now, +}) { + if (profile == null || timetable == null) return false; + return !_isTimetableStale(timetable, now); +} + String _time(DateTime value) => '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}'; diff --git a/flutter_app/test/feed_summary_test.dart b/flutter_app/test/feed_summary_test.dart index a176184..4647c70 100644 --- a/flutter_app/test/feed_summary_test.dart +++ b/flutter_app/test/feed_summary_test.dart @@ -11,10 +11,7 @@ void main() { ); expect(snapshot.summary.title, 'Set up your StudyOS'); - expect( - snapshot.summary.body, - contains('Connect your profile'), - ); + expect(snapshot.summary.body, contains('Connect your profile')); expect(snapshot.nextAction.title, 'Complete profile'); expect(snapshot.sources.first.status, HomeFeedSourceStatus.unavailable); }); @@ -56,7 +53,7 @@ void main() { expect(snapshot.sources.first.status, HomeFeedSourceStatus.fresh); }); - test('home feed marks soon lectures as urgent', () { + test('home feed does not duplicate the next lecture action as urgent', () { final now = DateTime(2026, 7, 1, 9); final snapshot = HomeFeedSnapshot.fromLocalState( profile: const OnboardingProfile( @@ -83,6 +80,38 @@ void main() { now: now, ); + expect(snapshot.nextAction.title, 'Prepare for next lecture'); + expect(snapshot.hasUrgentItems, isFalse); + }); + + test('home feed keeps soon lecture warning when timetable needs refresh', () { + final now = DateTime(2026, 7, 1, 9); + final snapshot = HomeFeedSnapshot.fromLocalState( + profile: const OnboardingProfile( + displayName: 'Ada', + username: 'ada42', + email: null, + degreeProgram: 'M.Sc. AI', + semester: 2, + livesInTuebingen: true, + ), + timetable: TimetableSnapshot( + refreshedAt: now.subtract(const Duration(hours: 5)), + sourceTerm: 'Summer 2026', + events: [ + LectureEvent( + id: 'seminar', + title: 'Seminar', + start: now.add(const Duration(minutes: 20)), + end: now.add(const Duration(hours: 2)), + ), + ], + ), + memoryText: '', + now: now, + ); + + expect(snapshot.nextAction.title, 'Refresh timetable'); expect(snapshot.hasUrgentItems, isTrue); expect(snapshot.urgentItems.single.title, 'Lecture soon'); }); From 2855fd08fc2e3ca8e061fef69a5d3a4418534ca4 Mon Sep 17 00:00:00 2001 From: SebastianBoehler <27767932+SebastianBoehler@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:16:06 +0200 Subject: [PATCH 3/4] fix(home): make status grid actionable --- flutter_app/lib/src/app_router.dart | 3 + flutter_app/lib/src/views/home_view.dart | 43 +++++- flutter_app/test/navigation_test.dart | 172 +++++++++++++++++------ 3 files changed, 173 insertions(+), 45 deletions(-) diff --git a/flutter_app/lib/src/app_router.dart b/flutter_app/lib/src/app_router.dart index a90a737..709fbde 100644 --- a/flutter_app/lib/src/app_router.dart +++ b/flutter_app/lib/src/app_router.dart @@ -219,6 +219,9 @@ class _HomeRoute extends StatelessWidget { snapshot: controller.homeFeedSnapshot, memoryText: controller.memoryText, timetable: controller.timetable, + onOpenProfile: () => context.push('/settings/profile'), + onOpenAssistant: () => context.push('/settings'), + onOpenNotes: () => context.push('/memories'), onOpenMail: () => context.go('/mail'), onOpenMaps: () => context.push('/maps'), onOpenCampus: () => context.go('/campus'), diff --git a/flutter_app/lib/src/views/home_view.dart b/flutter_app/lib/src/views/home_view.dart index 15bd38d..23e07b0 100644 --- a/flutter_app/lib/src/views/home_view.dart +++ b/flutter_app/lib/src/views/home_view.dart @@ -12,6 +12,9 @@ class HomeView extends StatelessWidget { required this.snapshot, required this.memoryText, required this.timetable, + required this.onOpenProfile, + required this.onOpenAssistant, + required this.onOpenNotes, required this.onOpenMail, required this.onOpenMaps, required this.onOpenCampus, @@ -25,6 +28,9 @@ class HomeView extends StatelessWidget { final HomeFeedSnapshot snapshot; final String memoryText; final TimetableSnapshot? timetable; + final VoidCallback onOpenProfile; + final VoidCallback onOpenAssistant; + final VoidCallback onOpenNotes; final VoidCallback onOpenMail; final VoidCallback onOpenMaps; final VoidCallback onOpenCampus; @@ -47,34 +53,46 @@ class HomeView extends StatelessWidget { _StatusGrid( items: <_HomeStatusItem>[ _HomeStatusItem( + cardKey: const ValueKey('home-status-profile'), icon: Icons.person_outline_rounded, label: 'Profile', value: profile == null ? 'Not connected' : 'Connected', + onTap: onOpenProfile, ), _HomeStatusItem( + cardKey: const ValueKey('home-status-assistant'), icon: Icons.auto_awesome_outlined, label: 'Assistant', value: assistantSetupLabel(config), + onTap: onOpenAssistant, ), _HomeStatusItem( + cardKey: const ValueKey('home-status-notes'), icon: Icons.psychology_alt_outlined, label: 'Notes', value: memoryText.trim().isEmpty ? 'Not yet' : 'Saved', + onTap: onOpenNotes, ), _HomeStatusItem( + cardKey: const ValueKey('home-status-timetable'), icon: Icons.calendar_month_outlined, label: 'Timetable', value: _timetableStatus, + onTap: onOpenSchedule, ), _HomeStatusItem( + cardKey: const ValueKey('home-status-mail'), icon: Icons.mark_email_unread_outlined, label: 'Mail', value: profile == null ? 'Sign in needed' : 'Local tools ready', + onTap: onOpenMail, ), - const _HomeStatusItem( + _HomeStatusItem( + cardKey: const ValueKey('home-status-map'), icon: Icons.map_outlined, label: 'Map', value: 'Tübingen', + onTap: onOpenMaps, ), ], ), @@ -197,7 +215,13 @@ class _StatusGrid extends StatelessWidget { physics: const NeverScrollableScrollPhysics(), children: [ for (final item in items) - _HomeCard(icon: item.icon, title: item.label, body: item.value), + _HomeCard( + key: item.cardKey, + icon: item.icon, + title: item.label, + body: item.value, + onTap: item.onTap, + ), ], ); } @@ -205,14 +229,18 @@ class _StatusGrid extends StatelessWidget { class _HomeStatusItem { const _HomeStatusItem({ + required this.cardKey, required this.icon, required this.label, required this.value, + required this.onTap, }); + final Key cardKey; final IconData icon; final String label; final String value; + final VoidCallback onTap; } class _HomeCard extends StatelessWidget { @@ -233,6 +261,15 @@ class _HomeCard extends StatelessWidget { @override Widget build(BuildContext context) { + final effectiveTrailing = + trailing ?? + (onTap == null + ? null + : const Icon( + Icons.chevron_right_rounded, + size: 22, + color: StudyOsColors.textMuted, + )); return Material( color: StudyOsColors.surface, shape: RoundedRectangleBorder( @@ -253,7 +290,7 @@ class _HomeCard extends StatelessWidget { children: [ Icon(icon, size: 32, color: StudyOsColors.accent), const Spacer(), - ?trailing, + ?effectiveTrailing, ], ), Column( diff --git a/flutter_app/test/navigation_test.dart b/flutter_app/test/navigation_test.dart index 9ec9df9..d6e0999 100644 --- a/flutter_app/test/navigation_test.dart +++ b/flutter_app/test/navigation_test.dart @@ -90,6 +90,9 @@ void main() { snapshot: _testSnapshot(), memoryText: '', timetable: null, + onOpenProfile: () => selectedTarget = 'profile', + onOpenAssistant: () => selectedTarget = 'assistant', + onOpenNotes: () => selectedTarget = 'notes', onOpenMail: () => selectedTarget = 'mail', onOpenMaps: () => selectedTarget = 'maps', onOpenCampus: () => selectedTarget = 'campus', @@ -127,6 +130,9 @@ void main() { snapshot: _testSnapshot(), memoryText: '', timetable: null, + onOpenProfile: () => selectedTarget = 'profile', + onOpenAssistant: () => selectedTarget = 'assistant', + onOpenNotes: () => selectedTarget = 'notes', onOpenMail: () => selectedTarget = 'mail', onOpenMaps: () => selectedTarget = 'maps', onOpenCampus: () => selectedTarget = 'campus', @@ -150,6 +156,11 @@ void main() { testWidgets('home navigation card opens maps view', ( WidgetTester tester, ) async { + tester.view.physicalSize = const Size(800, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var selectedTarget = 'home'; await tester.pumpWidget( @@ -162,6 +173,9 @@ void main() { snapshot: _testSnapshot(), memoryText: '', timetable: null, + onOpenProfile: () => selectedTarget = 'profile', + onOpenAssistant: () => selectedTarget = 'assistant', + onOpenNotes: () => selectedTarget = 'notes', onOpenMail: () => selectedTarget = 'mail', onOpenMaps: () => selectedTarget = 'maps', onOpenCampus: () => selectedTarget = 'campus', @@ -173,16 +187,68 @@ void main() { ); final mapsCard = find.byKey(const ValueKey('home-maps-card')); - await tester.scrollUntilVisible( - mapsCard, - 300, - scrollable: find.byType(Scrollable).first, - ); + await _bringCardIntoTapArea(tester, mapsCard); await tester.tap(mapsCard); expect(selectedTarget, 'maps'); }); + testWidgets('home status grid items open their destinations', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = const Size(800, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + var selectedTarget = 'home'; + + await tester.pumpWidget( + MaterialApp( + theme: buildStudyOsTheme(), + home: Scaffold( + body: HomeView( + profile: const OnboardingProfile( + displayName: 'Ada', + username: 'ada42', + email: null, + degreeProgram: 'M.Sc. AI', + semester: 2, + livesInTuebingen: true, + ), + config: const AgentConfig.defaults(), + snapshot: _testSnapshot(), + memoryText: 'Prefers morning study blocks.', + timetable: null, + onOpenProfile: () => selectedTarget = 'profile', + onOpenAssistant: () => selectedTarget = 'assistant', + onOpenNotes: () => selectedTarget = 'notes', + onOpenMail: () => selectedTarget = 'mail', + onOpenMaps: () => selectedTarget = 'maps', + onOpenCampus: () => selectedTarget = 'campus', + onOpenSchedule: () => selectedTarget = 'schedule', + onRefresh: () async {}, + ), + ), + ), + ); + + Future tapStatus(String key, String expectedTarget) async { + final finder = find.byKey(ValueKey(key)); + await _bringCardIntoTapArea(tester, finder); + await tester.tap(finder); + await tester.pump(); + expect(selectedTarget, expectedTarget); + } + + await tapStatus('home-status-profile', 'profile'); + await tapStatus('home-status-assistant', 'assistant'); + await tapStatus('home-status-notes', 'notes'); + await tapStatus('home-status-timetable', 'schedule'); + await tapStatus('home-status-mail', 'mail'); + await tapStatus('home-status-map', 'maps'); + }); + testWidgets('home renders proactive snapshot before cards', ( WidgetTester tester, ) async { @@ -201,6 +267,9 @@ void main() { ), memoryText: '', timetable: null, + onOpenProfile: () {}, + onOpenAssistant: () {}, + onOpenNotes: () {}, onOpenMail: () {}, onOpenMaps: () {}, onOpenCampus: () {}, @@ -212,47 +281,47 @@ void main() { ); expect(find.text('Set up your StudyOS'), findsOneWidget); - expect( - find.textContaining('Connect your profile'), - findsOneWidget, - ); + expect(find.textContaining('Connect your profile'), findsOneWidget); expect(find.text('Timetable: Unavailable'), findsOneWidget); expect(find.byType(RefreshIndicator), findsOneWidget); }); - test('home feed snapshot summarizes the next lecture from structured state', () { - final now = DateTime(2026, 7, 1, 9); - final snapshot = HomeFeedSnapshot.fromLocalState( - profile: const OnboardingProfile( - displayName: 'Ada', - username: 'ada42', - email: null, - degreeProgram: 'M.Sc. AI', - semester: 2, - livesInTuebingen: true, - ), - timetable: TimetableSnapshot( - refreshedAt: now, - sourceTerm: 'Sommer 2026', - events: [ - LectureEvent( - id: 'algorithms', - title: 'Algorithms', - start: now.add(const Duration(minutes: 45)), - end: now.add(const Duration(hours: 2)), - location: 'Room 101', - ), - ], - ), - memoryText: '', - now: now, - ); - - expect(snapshot.summary.title, 'Today at a glance'); - expect(snapshot.summary.body, contains('Algorithms')); - expect(snapshot.summary.body, contains('in 45 min')); - expect(snapshot.nextAction.title, 'Prepare for next lecture'); - }); + test( + 'home feed snapshot summarizes the next lecture from structured state', + () { + final now = DateTime(2026, 7, 1, 9); + final snapshot = HomeFeedSnapshot.fromLocalState( + profile: const OnboardingProfile( + displayName: 'Ada', + username: 'ada42', + email: null, + degreeProgram: 'M.Sc. AI', + semester: 2, + livesInTuebingen: true, + ), + timetable: TimetableSnapshot( + refreshedAt: now, + sourceTerm: 'Sommer 2026', + events: [ + LectureEvent( + id: 'algorithms', + title: 'Algorithms', + start: now.add(const Duration(minutes: 45)), + end: now.add(const Duration(hours: 2)), + location: 'Room 101', + ), + ], + ), + memoryText: '', + now: now, + ); + + expect(snapshot.summary.title, 'Today at a glance'); + expect(snapshot.summary.body, contains('Algorithms')); + expect(snapshot.summary.body, contains('in 45 min')); + expect(snapshot.nextAction.title, 'Prepare for next lecture'); + }, + ); testWidgets('shell swipes between primary tabs and updates location', ( WidgetTester tester, @@ -375,3 +444,22 @@ HomeFeedSnapshot _testSnapshot() { now: DateTime(2026, 7, 1, 9), ); } + +Future _bringCardIntoTapArea(WidgetTester tester, Finder finder) async { + final scrollable = find.byType(Scrollable).first; + await tester.scrollUntilVisible(finder, 300, scrollable: scrollable); + final viewportHeight = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + final centerY = tester.getCenter(finder).dy; + const safeInset = 72.0; + if (centerY > viewportHeight - safeInset) { + await tester.drag( + scrollable, + Offset(0, -(centerY - viewportHeight + safeInset)), + ); + await tester.pumpAndSettle(); + } else if (centerY < safeInset) { + await tester.drag(scrollable, Offset(0, safeInset - centerY)); + await tester.pumpAndSettle(); + } +} From 9c360494723138ac5cf83b0327ad52ca629b5b6c Mon Sep 17 00:00:00 2001 From: SebastianBoehler <27767932+SebastianBoehler@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:59:20 +0200 Subject: [PATCH 4/4] chore(format): apply dart formatter to home feed --- .../lib/src/widgets/proactive_feed_section.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/flutter_app/lib/src/widgets/proactive_feed_section.dart b/flutter_app/lib/src/widgets/proactive_feed_section.dart index d5f81ae..00e764b 100644 --- a/flutter_app/lib/src/widgets/proactive_feed_section.dart +++ b/flutter_app/lib/src/widgets/proactive_feed_section.dart @@ -20,7 +20,9 @@ class ProactiveFeedSection extends StatelessWidget { color: StudyOsColors.surfaceRaised, shape: RoundedRectangleBorder( side: BorderSide( - color: snapshot.isStale ? StudyOsColors.warning : StudyOsColors.border, + color: snapshot.isStale + ? StudyOsColors.warning + : StudyOsColors.border, ), borderRadius: BorderRadius.circular(StudyOsRadii.md), ), @@ -93,7 +95,10 @@ class _NextActionTile extends StatelessWidget { padding: const EdgeInsets.all(StudyOsSpacing.md), child: Row( children: [ - const Icon(Icons.arrow_forward_rounded, color: StudyOsColors.accent), + const Icon( + Icons.arrow_forward_rounded, + color: StudyOsColors.accent, + ), const SizedBox(width: StudyOsSpacing.md), Expanded( child: Column( @@ -215,4 +220,3 @@ class _TimeLeftPill extends StatelessWidget { ); } } -