From 37da4224c11a274331635b4e98026a0f5e133b62 Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Fri, 17 Jul 2026 11:56:46 +0000 Subject: [PATCH] feat(feed): add template card sections --- flutter_app/lib/src/app_router.dart | 5 + flutter_app/lib/src/feed_summary.dart | 122 +++++ flutter_app/lib/src/views/home_view.dart | 8 +- .../lib/src/widgets/feed_detail_sheet.dart | 341 +++++++++++++ .../src/widgets/proactive_feed_section.dart | 469 +++++++++++++----- flutter_app/test/feed_summary_test.dart | 50 ++ flutter_app/test/navigation_test.dart | 8 +- 7 files changed, 878 insertions(+), 125 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..036045e 100644 --- a/flutter_app/lib/src/app_router.dart +++ b/flutter_app/lib/src/app_router.dart @@ -229,6 +229,11 @@ 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..163c6af --- /dev/null +++ b/flutter_app/lib/src/widgets/feed_detail_sheet.dart @@ -0,0 +1,341 @@ +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..1e532c9 100644 --- a/flutter_app/lib/src/widgets/proactive_feed_section.dart +++ b/flutter_app/lib/src/widgets/proactive_feed_section.dart @@ -2,189 +2,417 @@ 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, - ), + _CompactPill(label: item.type!), + ], + const SizedBox(width: StudyOsSpacing.sm), + Text( + item.timeToNextLabel, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: StudyOsColors.accent, ), - _TimeLeftPill( - label: snapshot.isStale - ? 'Stale' - : 'Updated ${snapshot.generatedAtLabel}', + ), + ], + ), + ), + ), + ); + } +} + +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, + ), + ], ), - ], - ), - 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.lg), - 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}); +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 +420,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 +430,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..a198aa3 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'),