From f17e840fea680134d4d38642ea18dc2579a8ec6c Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Fri, 17 Jul 2026 11:56:46 +0000 Subject: [PATCH 1/7] feat(feed): add template card sections --- flutter_app/lib/src/app_router.dart | 6 + flutter_app/lib/src/feed_summary.dart | 122 +++++ flutter_app/lib/src/views/home_view.dart | 8 +- .../lib/src/widgets/feed_detail_sheet.dart | 344 +++++++++++++ .../src/widgets/proactive_feed_section.dart | 459 +++++++++++++----- flutter_app/test/feed_summary_test.dart | 50 ++ flutter_app/test/navigation_test.dart | 8 +- 7 files changed, 871 insertions(+), 126 deletions(-) create mode 100644 flutter_app/lib/src/widgets/feed_detail_sheet.dart diff --git a/flutter_app/lib/src/app_router.dart b/flutter_app/lib/src/app_router.dart index 66edafc..804bbd1 100644 --- a/flutter_app/lib/src/app_router.dart +++ b/flutter_app/lib/src/app_router.dart @@ -229,6 +229,12 @@ class _HomeRoute extends StatelessWidget { '/chat?prompt=What%20is%20good%20at%20the%20Mensa%20today%3F', ), onOpenSchedule: () => context.go('/plan'), + onAskAssistant: (prompt) => context.push( + Uri( + path: '/chat', + queryParameters: {'prompt': prompt}, + ).toString(), + ), onRefresh: controller.refreshHomeFeed, ), ); diff --git a/flutter_app/lib/src/feed_summary.dart b/flutter_app/lib/src/feed_summary.dart index d8f3c2e..688c8f5 100644 --- a/flutter_app/lib/src/feed_summary.dart +++ b/flutter_app/lib/src/feed_summary.dart @@ -5,6 +5,8 @@ enum HomeFeedSourceStatus { fresh, stale, unavailable } enum UrgentItemSeverity { info, warning } +enum FeedTemplateCardKind { schedule, highlight, email } + class DailySummary { const DailySummary({required this.title, required this.body}); @@ -56,12 +58,87 @@ class HomeFeedSourceFreshness { } } +class FeedScheduleCard { + const FeedScheduleCard({ + required this.id, + required this.courseName, + required this.timeRange, + required this.hoursUntil, + this.type, + this.address, + this.llmSummary, + }); + + final String id; + final String courseName; + final String? type; + final String timeRange; + final int hoursUntil; + final String? address; + + /// Template slot intentionally left for local LLM generated prep text. + final String? llmSummary; + + String get timeToNextLabel { + if (hoursUntil <= 0) return 'now'; + if (hoursUntil == 1) return '1 h'; + return '$hoursUntil h'; + } +} + +class FeedArticleCard { + const FeedArticleCard({ + required this.id, + required this.title, + required this.sourceLabel, + required this.body, + this.imageUrl, + this.publishedAt, + this.llmSummary, + }); + + final String id; + final String title; + final String sourceLabel; + final String body; + final String? imageUrl; + final DateTime? publishedAt; + + /// Template slot intentionally left for local LLM generated summary text. + final String? llmSummary; +} + +class FeedEmailCard { + const FeedEmailCard({ + required this.id, + required this.subject, + required this.sender, + required this.preview, + this.receivedAt, + this.isUnread = false, + this.llmSummary, + }); + + final String id; + final String subject; + final String sender; + final String preview; + final DateTime? receivedAt; + final bool isUnread; + + /// Template slot intentionally left for local LLM extracted action items. + final String? llmSummary; +} + class HomeFeedSnapshot { const HomeFeedSnapshot({ required this.summary, required this.nextAction, required this.urgentItems, required this.sources, + required this.todaySchedule, + required this.highlights, + required this.emails, required this.generatedAt, }); @@ -112,6 +189,7 @@ class HomeFeedSnapshot { now: now, ), ); + final todaySchedule = _scheduleCardsFor(timetable: timetable, now: now); return HomeFeedSnapshot( summary: summary, @@ -120,6 +198,9 @@ class HomeFeedSnapshot { sources: List.unmodifiable( _sourcesFor(timetable: timetable, memoryText: memoryText, now: now), ), + todaySchedule: List.unmodifiable(todaySchedule), + highlights: const [], + emails: const [], generatedAt: now, ); } @@ -128,6 +209,9 @@ class HomeFeedSnapshot { final NextAction nextAction; final List urgentItems; final List sources; + final List todaySchedule; + final List highlights; + final List emails; final DateTime generatedAt; bool get hasUrgentItems => urgentItems.isNotEmpty; @@ -136,6 +220,44 @@ class HomeFeedSnapshot { String get generatedAtLabel => _time(generatedAt); } +List _scheduleCardsFor({ + required TimetableSnapshot? timetable, + required DateTime now, +}) { + final events = timetable?.eventsOn(now) ?? const []; + return events.map((event) { + final hoursUntil = event.start.isAfter(now) + ? (event.start.difference(now).inMinutes / 60).ceil() + : 0; + return FeedScheduleCard( + id: event.id, + courseName: event.title, + type: _lectureType(event.detail), + timeRange: event.timeRangeText, + hoursUntil: hoursUntil, + address: event.location, + llmSummary: null, + ); + }).toList()..sort((a, b) { + final hours = a.hoursUntil.compareTo(b.hoursUntil); + if (hours != 0) return hours; + return a.courseName.compareTo(b.courseName); + }); +} + +String? _lectureType(String? detail) { + final normalized = detail?.toLowerCase(); + if (normalized == null || normalized.isEmpty) return null; + if (normalized.contains('tutorial') || normalized.contains('übung')) { + return 'Tutorial'; + } + if (normalized.contains('lecture') || normalized.contains('vorlesung')) { + return 'Lecture'; + } + if (normalized.contains('seminar')) return 'Seminar'; + return null; +} + class FeedMessage { const FeedMessage({required this.title, required this.body}); diff --git a/flutter_app/lib/src/views/home_view.dart b/flutter_app/lib/src/views/home_view.dart index 0f88fb2..3085e7c 100644 --- a/flutter_app/lib/src/views/home_view.dart +++ b/flutter_app/lib/src/views/home_view.dart @@ -20,6 +20,7 @@ class HomeView extends StatelessWidget { required this.onOpenMaps, required this.onOpenCampus, required this.onOpenSchedule, + required this.onAskAssistant, required this.onRefresh, super.key, }); @@ -37,6 +38,7 @@ class HomeView extends StatelessWidget { final VoidCallback onOpenMaps; final VoidCallback onOpenCampus; final VoidCallback onOpenSchedule; + final ValueChanged onAskAssistant; final Future Function() onRefresh; @override @@ -54,7 +56,11 @@ class HomeView extends StatelessWidget { const SizedBox(height: StudyOsSpacing.xxl), _SectionLabel(label: 'For you'), const SizedBox(height: StudyOsSpacing.sm), - ProactiveFeedSection(snapshot: snapshot, onRefresh: onRefresh), + ProactiveFeedSection( + snapshot: snapshot, + onRefresh: onRefresh, + onAskAssistant: onAskAssistant, + ), const SizedBox(height: StudyOsSpacing.xxl), _SectionLabel(label: 'StudyOS'), const SizedBox(height: StudyOsSpacing.sm), diff --git a/flutter_app/lib/src/widgets/feed_detail_sheet.dart b/flutter_app/lib/src/widgets/feed_detail_sheet.dart new file mode 100644 index 0000000..4fa4e17 --- /dev/null +++ b/flutter_app/lib/src/widgets/feed_detail_sheet.dart @@ -0,0 +1,344 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../models.dart'; +import '../studyos_theme.dart'; + +Future showScheduleFeedDetails( + BuildContext context, + FeedScheduleCard item, + ValueChanged onAskAssistant, +) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => _FeedDetailSheet( + title: item.courseName, + subtitle: [ + if (item.type != null) item.type!, + item.timeRange, + item.timeToNextLabel, + ].join(' · '), + headerColor: StudyOsColors.accent, + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AddressMapPanel(address: item.address), + if (item.llmSummary?.trim().isNotEmpty == true) ...[ + const SizedBox(height: StudyOsSpacing.md), + Text( + item.llmSummary!, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ], + ), + actions: <_FeedAction>[ + _FeedAction( + icon: Icons.auto_awesome_rounded, + label: 'Ask AI', + onTap: () { + Navigator.of(context).pop(); + onAskAssistant('Help me prepare for ${item.courseName}.'); + }, + ), + _FeedAction( + icon: Icons.send_rounded, + label: 'Send', + onTap: () { + Navigator.of(context).pop(); + onAskAssistant('Summarize this lecture card: ${item.courseName}.'); + }, + ), + _FeedAction( + icon: Icons.navigation_rounded, + label: 'Navigate', + onTap: () { + Navigator.of(context).pop(); + _openNavigation(item.address ?? item.courseName); + }, + ), + ], + ), + ); +} + +Future showArticleFeedDetails( + BuildContext context, + FeedArticleCard article, + ValueChanged onAskAssistant, +) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => _FeedDetailSheet( + title: article.title, + subtitle: article.sourceLabel, + headerColor: StudyOsColors.text, + body: Text(article.body, style: Theme.of(context).textTheme.bodyLarge), + actions: <_FeedAction>[ + _FeedAction( + icon: Icons.auto_awesome_rounded, + label: 'Ask AI', + onTap: () { + Navigator.of(context).pop(); + onAskAssistant( + 'Summarize this Tübingen highlight: ${article.title}', + ); + }, + ), + _FeedAction( + icon: Icons.send_rounded, + label: 'Send', + onTap: () { + Navigator.of(context).pop(); + onAskAssistant('Draft a short share text for: ${article.title}'); + }, + ), + ], + ), + ); +} + +Future showEmailFeedDetails( + BuildContext context, + FeedEmailCard email, + ValueChanged onAskAssistant, +) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => _FeedDetailSheet( + title: email.subject, + subtitle: email.sender, + headerColor: StudyOsColors.warning, + body: Text(email.preview, style: Theme.of(context).textTheme.bodyLarge), + actions: <_FeedAction>[ + _FeedAction( + icon: Icons.auto_awesome_rounded, + label: 'Ask AI', + onTap: () { + Navigator.of(context).pop(); + onAskAssistant( + 'Extract action items from this email: ${email.subject}', + ); + }, + ), + _FeedAction( + icon: Icons.send_rounded, + label: 'Send', + onTap: () { + Navigator.of(context).pop(); + onAskAssistant('Draft a reply for this email: ${email.subject}'); + }, + ), + ], + ), + ); +} + +class _FeedDetailSheet extends StatelessWidget { + const _FeedDetailSheet({ + required this.title, + required this.subtitle, + required this.headerColor, + required this.body, + required this.actions, + }); + + final String title; + final String subtitle; + final Color headerColor; + final Widget body; + final List<_FeedAction> actions; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: ClipRRect( + borderRadius: BorderRadius.circular(StudyOsRadii.lg), + child: Material( + color: StudyOsColors.surface, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _FeedDetailHeader( + title: title, + subtitle: subtitle, + color: headerColor, + ), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(StudyOsSpacing.xl), + child: body, + ), + ), + _FeedDetailActions(actions: actions), + ], + ), + ), + ), + ), + ); + } +} + +class _FeedDetailHeader extends StatelessWidget { + const _FeedDetailHeader({ + required this.title, + required this.subtitle, + required this.color, + }); + + final String title; + final String subtitle; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + color: color, + padding: const EdgeInsets.all(StudyOsSpacing.xl), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: StudyOsSpacing.xs), + Text(subtitle, style: const TextStyle(color: Color(0xFFE5E5EA))), + ], + ), + ); + } +} + +class _FeedDetailActions extends StatelessWidget { + const _FeedDetailActions({required this.actions}); + + final List<_FeedAction> actions; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB( + StudyOsSpacing.xl, + 0, + StudyOsSpacing.xl, + StudyOsSpacing.xl, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + for (final action in actions) ...[ + IconButton.filledTonal( + tooltip: action.label, + onPressed: action.onTap, + icon: Icon(action.icon), + ), + const SizedBox(width: StudyOsSpacing.sm), + ], + ], + ), + ); + } +} + +class _AddressMapPanel extends StatelessWidget { + const _AddressMapPanel({required this.address}); + + final String? address; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + height: 220, + decoration: BoxDecoration( + color: StudyOsColors.background, + borderRadius: BorderRadius.circular(StudyOsRadii.md), + border: Border.all(color: StudyOsColors.border), + ), + child: Stack( + children: [ + Positioned.fill(child: CustomPaint(painter: _MapGridPainter())), + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.location_on_rounded, + size: 36, + color: StudyOsColors.accent, + ), + const SizedBox(height: StudyOsSpacing.sm), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.lg, + ), + child: Text( + address?.trim().isNotEmpty == true + ? address! + : 'No address available yet', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelLarge, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _MapGridPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = StudyOsColors.border.withValues(alpha: 0.7) + ..strokeWidth = 1; + for (double x = 0; x < size.width; x += 32) { + canvas.drawLine(Offset(x, 0), Offset(x + 42, size.height), paint); + } + for (double y = 0; y < size.height; y += 34) { + canvas.drawLine(Offset(0, y), Offset(size.width, y + 24), paint); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +class _FeedAction { + const _FeedAction({ + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; +} + +Future _openNavigation(String destination) async { + final uri = Uri.https('www.google.com', '/maps/dir/', { + 'api': '1', + 'destination': destination, + }); + await launchUrl(uri, mode: LaunchMode.externalApplication); +} diff --git a/flutter_app/lib/src/widgets/proactive_feed_section.dart b/flutter_app/lib/src/widgets/proactive_feed_section.dart index db29018..98afa0b 100644 --- a/flutter_app/lib/src/widgets/proactive_feed_section.dart +++ b/flutter_app/lib/src/widgets/proactive_feed_section.dart @@ -2,189 +2,405 @@ import 'package:flutter/material.dart'; import '../models.dart'; import '../studyos_theme.dart'; +import 'feed_detail_sheet.dart'; class ProactiveFeedSection extends StatelessWidget { const ProactiveFeedSection({ required this.snapshot, required this.onRefresh, + required this.onAskAssistant, super.key, }); final HomeFeedSnapshot snapshot; final Future Function() onRefresh; + final ValueChanged onAskAssistant; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _FeedSectionTitle( + title: 'Today’s Schedule', + trailing: snapshot.isStale ? 'stale' : snapshot.generatedAtLabel, + ), + const SizedBox(height: StudyOsSpacing.sm), + _ScheduleSection( + items: snapshot.todaySchedule, + onAskAssistant: onAskAssistant, + ), + const SizedBox(height: StudyOsSpacing.xxl), + const _FeedSectionTitle(title: 'Highlights Tübingen'), + const SizedBox(height: StudyOsSpacing.sm), + _ArticleSection( + articles: snapshot.highlights, + emptyText: 'News could not be loaded.', + onAskAssistant: onAskAssistant, + ), + const SizedBox(height: StudyOsSpacing.xxl), + const _FeedSectionTitle(title: 'Emails'), + const SizedBox(height: StudyOsSpacing.sm), + _EmailSection( + emails: snapshot.emails, + onRefresh: onRefresh, + onAskAssistant: onAskAssistant, + ), + ], + ); + } +} + +class _FeedSectionTitle extends StatelessWidget { + const _FeedSectionTitle({required this.title, this.trailing}); + + final String title; + final String? trailing; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + if (trailing != null) + Text( + trailing!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: StudyOsColors.textMuted, + fontSize: 13, + ), + ), + ], + ); + } +} + +class _ScheduleSection extends StatelessWidget { + const _ScheduleSection({required this.items, required this.onAskAssistant}); + + final List items; + final ValueChanged onAskAssistant; + + @override + Widget build(BuildContext context) { + if (items.isEmpty) { + return const _EmptyDayCard(); + } + return Column( + children: [ + for (final item in items) ...[ + _ScheduleTile(item: item, onAskAssistant: onAskAssistant), + const SizedBox(height: StudyOsSpacing.sm), + ], + ], + ); + } +} + +class _EmptyDayCard extends StatelessWidget { + const _EmptyDayCard(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.xl, + vertical: StudyOsSpacing.xxl, + ), + decoration: BoxDecoration( + color: StudyOsColors.surface, + borderRadius: BorderRadius.circular(StudyOsRadii.md), + border: Border.all(color: StudyOsColors.border), + ), + child: Text( + 'No lectures today, have a great day', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontSize: 24, + height: 1.1, + fontWeight: FontWeight.w900, + ), + ), + ); + } +} + +class _ScheduleTile extends StatelessWidget { + const _ScheduleTile({required this.item, required this.onAskAssistant}); + + final FeedScheduleCard item; + final ValueChanged onAskAssistant; @override Widget build(BuildContext context) { - final nextAction = snapshot.nextAction; return Material( - color: StudyOsColors.surface, + color: StudyOsColors.text.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(StudyOsRadii.md), - child: Padding( - padding: const EdgeInsets.all(StudyOsSpacing.xl), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon( - Icons.auto_awesome, - color: StudyOsColors.accent, - size: 20, + child: InkWell( + borderRadius: BorderRadius.circular(StudyOsRadii.md), + onTap: () => showScheduleFeedDetails(context, item, onAskAssistant), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: Row( + children: [ + Expanded( + child: Text( + item.courseName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelLarge, ), + ), + if (item.type != null) ...[ 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}', - ), + _CompactPill(label: item.type!), ], - ), - 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(width: StudyOsSpacing.sm), + Text( + item.timeToNextLabel, + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: StudyOsColors.accent), + ), ], - const SizedBox(height: StudyOsSpacing.lg), - Wrap( - spacing: StudyOsSpacing.sm, - runSpacing: StudyOsSpacing.sm, - children: [ - for (final source in snapshot.sources) - _SourceFreshnessPill(source: source), - ], - ), - ], + ), + ), + ), + ); + } +} + +class _ArticleSection extends StatelessWidget { + const _ArticleSection({ + required this.articles, + required this.emptyText, + required this.onAskAssistant, + }); + + final List articles; + final String emptyText; + final ValueChanged onAskAssistant; + + @override + Widget build(BuildContext context) { + if (articles.isEmpty) { + return _UnavailableCard(text: emptyText); + } + return Column( + children: [ + for (final article in articles) ...[ + _ArticleTile(article: article, onAskAssistant: onAskAssistant), + const SizedBox(height: StudyOsSpacing.sm), + ], + ], + ); + } +} + +class _ArticleTile extends StatelessWidget { + const _ArticleTile({required this.article, required this.onAskAssistant}); + + final FeedArticleCard article; + final ValueChanged onAskAssistant; + + @override + Widget build(BuildContext context) { + return Material( + color: StudyOsColors.surface, + borderRadius: BorderRadius.circular(StudyOsRadii.md), + child: InkWell( + borderRadius: BorderRadius.circular(StudyOsRadii.md), + onTap: () => showArticleFeedDetails(context, article, onAskAssistant), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: Row( + children: [ + _ArticleImage(imageUrl: article.imageUrl), + const SizedBox(width: StudyOsSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + article.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: StudyOsSpacing.xs), + Text( + article.sourceLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ], + ), ), ), ); } } -class _NextActionTile extends StatelessWidget { - const _NextActionTile({required this.action, required this.onRefresh}); +class _EmailSection extends StatelessWidget { + const _EmailSection({ + required this.emails, + required this.onRefresh, + required this.onAskAssistant, + }); - final NextAction action; + final List emails; final Future Function() onRefresh; + final ValueChanged onAskAssistant; + + @override + Widget build(BuildContext context) { + if (emails.isEmpty) { + return _UnavailableCard( + text: 'Email highlights could not be loaded.', + actionLabel: 'Refresh', + onAction: onRefresh, + ); + } + return Column( + children: [ + for (final email in emails) ...[ + _EmailTile(email: email, onAskAssistant: onAskAssistant), + const SizedBox(height: StudyOsSpacing.sm), + ], + ], + ); + } +} + +class _EmailTile extends StatelessWidget { + const _EmailTile({required this.email, required this.onAskAssistant}); + + final FeedEmailCard email; + final ValueChanged onAskAssistant; @override Widget build(BuildContext context) { - final isRefresh = action.label == 'Refresh'; return Material( - color: StudyOsColors.accent.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(StudyOsRadii.sm), - child: Padding( - padding: const EdgeInsets.all(StudyOsSpacing.md), - child: Row( - children: [ - const Icon(Icons.arrow_upward_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, - ), - ], + color: StudyOsColors.surface, + borderRadius: BorderRadius.circular(StudyOsRadii.md), + child: InkWell( + borderRadius: BorderRadius.circular(StudyOsRadii.md), + onTap: () => showEmailFeedDetails(context, email, onAskAssistant), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: Row( + children: [ + Icon( + email.isUnread + ? Icons.mark_email_unread_outlined + : Icons.mail_outline_rounded, + color: StudyOsColors.accent, ), - ), - if (isRefresh) ...[ - const SizedBox(width: StudyOsSpacing.sm), - TextButton( - onPressed: () => onRefresh(), - style: TextButton.styleFrom( - foregroundColor: StudyOsColors.accent, + const SizedBox(width: StudyOsSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + email.subject, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: StudyOsSpacing.xs), + Text( + email.sender, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], ), - child: Text(action.label), ), ], - ], + ), ), ), ); } } -class _UrgentItemTile extends StatelessWidget { - const _UrgentItemTile({required this.item}); +class _UnavailableCard extends StatelessWidget { + const _UnavailableCard({required this.text, this.actionLabel, this.onAction}); - final UrgentItem item; + final String text; + final String? actionLabel; + final Future Function()? onAction; @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: StudyOsSpacing.sm), + return Container( + width: double.infinity, + padding: const EdgeInsets.all(StudyOsSpacing.lg), + decoration: BoxDecoration( + color: StudyOsColors.text.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), 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, + text, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: StudyOsColors.textMuted, + fontWeight: FontWeight.w600, + ), ), ), + if (actionLabel != null && onAction != null) + TextButton(onPressed: () => onAction!(), child: Text(actionLabel!)), ], ), ); } } -class _SourceFreshnessPill extends StatelessWidget { - const _SourceFreshnessPill({required this.source}); +class _ArticleImage extends StatelessWidget { + const _ArticleImage({this.imageUrl}); - final HomeFeedSourceFreshness source; + final String? imageUrl; @override Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: StudyOsColors.background, - borderRadius: BorderRadius.circular(999), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: StudyOsSpacing.sm, - vertical: StudyOsSpacing.xs, - ), - child: Text( - '${source.label}: ${source.statusLabel}', - style: Theme.of(context).textTheme.bodyMedium, - ), + final url = imageUrl; + return ClipRRect( + borderRadius: BorderRadius.circular(StudyOsRadii.sm), + child: SizedBox( + width: 88, + height: 72, + child: url == null || url.isEmpty + ? ColoredBox( + color: StudyOsColors.accent.withValues(alpha: 0.12), + child: const Icon( + Icons.article_outlined, + color: StudyOsColors.accent, + ), + ) + : Image.network(url, fit: BoxFit.cover), ), ); } } -class _TimeLeftPill extends StatelessWidget { - const _TimeLeftPill({required this.label}); +class _CompactPill extends StatelessWidget { + const _CompactPill({required this.label}); final String label; @@ -192,7 +408,7 @@ class _TimeLeftPill extends StatelessWidget { Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( - color: StudyOsColors.accent.withValues(alpha: 0.10), + color: StudyOsColors.surface.withValues(alpha: 0.85), borderRadius: BorderRadius.circular(999), ), child: Padding( @@ -202,10 +418,9 @@ class _TimeLeftPill extends StatelessWidget { ), child: Text( label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: StudyOsColors.accent, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 12, + color: StudyOsColors.text, fontWeight: FontWeight.w700, ), ), diff --git a/flutter_app/test/feed_summary_test.dart b/flutter_app/test/feed_summary_test.dart index 4647c70..8b0800a 100644 --- a/flutter_app/test/feed_summary_test.dart +++ b/flutter_app/test/feed_summary_test.dart @@ -50,9 +50,59 @@ void main() { 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.todaySchedule.single.courseName, 'Machine Learning'); + expect(snapshot.todaySchedule.single.type, isNull); + expect(snapshot.todaySchedule.single.timeToNextLabel, '1 h'); expect(snapshot.sources.first.status, HomeFeedSourceStatus.fresh); }); + test('home feed schedule cards are sorted and typed for template UI', () { + 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: 'tutorial', + title: 'ML Practice', + start: DateTime(2026, 7, 1, 13), + end: DateTime(2026, 7, 1, 15), + location: 'Sand 14', + detail: 'Tutorial', + ), + LectureEvent( + id: 'lecture', + title: 'ML Lecture', + start: DateTime(2026, 7, 1, 10), + end: DateTime(2026, 7, 1, 12), + location: 'Room A', + detail: 'Lecture', + ), + ], + ), + memoryText: '', + now: now, + ); + + expect(snapshot.todaySchedule.map((item) => item.courseName), [ + 'ML Lecture', + 'ML Practice', + ]); + expect(snapshot.todaySchedule.first.type, 'Lecture'); + expect(snapshot.todaySchedule.last.type, 'Tutorial'); + expect(snapshot.highlights, isEmpty); + expect(snapshot.emails, isEmpty); + }); + test('home feed does not duplicate the next lecture action as urgent', () { final now = DateTime(2026, 7, 1, 9); final snapshot = HomeFeedSnapshot.fromLocalState( diff --git a/flutter_app/test/navigation_test.dart b/flutter_app/test/navigation_test.dart index 1579d22..17e2ed1 100644 --- a/flutter_app/test/navigation_test.dart +++ b/flutter_app/test/navigation_test.dart @@ -195,15 +195,17 @@ void main() { onOpenMaps: () {}, onOpenCampus: () {}, onOpenSchedule: () {}, + onAskAssistant: (_) {}, onRefresh: () async {}, ), ), ), ); - expect(find.text('Set up your StudyOS'), findsOneWidget); - expect(find.textContaining('Connect your profile'), findsOneWidget); - expect(find.text('Timetable: Unavailable'), findsOneWidget); + expect(find.text('Today’s Schedule'), findsOneWidget); + expect(find.text('No lectures today, have a great day'), findsOneWidget); + expect(find.text('Highlights Tübingen'), findsOneWidget); + expect(find.text('News could not be loaded.'), findsOneWidget); expect(find.text('For you'), findsOneWidget); await tester.scrollUntilVisible( find.text('StudyOS'), From dd9444b3f38216fc69e79df9703b0507908a572e Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Fri, 17 Jul 2026 12:26:35 +0000 Subject: [PATCH 2/7] fix(feed): refine for you card presentation --- flutter_app/lib/src/views/home_view.dart | 2 - .../src/widgets/proactive_feed_section.dart | 96 ++++++++++++------- flutter_app/test/navigation_test.dart | 62 ++++++++++++ 3 files changed, 124 insertions(+), 36 deletions(-) diff --git a/flutter_app/lib/src/views/home_view.dart b/flutter_app/lib/src/views/home_view.dart index 3085e7c..2c89b23 100644 --- a/flutter_app/lib/src/views/home_view.dart +++ b/flutter_app/lib/src/views/home_view.dart @@ -54,8 +54,6 @@ class HomeView extends StatelessWidget { const SizedBox(height: StudyOsSpacing.xxl), _TodayFocus(next: next, onTap: onOpenSchedule), const SizedBox(height: StudyOsSpacing.xxl), - _SectionLabel(label: 'For you'), - const SizedBox(height: StudyOsSpacing.sm), ProactiveFeedSection( snapshot: snapshot, onRefresh: onRefresh, diff --git a/flutter_app/lib/src/widgets/proactive_feed_section.dart b/flutter_app/lib/src/widgets/proactive_feed_section.dart index 98afa0b..4f4c1ac 100644 --- a/flutter_app/lib/src/widgets/proactive_feed_section.dart +++ b/flutter_app/lib/src/widgets/proactive_feed_section.dart @@ -18,35 +18,51 @@ class ProactiveFeedSection extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _FeedSectionTitle( - title: 'Today’s Schedule', - trailing: snapshot.isStale ? 'stale' : snapshot.generatedAtLabel, - ), - const SizedBox(height: StudyOsSpacing.sm), - _ScheduleSection( - items: snapshot.todaySchedule, - onAskAssistant: onAskAssistant, - ), - const SizedBox(height: StudyOsSpacing.xxl), - const _FeedSectionTitle(title: 'Highlights Tübingen'), - const SizedBox(height: StudyOsSpacing.sm), - _ArticleSection( - articles: snapshot.highlights, - emptyText: 'News could not be loaded.', - onAskAssistant: onAskAssistant, - ), - const SizedBox(height: StudyOsSpacing.xxl), - const _FeedSectionTitle(title: 'Emails'), - const SizedBox(height: StudyOsSpacing.sm), - _EmailSection( - emails: snapshot.emails, - onRefresh: onRefresh, - onAskAssistant: onAskAssistant, + return Material( + color: StudyOsColors.text.withValues(alpha: 0.045), + borderRadius: BorderRadius.circular(StudyOsRadii.lg), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'For you', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontSize: 28, + height: 1.05, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: StudyOsSpacing.lg), + _FeedSectionTitle( + title: 'Today’s Schedule', + trailing: snapshot.isStale ? 'stale' : snapshot.generatedAtLabel, + ), + const SizedBox(height: StudyOsSpacing.sm), + _ScheduleSection( + items: snapshot.todaySchedule, + onAskAssistant: onAskAssistant, + ), + const SizedBox(height: StudyOsSpacing.xl), + const _FeedSectionTitle(title: 'Highlights Tübingen'), + const SizedBox(height: StudyOsSpacing.sm), + _ArticleSection( + articles: snapshot.highlights, + emptyText: 'News could not be loaded.', + onAskAssistant: onAskAssistant, + ), + const SizedBox(height: StudyOsSpacing.xl), + const _FeedSectionTitle(title: 'Emails'), + const SizedBox(height: StudyOsSpacing.sm), + _EmailSection( + emails: snapshot.emails, + onRefresh: onRefresh, + onAskAssistant: onAskAssistant, + ), + ], ), - ], + ), ); } } @@ -94,9 +110,10 @@ class _ScheduleSection extends StatelessWidget { if (items.isEmpty) { return const _EmptyDayCard(); } + final visibleItems = items.take(1); return Column( children: [ - for (final item in items) ...[ + for (final item in visibleItems) ...[ _ScheduleTile(item: item, onAskAssistant: onAskAssistant), const SizedBox(height: StudyOsSpacing.sm), ], @@ -269,7 +286,8 @@ class _EmailSection extends StatelessWidget { if (emails.isEmpty) { return _UnavailableCard( text: 'Email highlights could not be loaded.', - actionLabel: 'Refresh', + actionIcon: Icons.refresh_rounded, + actionTooltip: 'Refresh', onAction: onRefresh, ); } @@ -338,10 +356,16 @@ class _EmailTile extends StatelessWidget { } class _UnavailableCard extends StatelessWidget { - const _UnavailableCard({required this.text, this.actionLabel, this.onAction}); + const _UnavailableCard({ + required this.text, + this.actionIcon, + this.actionTooltip, + this.onAction, + }); final String text; - final String? actionLabel; + final IconData? actionIcon; + final String? actionTooltip; final Future Function()? onAction; @override @@ -364,8 +388,12 @@ class _UnavailableCard extends StatelessWidget { ), ), ), - if (actionLabel != null && onAction != null) - TextButton(onPressed: () => onAction!(), child: Text(actionLabel!)), + if (actionIcon != null && onAction != null) + IconButton( + tooltip: actionTooltip, + onPressed: () => onAction!(), + icon: Icon(actionIcon, color: StudyOsColors.accent), + ), ], ), ); diff --git a/flutter_app/test/navigation_test.dart b/flutter_app/test/navigation_test.dart index 17e2ed1..ef50700 100644 --- a/flutter_app/test/navigation_test.dart +++ b/flutter_app/test/navigation_test.dart @@ -218,6 +218,68 @@ void main() { expect(find.byType(RefreshIndicator), findsOneWidget); }); + testWidgets('home feed card previews one lecture and icon refresh', ( + WidgetTester tester, + ) async { + final now = DateTime(2026, 7, 1, 9); + final timetable = TimetableSnapshot( + refreshedAt: now, + sourceTerm: 'Summer 2026', + events: [ + LectureEvent( + id: 'lecture', + title: 'ML Lecture', + start: DateTime(2026, 7, 1, 10), + end: DateTime(2026, 7, 1, 12), + detail: 'Lecture', + ), + LectureEvent( + id: 'practice', + title: 'ML Practice', + start: DateTime(2026, 7, 1, 13), + end: DateTime(2026, 7, 1, 15), + detail: 'Tutorial', + ), + ], + ); + + await tester.pumpWidget( + MaterialApp( + theme: buildStudyOsTheme(), + home: Scaffold( + body: HomeView( + profile: null, + config: const AgentConfig.defaults(), + snapshot: HomeFeedSnapshot.fromLocalState( + profile: null, + timetable: timetable, + memoryText: '', + now: now, + ), + memoryText: '', + timetable: timetable, + onOpenProfile: () {}, + onOpenAssistant: () {}, + onOpenNotes: () {}, + onOpenTalks: () {}, + onOpenMail: () {}, + onOpenMaps: () {}, + onOpenCampus: () {}, + onOpenSchedule: () {}, + onAskAssistant: (_) {}, + onRefresh: () async {}, + ), + ), + ), + ); + + expect(find.text('For you'), findsOneWidget); + expect(find.text('ML Lecture'), findsWidgets); + expect(find.text('ML Practice'), findsNothing); + expect(find.text('Refresh'), findsNothing); + expect(find.byIcon(Icons.refresh_rounded), findsOneWidget); + }); + test( 'home feed snapshot summarizes the next lecture from structured state', () { From 327f130127dbd334ffa025f6a4a17bcde0ba26bd Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Fri, 17 Jul 2026 12:46:40 +0000 Subject: [PATCH 3/7] feat(feed): add assistant brief greeting --- flutter_app/lib/src/feed_summary.dart | 56 +++++++++++++++++++ .../src/widgets/proactive_feed_section.dart | 51 +++++++++++++++++ flutter_app/test/feed_summary_test.dart | 3 + flutter_app/test/navigation_test.dart | 2 + 4 files changed, 112 insertions(+) diff --git a/flutter_app/lib/src/feed_summary.dart b/flutter_app/lib/src/feed_summary.dart index 688c8f5..d446727 100644 --- a/flutter_app/lib/src/feed_summary.dart +++ b/flutter_app/lib/src/feed_summary.dart @@ -14,6 +14,20 @@ class DailySummary { final String body; } +class ForYouAssistantBrief { + const ForYouAssistantBrief({ + required this.text, + this.isGenerating = false, + this.llmSummary, + }); + + final String text; + final bool isGenerating; + + /// Template slot for the local LLM to overwrite this compact feed greeting. + final String? llmSummary; +} + class NextAction { const NextAction({ required this.title, @@ -132,6 +146,7 @@ class FeedEmailCard { class HomeFeedSnapshot { const HomeFeedSnapshot({ + required this.assistantBrief, required this.summary, required this.nextAction, required this.urgentItems, @@ -192,6 +207,12 @@ class HomeFeedSnapshot { final todaySchedule = _scheduleCardsFor(timetable: timetable, now: now); return HomeFeedSnapshot( + assistantBrief: _assistantBriefFor( + profile: profile, + nextLecture: nextLecture, + todayCount: todayCount, + now: now, + ), summary: summary, nextAction: nextAction, urgentItems: List.unmodifiable(urgentItems), @@ -205,6 +226,7 @@ class HomeFeedSnapshot { ); } + final ForYouAssistantBrief assistantBrief; final DailySummary summary; final NextAction nextAction; final List urgentItems; @@ -258,6 +280,40 @@ String? _lectureType(String? detail) { return null; } +ForYouAssistantBrief _assistantBriefFor({ + required OnboardingProfile? profile, + required LectureEvent? nextLecture, + required int todayCount, + required DateTime now, +}) { + if (profile == null) { + return const ForYouAssistantBrief( + text: + 'Hi, I’m setting up your feed. Tübingen fact: the Neckarfront still wins at golden hour.', + isGenerating: true, + ); + } + if (nextLecture != null) { + final count = todayCount == 1 ? 'one lecture' : '$todayCount lectures'; + return ForYouAssistantBrief( + text: + 'Good ${_dayPart(now)}. I see $count today. Next is ${nextLecture.title} ${nextLecture.relativeTimeLabel(now)}.', + isGenerating: true, + ); + } + return ForYouAssistantBrief( + text: + 'Good ${_dayPart(now)}. I’m checking campus updates; no lecture is blocking your day right now.', + isGenerating: true, + ); +} + +String _dayPart(DateTime now) { + if (now.hour < 12) return 'morning'; + if (now.hour < 18) return 'afternoon'; + return 'evening'; +} + class FeedMessage { const FeedMessage({required this.title, required this.body}); diff --git a/flutter_app/lib/src/widgets/proactive_feed_section.dart b/flutter_app/lib/src/widgets/proactive_feed_section.dart index 4f4c1ac..5486cc5 100644 --- a/flutter_app/lib/src/widgets/proactive_feed_section.dart +++ b/flutter_app/lib/src/widgets/proactive_feed_section.dart @@ -34,6 +34,8 @@ class ProactiveFeedSection extends StatelessWidget { fontWeight: FontWeight.w900, ), ), + const SizedBox(height: StudyOsSpacing.sm), + _AssistantBriefCard(brief: snapshot.assistantBrief), const SizedBox(height: StudyOsSpacing.lg), _FeedSectionTitle( title: 'Today’s Schedule', @@ -67,6 +69,55 @@ class ProactiveFeedSection extends StatelessWidget { } } +class _AssistantBriefCard extends StatelessWidget { + const _AssistantBriefCard({required this.brief}); + + final ForYouAssistantBrief brief; + + @override + Widget build(BuildContext context) { + final llmText = brief.llmSummary?.trim(); + final text = llmText == null || llmText.isEmpty ? brief.text : llmText; + + return DecoratedBox( + decoration: BoxDecoration( + color: StudyOsColors.surface.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + border: Border.all(color: StudyOsColors.border), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.md, + vertical: StudyOsSpacing.sm, + ), + child: Row( + children: [ + Icon( + brief.isGenerating + ? Icons.auto_awesome_outlined + : Icons.check_circle_outline_rounded, + color: StudyOsColors.accent, + size: 18, + ), + const SizedBox(width: StudyOsSpacing.sm), + Expanded( + child: Text( + text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: StudyOsColors.text, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } +} + class _FeedSectionTitle extends StatelessWidget { const _FeedSectionTitle({required this.title, this.trailing}); diff --git a/flutter_app/test/feed_summary_test.dart b/flutter_app/test/feed_summary_test.dart index 8b0800a..1b34004 100644 --- a/flutter_app/test/feed_summary_test.dart +++ b/flutter_app/test/feed_summary_test.dart @@ -13,6 +13,8 @@ void main() { expect(snapshot.summary.title, 'Set up your StudyOS'); expect(snapshot.summary.body, contains('Connect your profile')); expect(snapshot.nextAction.title, 'Complete profile'); + expect(snapshot.assistantBrief.text, contains('setting up your feed')); + expect(snapshot.assistantBrief.isGenerating, isTrue); expect(snapshot.sources.first.status, HomeFeedSourceStatus.unavailable); }); @@ -50,6 +52,7 @@ void main() { 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.assistantBrief.text, contains('Next is Machine Learning')); expect(snapshot.todaySchedule.single.courseName, 'Machine Learning'); expect(snapshot.todaySchedule.single.type, isNull); expect(snapshot.todaySchedule.single.timeToNextLabel, '1 h'); diff --git a/flutter_app/test/navigation_test.dart b/flutter_app/test/navigation_test.dart index ef50700..952d328 100644 --- a/flutter_app/test/navigation_test.dart +++ b/flutter_app/test/navigation_test.dart @@ -207,6 +207,7 @@ void main() { expect(find.text('Highlights Tübingen'), findsOneWidget); expect(find.text('News could not be loaded.'), findsOneWidget); expect(find.text('For you'), findsOneWidget); + expect(find.textContaining('setting up your feed'), findsOneWidget); await tester.scrollUntilVisible( find.text('StudyOS'), 300, @@ -274,6 +275,7 @@ void main() { ); expect(find.text('For you'), findsOneWidget); + expect(find.textContaining('Next is ML Lecture'), findsOneWidget); expect(find.text('ML Lecture'), findsWidgets); expect(find.text('ML Practice'), findsNothing); expect(find.text('Refresh'), findsNothing); From 6fbde1246a50251a19ff698fa9762b52c94a5ea6 Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Fri, 17 Jul 2026 12:48:33 +0000 Subject: [PATCH 4/7] test(feed): cover assistant brief with profile --- flutter_app/test/navigation_test.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/flutter_app/test/navigation_test.dart b/flutter_app/test/navigation_test.dart index 952d328..6965f48 100644 --- a/flutter_app/test/navigation_test.dart +++ b/flutter_app/test/navigation_test.dart @@ -243,16 +243,24 @@ void main() { ), ], ); + const profile = OnboardingProfile( + displayName: 'Ada', + username: 'ada42', + email: null, + degreeProgram: 'M.Sc. AI', + semester: 2, + livesInTuebingen: true, + ); await tester.pumpWidget( MaterialApp( theme: buildStudyOsTheme(), home: Scaffold( body: HomeView( - profile: null, + profile: profile, config: const AgentConfig.defaults(), snapshot: HomeFeedSnapshot.fromLocalState( - profile: null, + profile: profile, timetable: timetable, memoryText: '', now: now, From e5f3fb015b92b25ba3cac2fb85e9dc329cd55575 Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Fri, 17 Jul 2026 13:19:43 +0000 Subject: [PATCH 5/7] fix(feed): simplify unloaded empty states --- .../lib/src/widgets/proactive_feed_section.dart | 15 +++++---------- flutter_app/test/navigation_test.dart | 5 +++++ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/flutter_app/lib/src/widgets/proactive_feed_section.dart b/flutter_app/lib/src/widgets/proactive_feed_section.dart index 5486cc5..90c8547 100644 --- a/flutter_app/lib/src/widgets/proactive_feed_section.dart +++ b/flutter_app/lib/src/widgets/proactive_feed_section.dart @@ -260,7 +260,7 @@ class _ArticleSection extends StatelessWidget { @override Widget build(BuildContext context) { if (articles.isEmpty) { - return _UnavailableCard(text: emptyText); + return _UnavailableText(text: emptyText); } return Column( children: [ @@ -335,7 +335,7 @@ class _EmailSection extends StatelessWidget { @override Widget build(BuildContext context) { if (emails.isEmpty) { - return _UnavailableCard( + return _UnavailableText( text: 'Email highlights could not be loaded.', actionIcon: Icons.refresh_rounded, actionTooltip: 'Refresh', @@ -406,8 +406,8 @@ class _EmailTile extends StatelessWidget { } } -class _UnavailableCard extends StatelessWidget { - const _UnavailableCard({ +class _UnavailableText extends StatelessWidget { + const _UnavailableText({ required this.text, this.actionIcon, this.actionTooltip, @@ -421,13 +421,8 @@ class _UnavailableCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( + return SizedBox( width: double.infinity, - padding: const EdgeInsets.all(StudyOsSpacing.lg), - decoration: BoxDecoration( - color: StudyOsColors.text.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(StudyOsRadii.md), - ), child: Row( children: [ Expanded( diff --git a/flutter_app/test/navigation_test.dart b/flutter_app/test/navigation_test.dart index 6965f48..75250b7 100644 --- a/flutter_app/test/navigation_test.dart +++ b/flutter_app/test/navigation_test.dart @@ -206,6 +206,11 @@ void main() { expect(find.text('No lectures today, have a great day'), findsOneWidget); expect(find.text('Highlights Tübingen'), findsOneWidget); expect(find.text('News could not be loaded.'), findsOneWidget); + final newsText = find.text('News could not be loaded.'); + expect( + find.ancestor(of: newsText, matching: find.byType(DecoratedBox)), + findsNothing, + ); expect(find.text('For you'), findsOneWidget); expect(find.textContaining('setting up your feed'), findsOneWidget); await tester.scrollUntilVisible( From 41e10181de3c46f5143c49f2bd0cb82f448e9ea5 Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Fri, 17 Jul 2026 13:39:34 +0000 Subject: [PATCH 6/7] fix(feed): remove nested empty state cards --- .../src/widgets/proactive_feed_section.dart | 74 +++++++------------ flutter_app/test/navigation_test.dart | 10 +++ 2 files changed, 36 insertions(+), 48 deletions(-) diff --git a/flutter_app/lib/src/widgets/proactive_feed_section.dart b/flutter_app/lib/src/widgets/proactive_feed_section.dart index 90c8547..1be4ace 100644 --- a/flutter_app/lib/src/widgets/proactive_feed_section.dart +++ b/flutter_app/lib/src/widgets/proactive_feed_section.dart @@ -35,7 +35,7 @@ class ProactiveFeedSection extends StatelessWidget { ), ), const SizedBox(height: StudyOsSpacing.sm), - _AssistantBriefCard(brief: snapshot.assistantBrief), + _AssistantBrief(brief: snapshot.assistantBrief), const SizedBox(height: StudyOsSpacing.lg), _FeedSectionTitle( title: 'Today’s Schedule', @@ -69,8 +69,8 @@ class ProactiveFeedSection extends StatelessWidget { } } -class _AssistantBriefCard extends StatelessWidget { - const _AssistantBriefCard({required this.brief}); +class _AssistantBrief extends StatelessWidget { + const _AssistantBrief({required this.brief}); final ForYouAssistantBrief brief; @@ -79,41 +79,28 @@ class _AssistantBriefCard extends StatelessWidget { final llmText = brief.llmSummary?.trim(); final text = llmText == null || llmText.isEmpty ? brief.text : llmText; - return DecoratedBox( - decoration: BoxDecoration( - color: StudyOsColors.surface.withValues(alpha: 0.82), - borderRadius: BorderRadius.circular(StudyOsRadii.md), - border: Border.all(color: StudyOsColors.border), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: StudyOsSpacing.md, - vertical: StudyOsSpacing.sm, + return Row( + children: [ + Icon( + brief.isGenerating + ? Icons.auto_awesome_outlined + : Icons.check_circle_outline_rounded, + color: StudyOsColors.accent, + size: 18, ), - child: Row( - children: [ - Icon( - brief.isGenerating - ? Icons.auto_awesome_outlined - : Icons.check_circle_outline_rounded, - color: StudyOsColors.accent, - size: 18, - ), - const SizedBox(width: StudyOsSpacing.sm), - Expanded( - child: Text( - text, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: StudyOsColors.text, - fontWeight: FontWeight.w600, - ), - ), + const SizedBox(width: StudyOsSpacing.sm), + Expanded( + child: Text( + text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: StudyOsColors.text, + fontWeight: FontWeight.w600, ), - ], + ), ), - ), + ], ); } } @@ -159,7 +146,7 @@ class _ScheduleSection extends StatelessWidget { @override Widget build(BuildContext context) { if (items.isEmpty) { - return const _EmptyDayCard(); + return const _EmptyDayText(); } final visibleItems = items.take(1); return Column( @@ -173,22 +160,13 @@ class _ScheduleSection extends StatelessWidget { } } -class _EmptyDayCard extends StatelessWidget { - const _EmptyDayCard(); +class _EmptyDayText extends StatelessWidget { + const _EmptyDayText(); @override Widget build(BuildContext context) { - return Container( + return SizedBox( width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: StudyOsSpacing.xl, - vertical: StudyOsSpacing.xxl, - ), - decoration: BoxDecoration( - color: StudyOsColors.surface, - borderRadius: BorderRadius.circular(StudyOsRadii.md), - border: Border.all(color: StudyOsColors.border), - ), child: Text( 'No lectures today, have a great day', style: Theme.of(context).textTheme.titleMedium?.copyWith( diff --git a/flutter_app/test/navigation_test.dart b/flutter_app/test/navigation_test.dart index 75250b7..741f999 100644 --- a/flutter_app/test/navigation_test.dart +++ b/flutter_app/test/navigation_test.dart @@ -204,6 +204,11 @@ void main() { expect(find.text('Today’s Schedule'), findsOneWidget); expect(find.text('No lectures today, have a great day'), findsOneWidget); + final emptyScheduleText = find.text('No lectures today, have a great day'); + expect( + find.ancestor(of: emptyScheduleText, matching: find.byType(DecoratedBox)), + findsNothing, + ); expect(find.text('Highlights Tübingen'), findsOneWidget); expect(find.text('News could not be loaded.'), findsOneWidget); final newsText = find.text('News could not be loaded.'); @@ -213,6 +218,11 @@ void main() { ); expect(find.text('For you'), findsOneWidget); expect(find.textContaining('setting up your feed'), findsOneWidget); + final assistantText = find.textContaining('setting up your feed'); + expect( + find.ancestor(of: assistantText, matching: find.byType(DecoratedBox)), + findsNothing, + ); await tester.scrollUntilVisible( find.text('StudyOS'), 300, From 9c22f3e1f2e342e5a240d7bf1b8bc1ef7f7a2538 Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Fri, 17 Jul 2026 13:51:03 +0000 Subject: [PATCH 7/7] fix(feed): soften empty schedule text --- flutter_app/lib/src/widgets/proactive_feed_section.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flutter_app/lib/src/widgets/proactive_feed_section.dart b/flutter_app/lib/src/widgets/proactive_feed_section.dart index 1be4ace..0e4a780 100644 --- a/flutter_app/lib/src/widgets/proactive_feed_section.dart +++ b/flutter_app/lib/src/widgets/proactive_feed_section.dart @@ -169,10 +169,10 @@ class _EmptyDayText extends StatelessWidget { width: double.infinity, child: Text( 'No lectures today, have a great day', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontSize: 24, - height: 1.1, - fontWeight: FontWeight.w900, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontSize: 16, + height: 1.35, + fontWeight: FontWeight.w400, ), ), );