From c19d6cdd36d42f260c6f52fdbe3a40b80e2e1b31 Mon Sep 17 00:00:00 2001 From: SebastianBoehler <27767932+SebastianBoehler@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:26:44 +0200 Subject: [PATCH] feat(ui): redesign native study workspace --- flutter_app/lib/src/studyos_theme.dart | 104 +++- flutter_app/lib/src/views/campus_view.dart | 46 +- flutter_app/lib/src/views/chat_route.dart | 63 ++- flutter_app/lib/src/views/home_view.dart | 484 ++++++++++-------- flutter_app/lib/src/views/mail_view.dart | 42 +- .../lib/src/views/mail_view_components.dart | 150 +++--- flutter_app/lib/src/views/memories_view.dart | 3 +- flutter_app/lib/src/views/schedule_view.dart | 76 ++- flutter_app/lib/src/views/shell_scaffold.dart | 23 +- flutter_app/lib/src/widgets/composer_bar.dart | 14 +- flutter_app/lib/src/widgets/message_list.dart | 12 +- .../src/widgets/proactive_feed_section.dart | 45 +- .../lib/src/widgets/schedule_components.dart | 78 ++- .../lib/src/widgets/settings_card.dart | 5 +- .../lib/src/widgets/shell_app_bar.dart | 138 +++-- .../lib/src/widgets/study_bottom_bar.dart | 174 +++---- flutter_app/test/navigation_test.dart | 44 +- 17 files changed, 858 insertions(+), 643 deletions(-) diff --git a/flutter_app/lib/src/studyos_theme.dart b/flutter_app/lib/src/studyos_theme.dart index 7cb6bd2..9fa52bc 100644 --- a/flutter_app/lib/src/studyos_theme.dart +++ b/flutter_app/lib/src/studyos_theme.dart @@ -1,16 +1,18 @@ import 'package:flutter/material.dart'; abstract final class StudyOsColors { - static const Color background = Color(0xFF070A0F); - static const Color surface = Color(0xFF111821); - static const Color surfaceRaised = Color(0xFF17212D); - static const Color border = Color(0xFF253142); - static const Color text = Color(0xFFF3F6FA); - static const Color textMuted = Color(0xFF94A3B8); - static const Color accent = Color(0xFF5EA8FF); - static const Color accentStrong = Color(0xFF2F80ED); - static const Color success = Color(0xFF3DDC97); - static const Color warning = Color(0xFFF4B860); + static const Color background = Color(0xFFF5F5F7); + static const Color surface = Color(0xFFFFFFFF); + static const Color surfaceRaised = Color(0xFFFFFFFF); + static const Color border = Color(0xFFE5E5EA); + static const Color separator = Color(0xFFC6C6C8); + static const Color text = Color(0xFF1D1D1F); + static const Color textMuted = Color(0xFF6E6E73); + static const Color accent = Color(0xFF007AFF); + static const Color accentStrong = Color(0xFF0066D6); + static const Color success = Color(0xFF34C759); + static const Color warning = Color(0xFFFF9500); + static const Color destructive = Color(0xFFFF3B30); } abstract final class StudyOsSpacing { @@ -19,19 +21,22 @@ abstract final class StudyOsSpacing { static const double md = 12; static const double lg = 16; static const double xl = 24; + static const double xxl = 32; } abstract final class StudyOsRadii { - static const double sm = 8; - static const double md = 14; - static const double lg = 18; + static const double sm = 10; + static const double md = 16; + static const double lg = 22; } ThemeData buildStudyOsTheme() { - final scheme = ColorScheme.fromSeed( - seedColor: StudyOsColors.accent, - brightness: Brightness.dark, + const scheme = ColorScheme.light( + primary: StudyOsColors.accent, + onPrimary: Colors.white, surface: StudyOsColors.surface, + onSurface: StudyOsColors.text, + error: StudyOsColors.destructive, ); return ThemeData( @@ -39,37 +44,88 @@ ThemeData buildStudyOsTheme() { scaffoldBackgroundColor: StudyOsColors.background, fontFamily: 'SF Pro Text', useMaterial3: true, + splashFactory: InkSparkle.splashFactory, textTheme: const TextTheme( headlineSmall: TextStyle( color: StudyOsColors.text, - fontSize: 24, - height: 1.15, + fontSize: 34, + height: 1.08, fontWeight: FontWeight.w700, + letterSpacing: -0.7, ), titleMedium: TextStyle( color: StudyOsColors.text, - fontSize: 16, + fontSize: 17, height: 1.25, fontWeight: FontWeight.w700, + letterSpacing: -0.2, ), bodyLarge: TextStyle( color: StudyOsColors.text, fontSize: 16, - height: 1.45, + height: 1.35, fontWeight: FontWeight.w400, ), bodyMedium: TextStyle( color: StudyOsColors.textMuted, - fontSize: 14, - height: 1.4, + fontSize: 15, + height: 1.3, fontWeight: FontWeight.w400, ), labelLarge: TextStyle( color: StudyOsColors.text, - fontSize: 14, - height: 1.2, + fontSize: 15, + height: 1.25, fontWeight: FontWeight.w700, ), ), + dividerTheme: const DividerThemeData( + color: StudyOsColors.border, + thickness: 0.5, + space: 1, + ), + bottomAppBarTheme: const BottomAppBarThemeData( + surfaceTintColor: Colors.transparent, + elevation: 0, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: StudyOsColors.surface, + contentPadding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.md, + vertical: StudyOsSpacing.md, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(StudyOsRadii.sm), + borderSide: const BorderSide(color: StudyOsColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(StudyOsRadii.sm), + borderSide: const BorderSide(color: StudyOsColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(StudyOsRadii.sm), + borderSide: const BorderSide(color: StudyOsColors.accent, width: 1.5), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: StudyOsColors.accent, + foregroundColor: Colors.white, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + textStyle: const TextStyle(fontWeight: FontWeight.w700), + ), + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: StudyOsColors.text, + contentTextStyle: const TextStyle(color: Colors.white), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + ), ); } diff --git a/flutter_app/lib/src/views/campus_view.dart b/flutter_app/lib/src/views/campus_view.dart index 2d2d2a3..469625f 100644 --- a/flutter_app/lib/src/views/campus_view.dart +++ b/flutter_app/lib/src/views/campus_view.dart @@ -61,7 +61,7 @@ class _CampusViewState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Campus', + 'Mensa', style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: StudyOsSpacing.xs), @@ -105,7 +105,29 @@ class _CampusViewState extends State { } return Column( children: [ - for (final canteen in canteens) _CanteenCard(canteen: canteen), + Material( + color: StudyOsColors.surface, + borderRadius: BorderRadius.circular(StudyOsRadii.md), + child: ClipRRect( + borderRadius: BorderRadius.circular(StudyOsRadii.md), + child: Column( + children: [ + for ( + var index = 0; + index < canteens.length; + index++ + ) ...[ + _CanteenCard(canteen: canteens[index]), + if (index < canteens.length - 1) + const Padding( + padding: EdgeInsets.only(left: StudyOsSpacing.lg), + child: Divider(), + ), + ], + ], + ), + ), + ), ], ); }, @@ -123,15 +145,8 @@ class _CanteenCard extends StatelessWidget { @override Widget build(BuildContext context) { final menu = canteen.menus.first; - return Container( - width: double.infinity, - margin: const EdgeInsets.only(bottom: StudyOsSpacing.md), + return Padding( padding: const EdgeInsets.all(StudyOsSpacing.lg), - decoration: BoxDecoration( - color: StudyOsColors.surface, - border: Border.all(color: StudyOsColors.border), - borderRadius: BorderRadius.circular(StudyOsRadii.md), - ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -152,13 +167,9 @@ class _CanteenCard extends StatelessWidget { ), if (menu.icons.isNotEmpty) ...[ const SizedBox(height: StudyOsSpacing.md), - Wrap( - spacing: StudyOsSpacing.sm, - runSpacing: StudyOsSpacing.sm, - children: [ - for (final icon in menu.icons.take(4)) - Chip(label: Text(icon), visualDensity: VisualDensity.compact), - ], + Text( + menu.icons.take(4).join(' · '), + style: Theme.of(context).textTheme.bodyMedium, ), ], ], @@ -191,7 +202,6 @@ class _CampusMessage extends StatelessWidget { padding: const EdgeInsets.all(StudyOsSpacing.lg), decoration: BoxDecoration( color: StudyOsColors.surface, - border: Border.all(color: StudyOsColors.border), borderRadius: BorderRadius.circular(StudyOsRadii.md), ), child: Column( diff --git a/flutter_app/lib/src/views/chat_route.dart b/flutter_app/lib/src/views/chat_route.dart index 33c8528..e0ce471 100644 --- a/flutter_app/lib/src/views/chat_route.dart +++ b/flutter_app/lib/src/views/chat_route.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import '../app_shell_controller.dart'; import '../app_shell_scope.dart'; import '../chat_session_mutation.dart'; import '../studyos_theme.dart'; @@ -62,27 +63,20 @@ class _ChatRouteState extends State { builder: (context, _) { final controller = AppShellScope.of(context); return Scaffold( - endDrawer: Drawer( - backgroundColor: StudyOsColors.background, - child: ConversationList( - sessions: controller.sessions, - activeSessionId: controller.activeSessionId, - onSelectSession: controller.selectSession, - onCreateSession: controller.createSession, - onDeleteSession: controller.deleteSession, - ), - ), body: SafeArea( child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 760), child: Padding( padding: const EdgeInsets.symmetric( - horizontal: StudyOsSpacing.lg, + horizontal: StudyOsSpacing.xl, ), child: Column( children: [ - _ChatHeader(onCreateSession: controller.createSession), + _ChatHeader( + onCreateSession: controller.createSession, + onShowHistory: () => _showHistory(context, controller), + ), Expanded( child: ChatView( messages: activeSessionFrom( @@ -111,12 +105,43 @@ class _ChatRouteState extends State { }, ); } + + void _showHistory(BuildContext context, AppShellController controller) { + showModalBottomSheet( + context: context, + backgroundColor: StudyOsColors.surface, + showDragHandle: true, + isScrollControlled: true, + builder: (context) => SafeArea( + child: SizedBox( + height: MediaQuery.sizeOf(context).height * 0.72, + child: ConversationList( + sessions: controller.sessions, + activeSessionId: controller.activeSessionId, + onSelectSession: (session) { + controller.selectSession(session); + Navigator.of(context).pop(); + }, + onCreateSession: () { + controller.createSession(); + Navigator.of(context).pop(); + }, + onDeleteSession: controller.deleteSession, + ), + ), + ), + ); + } } class _ChatHeader extends StatelessWidget { - const _ChatHeader({required this.onCreateSession}); + const _ChatHeader({ + required this.onCreateSession, + required this.onShowHistory, + }); final VoidCallback onCreateSession; + final VoidCallback onShowHistory; @override Widget build(BuildContext context) { @@ -138,18 +163,16 @@ class _ChatHeader extends StatelessWidget { const SizedBox(width: StudyOsSpacing.md), Expanded( child: Text( - 'StudyOS Agent', + 'Assistant', maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleLarge, ), ), - Builder( - builder: (context) => _HeaderIconButton( - tooltip: 'Chat history', - icon: Icons.history_rounded, - onPressed: Scaffold.of(context).openEndDrawer, - ), + _HeaderIconButton( + tooltip: 'Chat history', + icon: Icons.history_rounded, + onPressed: onShowHistory, ), const SizedBox(width: StudyOsSpacing.sm), _HeaderIconButton( diff --git a/flutter_app/lib/src/views/home_view.dart b/flutter_app/lib/src/views/home_view.dart index f867f75..2aff90b 100644 --- a/flutter_app/lib/src/views/home_view.dart +++ b/flutter_app/lib/src/views/home_view.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import '../assistant_copy.dart'; import '../models.dart'; import '../studyos_theme.dart'; -import '../widgets/generated_ui_preview_section.dart'; import '../widgets/proactive_feed_section.dart'; class HomeView extends StatelessWidget { @@ -40,95 +39,87 @@ class HomeView extends StatelessWidget { @override Widget build(BuildContext context) { - final profile = this.profile; + final next = timetable?.nextLectureAt(DateTime.now()); return RefreshIndicator( onRefresh: onRefresh, child: ListView( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(top: StudyOsSpacing.xl, bottom: 96), + padding: const EdgeInsets.only(top: StudyOsSpacing.xl, bottom: 100), children: [ - _HomeHeader(profile: profile), - const SizedBox(height: StudyOsSpacing.lg), + _Greeting(profile: profile), + 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), - const SizedBox(height: StudyOsSpacing.xl), - const GeneratedUiPreviewSection(), - const SizedBox(height: StudyOsSpacing.xl), - _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, + const SizedBox(height: StudyOsSpacing.xxl), + _SectionLabel(label: 'Study tools'), + const SizedBox(height: StudyOsSpacing.sm), + _GroupedList( + children: [ + _ToolRow( + itemKey: const ValueKey('home-status-timetable'), + icon: Icons.calendar_today_outlined, + title: 'Schedule', + detail: _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', + _ToolRow( + itemKey: const ValueKey('home-status-mail'), + icon: Icons.mail_outline_rounded, + title: 'Inbox', + detail: profile == null ? 'Sign in needed' : 'University mail', onTap: onOpenMail, ), - _HomeStatusItem( - cardKey: const ValueKey('home-status-map'), + _ToolRow( + itemKey: const ValueKey('home-campus-card'), + icon: Icons.restaurant_outlined, + title: 'Campus', + detail: _campusLine(profile), + onTap: onOpenCampus, + ), + _ToolRow( + itemKey: const ValueKey('home-maps-card'), + containerKey: const ValueKey('home-status-map'), icon: Icons.map_outlined, - label: 'Map', - value: 'Tübingen', + title: 'Map', + detail: 'Find your way around Tübingen', onTap: onOpenMaps, ), ], ), - const SizedBox(height: StudyOsSpacing.lg), - _HomeCard( - icon: Icons.event_available_outlined, - title: 'Next lecture', - body: _nextLectureLine(), - trailing: _nextLectureCountdown(), - onTap: onOpenSchedule, - ), - const SizedBox(height: StudyOsSpacing.md), - _HomeCard( - icon: Icons.mail_outline_rounded, - title: 'Inbox', - body: 'Browse folders or ask the agent to search recent mail.', - onTap: onOpenMail, - ), - const SizedBox(height: StudyOsSpacing.md), - _HomeCard( - key: const ValueKey('home-maps-card'), - icon: Icons.map_outlined, - title: 'Navigate', - body: 'Search destinations and ask StudyOS about routes.', - onTap: onOpenMaps, - ), - const SizedBox(height: StudyOsSpacing.md), - _HomeCard( - key: const ValueKey('home-campus-card'), - icon: Icons.restaurant_outlined, - title: 'Campus', - body: _campusLine(profile), - onTap: onOpenCampus, + const SizedBox(height: StudyOsSpacing.xxl), + _SectionLabel(label: 'StudyOS'), + const SizedBox(height: StudyOsSpacing.sm), + _GroupedList( + children: [ + _ToolRow( + itemKey: const ValueKey('home-status-profile'), + icon: Icons.person_outline_rounded, + title: 'Profile', + detail: profile == null + ? 'Complete your student profile' + : 'Your study details', + onTap: onOpenProfile, + ), + _ToolRow( + itemKey: const ValueKey('home-status-assistant'), + icon: Icons.auto_awesome_outlined, + title: 'Assistant', + detail: assistantSetupLabel(config), + onTap: onOpenAssistant, + ), + _ToolRow( + itemKey: const ValueKey('home-status-notes'), + icon: Icons.note_alt_outlined, + title: 'Notes', + detail: memoryText.trim().isEmpty + ? 'Nothing saved yet' + : 'Personal context saved', + onTap: onOpenNotes, + ), + ], ), ], ), @@ -137,213 +128,270 @@ class HomeView extends StatelessWidget { String get _timetableStatus { final snapshot = timetable; - if (snapshot == null) return 'Not synced yet'; - return snapshot.isStale ? 'Refresh due' : 'Synced'; - } - - String _nextLectureLine() { - final next = timetable?.nextLectureAt(DateTime.now()); - if (next == null) return 'Refresh your timetable to see upcoming lectures.'; - final location = next.location == null ? '' : ' · ${next.location}'; - return '${next.dayLabel} · ${next.timeRangeText}$location'; - } - - Widget? _nextLectureCountdown() { - final now = DateTime.now(); - final next = timetable?.nextLectureAt(now); - if (next == null) return null; - return _TimeLeftPill(label: next.relativeTimeLabel(now)); + if (snapshot == null) return 'Sync your ALMA timetable'; + return snapshot.isStale ? 'Refresh available' : 'Up to date'; } String _campusLine(OnboardingProfile? profile) { if (profile?.interests.contains(StudyInterest.mensa) == true) { final preference = profile!.foodPreference; - if (preference == FoodPreference.noPreference) { - return 'Mensa meals will appear here when campus data is connected.'; + if (preference != FoodPreference.noPreference) { + return '${preference.label} Mensa choices'; } - return '${preference.label} Mensa options will appear here.'; } - return 'Mensa, rooms, and campus shortcuts will appear here.'; + return 'Mensa menus and essentials'; } } -class _HomeHeader extends StatelessWidget { - const _HomeHeader({required this.profile}); +class _Greeting extends StatelessWidget { + const _Greeting({required this.profile}); final OnboardingProfile? profile; @override Widget build(BuildContext context) { - final profile = this.profile; + final hour = DateTime.now().hour; + final greeting = hour < 12 + ? 'Good morning' + : hour < 18 + ? 'Good afternoon' + : 'Good evening'; + final name = profile?.displayName; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - profile == null ? 'Home' : 'Hi ${profile.displayName}', + '$greeting${name == null ? '' : ', $name'}', style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: StudyOsSpacing.xs), - Text( - profile == null - ? 'Connect your student profile to personalize StudyOS.' - : _profileLine(profile), - style: Theme.of(context).textTheme.bodyMedium, - ), + Text(_dateLabel(), style: Theme.of(context).textTheme.bodyMedium), ], ); } - String _profileLine(OnboardingProfile profile) { - final parts = [ - profile.degreeProgram, - if (profile.semester != null) 'Semester ${profile.semester}', + String _dateLabel() { + const weekdays = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]; + const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', ]; - return parts.join(' · '); + final now = DateTime.now(); + return '${weekdays[now.weekday - 1]}, ${months[now.month - 1]} ${now.day}'; } } -class _StatusGrid extends StatelessWidget { - const _StatusGrid({required this.items}); +class _TodayFocus extends StatelessWidget { + const _TodayFocus({required this.next, required this.onTap}); - final List<_HomeStatusItem> items; + final LectureEvent? next; + final VoidCallback onTap; @override Widget build(BuildContext context) { - return GridView.count( - crossAxisCount: MediaQuery.sizeOf(context).width < 520 ? 2 : 4, - childAspectRatio: 1.25, - mainAxisSpacing: StudyOsSpacing.md, - crossAxisSpacing: StudyOsSpacing.md, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - children: [ - for (final item in items) - _HomeCard( - key: item.cardKey, - icon: item.icon, - title: item.label, - body: item.value, - onTap: item.onTap, - ), - ], + final now = DateTime.now(); + final lecture = next; + return Material( + color: StudyOsColors.text, + borderRadius: BorderRadius.circular(StudyOsRadii.lg), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(StudyOsRadii.lg), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.xl), + child: lecture == null + ? const _FocusContent( + eyebrow: 'TODAY', + title: 'Your day is clear.', + detail: 'Sync your timetable to see what is next.', + trailing: Icons.calendar_today_outlined, + ) + : _FocusContent( + eyebrow: lecture.relativeTimeLabel(now).toUpperCase(), + title: lecture.title, + detail: + '${lecture.timeRangeText}${lecture.location == null ? '' : ' · ${lecture.location}'}', + trailing: Icons.arrow_forward_rounded, + ), + ), + ), ); } } -class _HomeStatusItem { - const _HomeStatusItem({ - required this.cardKey, - required this.icon, - required this.label, - required this.value, - required this.onTap, +class _FocusContent extends StatelessWidget { + const _FocusContent({ + required this.eyebrow, + required this.title, + required this.detail, + required this.trailing, }); - final Key cardKey; - final IconData icon; + final String eyebrow; + final String title; + final String detail; + final IconData trailing; + + @override + Widget build(BuildContext context) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + eyebrow, + style: const TextStyle( + color: Color(0xFFAEAEB2), + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: StudyOsSpacing.sm), + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 22, + height: 1.12, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + ), + ), + const SizedBox(height: StudyOsSpacing.sm), + Text( + detail, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFD1D1D6), fontSize: 15), + ), + ], + ), + ), + const SizedBox(width: StudyOsSpacing.md), + Icon(trailing, color: Colors.white, size: 23), + ], + ); +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); final String label; - final String value; - final VoidCallback onTap; + + @override + Widget build(BuildContext context) => Text( + label, + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontSize: 19), + ); } -class _HomeCard extends StatelessWidget { - const _HomeCard({ +class _GroupedList extends StatelessWidget { + const _GroupedList({required this.children}); + final List children; + + @override + Widget build(BuildContext context) => ClipRRect( + borderRadius: BorderRadius.circular(StudyOsRadii.md), + child: Material( + color: StudyOsColors.surface, + child: Column(children: children), + ), + ); +} + +class _ToolRow extends StatelessWidget { + const _ToolRow({ + required this.itemKey, + this.containerKey, required this.icon, required this.title, - required this.body, - this.trailing, - this.onTap, - super.key, + required this.detail, + required this.onTap, }); + final Key itemKey; + final Key? containerKey; final IconData icon; final String title; - final String body; - final Widget? trailing; - final VoidCallback? onTap; + final String detail; + final VoidCallback onTap; @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( - side: const BorderSide(color: StudyOsColors.border), - borderRadius: BorderRadius.circular(StudyOsRadii.md), - ), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(StudyOsRadii.md), - child: Padding( - padding: const EdgeInsets.all(StudyOsSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, size: 32, color: StudyOsColors.accent), - const Spacer(), - ?effectiveTrailing, - ], - ), - Column( + Widget build(BuildContext context) => Container( + key: containerKey, + child: InkWell( + key: itemKey, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.md, + vertical: StudyOsSpacing.md, + ), + child: Row( + children: [ + _ToolIcon(icon: icon), + const SizedBox(width: StudyOsSpacing.md), + Expanded( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: Theme.of(context).textTheme.labelLarge), - const SizedBox(height: StudyOsSpacing.xs), + const SizedBox(height: 2), Text( - body, - maxLines: 2, + detail, + maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium, ), ], ), - ], - ), + ), + const Icon( + Icons.chevron_right_rounded, + color: StudyOsColors.separator, + ), + ], ), ), - ); - } + ), + ); } -class _TimeLeftPill extends StatelessWidget { - const _TimeLeftPill({required this.label}); - - final String label; +class _ToolIcon extends StatelessWidget { + const _ToolIcon({required this.icon}); + final IconData icon; @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, - ), - ), - ), - ); - } + Widget build(BuildContext context) => Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: StudyOsColors.accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + alignment: Alignment.center, + child: Icon(icon, color: StudyOsColors.accent, size: 19), + ); } diff --git a/flutter_app/lib/src/views/mail_view.dart b/flutter_app/lib/src/views/mail_view.dart index 9c64dec..5378b43 100644 --- a/flutter_app/lib/src/views/mail_view.dart +++ b/flutter_app/lib/src/views/mail_view.dart @@ -84,12 +84,12 @@ class _MailViewState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Mail', + 'Inbox', style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: StudyOsSpacing.xs), Text( - 'University mailbox, folders, and read-only message access.', + 'Your university mail, in one place.', style: Theme.of(context).textTheme.bodyMedium, ), ], @@ -161,14 +161,38 @@ class _MailViewState extends State { body: 'Try another folder or turn off the unread filter.', ) else - for (final message in state.inbox.messages) - _MailSummaryCard( - message: message, - selected: - _selectedMessage?.uid == message.uid || - _openingMessageUid == message.uid, - onTap: () => _openMessage(message), + Material( + color: StudyOsColors.surface, + borderRadius: BorderRadius.circular(StudyOsRadii.md), + child: ClipRRect( + borderRadius: BorderRadius.circular(StudyOsRadii.md), + child: Column( + children: [ + for ( + var index = 0; + index < state.inbox.messages.length; + index++ + ) ...[ + _MailSummaryCard( + message: state.inbox.messages[index], + selected: + _selectedMessage?.uid == + state.inbox.messages[index].uid || + _openingMessageUid == + state.inbox.messages[index].uid, + onTap: () => + _openMessage(state.inbox.messages[index]), + ), + if (index < state.inbox.messages.length - 1) + const Padding( + padding: EdgeInsets.only(left: 62), + child: Divider(), + ), + ], + ], + ), ), + ), ], ); }, diff --git a/flutter_app/lib/src/views/mail_view_components.dart b/flutter_app/lib/src/views/mail_view_components.dart index 5581467..08cac9b 100644 --- a/flutter_app/lib/src/views/mail_view_components.dart +++ b/flutter_app/lib/src/views/mail_view_components.dart @@ -29,8 +29,11 @@ class _MailControls extends StatelessWidget { ] : mailboxes; return Container( - padding: const EdgeInsets.all(StudyOsSpacing.md), - decoration: _cardDecoration, + padding: const EdgeInsets.symmetric(horizontal: StudyOsSpacing.md), + decoration: BoxDecoration( + color: StudyOsColors.surface, + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), child: Row( children: [ Expanded( @@ -54,11 +57,18 @@ class _MailControls extends StatelessWidget { }, ), ), - const SizedBox(width: StudyOsSpacing.md), - FilterChip( - label: const Text('Unread'), - selected: unreadOnly, - onSelected: onUnreadOnlyChanged, + const SizedBox(width: StudyOsSpacing.sm), + IconButton( + tooltip: unreadOnly ? 'Show all messages' : 'Show unread only', + onPressed: () => onUnreadOnlyChanged(!unreadOnly), + icon: Icon( + unreadOnly + ? Icons.mark_email_unread_rounded + : Icons.mark_email_read_outlined, + color: unreadOnly + ? StudyOsColors.accent + : StudyOsColors.textMuted, + ), ), ], ), @@ -79,81 +89,74 @@ class _MailSummaryCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: StudyOsSpacing.md), - child: Material( - color: StudyOsColors.surface, - shape: RoundedRectangleBorder( - side: BorderSide( - color: selected ? StudyOsColors.accent : StudyOsColors.border, + return Material( + color: selected + ? StudyOsColors.accent.withValues(alpha: 0.08) + : StudyOsColors.surface, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.md, + vertical: StudyOsSpacing.md, ), - borderRadius: BorderRadius.circular(StudyOsRadii.md), - ), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(StudyOsSpacing.md), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - message.isUnread - ? Icons.mark_email_unread - : Icons.mail_outline, - color: message.isUnread - ? StudyOsColors.accent - : StudyOsColors.textMuted, - ), - const SizedBox(width: StudyOsSpacing.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - message.subject, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: StudyOsSpacing.xs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + message.isUnread ? Icons.mark_email_unread : Icons.mail_outline, + color: message.isUnread + ? StudyOsColors.accent + : StudyOsColors.textMuted, + ), + const SizedBox(width: StudyOsSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + message.subject, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: StudyOsSpacing.xs), + Text( + message.senderLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + if (message.preview != null) Text( - message.senderLabel, - maxLines: 1, + message.preview!, + maxLines: 2, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium, ), - if (message.preview != null) - Text( - message.preview!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium, - ), - if (message.approvalNotice != null) - const Padding( - padding: EdgeInsets.only(top: StudyOsSpacing.xs), - child: _ApprovalBadge(), - ), - ], - ), - ), - if (message.receivedAt?.isNotEmpty == true) ...[ - const SizedBox(width: StudyOsSpacing.sm), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 72), - child: Text( - message.receivedAt!, - maxLines: 2, - textAlign: TextAlign.right, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: StudyOsColors.textMuted, + if (message.approvalNotice != null) + const Padding( + padding: EdgeInsets.only(top: StudyOsSpacing.xs), + child: _ApprovalBadge(), ), + ], + ), + ), + if (message.receivedAt?.isNotEmpty == true) ...[ + const SizedBox(width: StudyOsSpacing.sm), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 72), + child: Text( + message.receivedAt!, + maxLines: 2, + textAlign: TextAlign.right, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: StudyOsColors.textMuted, ), ), - ], + ), ], - ), + ], ), ), ), @@ -258,6 +261,5 @@ class _MailMessageCard extends StatelessWidget { final _cardDecoration = BoxDecoration( color: StudyOsColors.surface, - border: Border.all(color: StudyOsColors.border), borderRadius: BorderRadius.circular(StudyOsRadii.md), ); diff --git a/flutter_app/lib/src/views/memories_view.dart b/flutter_app/lib/src/views/memories_view.dart index 3d76ece..7e4a992 100644 --- a/flutter_app/lib/src/views/memories_view.dart +++ b/flutter_app/lib/src/views/memories_view.dart @@ -110,8 +110,7 @@ class _MemoryEditor extends StatelessWidget { padding: const EdgeInsets.all(StudyOsSpacing.lg), decoration: BoxDecoration( color: StudyOsColors.surface, - border: Border.all(color: StudyOsColors.border), - borderRadius: BorderRadius.circular(StudyOsRadii.lg), + borderRadius: BorderRadius.circular(StudyOsRadii.md), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/flutter_app/lib/src/views/schedule_view.dart b/flutter_app/lib/src/views/schedule_view.dart index 8a47a5a..575a330 100644 --- a/flutter_app/lib/src/views/schedule_view.dart +++ b/flutter_app/lib/src/views/schedule_view.dart @@ -54,7 +54,7 @@ class _ScheduleViewState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Schedule', + 'Your schedule', style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: StudyOsSpacing.xs), @@ -65,22 +65,16 @@ class _ScheduleViewState extends State { ], ), ), - IconButton( - tooltip: 'Refresh timetable', - onPressed: widget.isRefreshing ? null : widget.onRefresh, - icon: widget.isRefreshing - ? const SizedBox.square( - dimension: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh_rounded), + _RefreshButton( + isRefreshing: widget.isRefreshing, + onPressed: widget.onRefresh, ), ], ), const SizedBox(height: StudyOsSpacing.sm), Align( alignment: Alignment.centerLeft, - child: FilledButton.tonalIcon( + child: TextButton.icon( key: const ValueKey('schedule-sync-calendar'), onPressed: hasSyncableTimetable && !widget.isSyncingCalendar ? widget.onSyncCalendar @@ -91,7 +85,11 @@ class _ScheduleViewState extends State { child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.event_available_outlined), - label: Text(widget.isSyncingCalendar ? 'Syncing' : 'Sync calendar'), + label: Text( + widget.isSyncingCalendar + ? 'Syncing calendar' + : 'Sync to Calendar', + ), ), ), if (widget.error != null) ...[ @@ -144,12 +142,33 @@ class _ScheduleViewState extends State { body: 'Pick another day from the strip above.', ) else - for (var index = 0; index < events.length; index++) - ScheduleLectureCard( - event: events[index], - isFirst: index == 0, - color: scheduleColorFor(events[index].title), + Material( + color: StudyOsColors.surface, + borderRadius: BorderRadius.circular(StudyOsRadii.md), + child: ClipRRect( + borderRadius: BorderRadius.circular(StudyOsRadii.md), + child: Column( + children: [ + for ( + var index = 0; + index < events.length; + index++ + ) ...[ + ScheduleLectureCard( + event: events[index], + isFirst: index == 0, + color: scheduleColorFor(events[index].title), + ), + if (index < events.length - 1) + const Padding( + padding: EdgeInsets.only(left: 60), + child: Divider(), + ), + ], + ], + ), ), + ), ], ], ); @@ -180,3 +199,26 @@ class _ScheduleViewState extends State { : details.join(' · '); } } + +class _RefreshButton extends StatelessWidget { + const _RefreshButton({required this.isRefreshing, required this.onPressed}); + + final bool isRefreshing; + final Future Function() onPressed; + + @override + Widget build(BuildContext context) => IconButton( + tooltip: 'Refresh timetable', + onPressed: isRefreshing ? null : onPressed, + style: IconButton.styleFrom( + backgroundColor: StudyOsColors.surface, + foregroundColor: StudyOsColors.accent, + ), + icon: isRefreshing + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh_rounded), + ); +} diff --git a/flutter_app/lib/src/views/shell_scaffold.dart b/flutter_app/lib/src/views/shell_scaffold.dart index 0eb0f13..e014b78 100644 --- a/flutter_app/lib/src/views/shell_scaffold.dart +++ b/flutter_app/lib/src/views/shell_scaffold.dart @@ -3,7 +3,6 @@ import 'package:go_router/go_router.dart'; import '../app_shell_scope.dart'; import '../studyos_theme.dart'; -import '../widgets/secondary_destinations_drawer.dart'; import '../widgets/shell_app_bar.dart'; import '../widgets/study_bottom_bar.dart'; @@ -27,16 +26,11 @@ class _ShellScaffoldState extends State { listenable: AppShellScope.of(context), builder: (context, _) { return Scaffold( - drawer: const SecondaryDestinationsDrawer(), - floatingActionButton: AskFab( - onPressed: () => context.push('/chat'), - onLongPress: () => context.go('/voice'), - ), - floatingActionButtonLocation: - FloatingActionButtonLocation.centerDocked, bottomNavigationBar: StudyBottomBar( selectedIndex: selectedIndex, onDestinationSelected: _goBranch, + onAssistantPressed: () => context.push('/chat'), + onAssistantLongPress: () => context.go('/voice'), ), body: SafeArea( child: Center( @@ -44,11 +38,11 @@ class _ShellScaffoldState extends State { constraints: const BoxConstraints(maxWidth: 760), child: Padding( padding: const EdgeInsets.symmetric( - horizontal: StudyOsSpacing.lg, + horizontal: StudyOsSpacing.xl, ), child: Column( children: [ - ShellAppBar(title: _titleForIndex(selectedIndex)), + const ShellAppBar(), Expanded( child: GestureDetector( key: const ValueKey('shell-swipe-area'), @@ -100,13 +94,4 @@ class _ShellScaffoldState extends State { setState(() => _contentOffset = Offset.zero); }); } - - String _titleForIndex(int index) { - return switch (index) { - 1 => 'Schedule', - 2 => 'Mail', - 3 => 'Campus', - _ => 'Home', - }; - } } diff --git a/flutter_app/lib/src/widgets/composer_bar.dart b/flutter_app/lib/src/widgets/composer_bar.dart index a5e0f47..9e87e20 100644 --- a/flutter_app/lib/src/widgets/composer_bar.dart +++ b/flutter_app/lib/src/widgets/composer_bar.dart @@ -35,9 +35,9 @@ class ComposerBar extends StatelessWidget { borderRadius: BorderRadius.circular(StudyOsRadii.lg), boxShadow: const [ BoxShadow( - color: Color(0x66000000), - blurRadius: 22, - offset: Offset(0, 12), + color: Color(0x12000000), + blurRadius: 20, + offset: Offset(0, 8), ), ], ), @@ -71,7 +71,7 @@ class ComposerBar extends StatelessWidget { onPressed: isSending ? onStop : onSend, style: FilledButton.styleFrom( backgroundColor: StudyOsColors.accent, - foregroundColor: const Color(0xFF06101F), + foregroundColor: Colors.white, disabledBackgroundColor: StudyOsColors.border, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(StudyOsRadii.md), @@ -148,15 +148,13 @@ class _MicButton extends StatelessWidget { dimension: 46, child: DecoratedBox( decoration: BoxDecoration( - color: active - ? StudyOsColors.accent - : StudyOsColors.surfaceRaised, + color: active ? StudyOsColors.accent : StudyOsColors.background, borderRadius: BorderRadius.circular(StudyOsRadii.md), border: Border.all(color: StudyOsColors.border), ), child: Icon( icon, - color: active ? const Color(0xFF06101F) : StudyOsColors.text, + color: active ? Colors.white : StudyOsColors.text, ), ), ), diff --git a/flutter_app/lib/src/widgets/message_list.dart b/flutter_app/lib/src/widgets/message_list.dart index 0f92e07..eb12c63 100644 --- a/flutter_app/lib/src/widgets/message_list.dart +++ b/flutter_app/lib/src/widgets/message_list.dart @@ -170,10 +170,7 @@ class _MessageBubble extends StatelessWidget { compact ? StudyOsSpacing.sm : StudyOsSpacing.md, ), decoration: BoxDecoration( - color: StudyOsColors.accentStrong, - border: Border.all( - color: StudyOsColors.accent.withValues(alpha: 0.8), - ), + color: StudyOsColors.accent, borderRadius: BorderRadius.only( topLeft: const Radius.circular(StudyOsRadii.lg), topRight: const Radius.circular(StudyOsRadii.lg), @@ -181,7 +178,12 @@ class _MessageBubble extends StatelessWidget { bottomRight: const Radius.circular(StudyOsRadii.sm), ), ), - child: Text(message.text, style: Theme.of(context).textTheme.bodyLarge), + child: Text( + message.text, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: Colors.white), + ), ), ); } diff --git a/flutter_app/lib/src/widgets/proactive_feed_section.dart b/flutter_app/lib/src/widgets/proactive_feed_section.dart index 00e764b..db29018 100644 --- a/flutter_app/lib/src/widgets/proactive_feed_section.dart +++ b/flutter_app/lib/src/widgets/proactive_feed_section.dart @@ -17,23 +17,20 @@ class ProactiveFeedSection extends StatelessWidget { 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), - ), + color: StudyOsColors.surface, + borderRadius: BorderRadius.circular(StudyOsRadii.md), child: Padding( - padding: const EdgeInsets.all(StudyOsSpacing.lg), + padding: const EdgeInsets.all(StudyOsSpacing.xl), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - const Icon(Icons.auto_awesome, color: StudyOsColors.accent), + const Icon( + Icons.auto_awesome, + color: StudyOsColors.accent, + size: 20, + ), const SizedBox(width: StudyOsSpacing.sm), Expanded( child: Text( @@ -60,7 +57,7 @@ class ProactiveFeedSection extends StatelessWidget { for (final item in snapshot.urgentItems) _UrgentItemTile(item: item), ], - const SizedBox(height: StudyOsSpacing.md), + const SizedBox(height: StudyOsSpacing.lg), Wrap( spacing: StudyOsSpacing.sm, runSpacing: StudyOsSpacing.sm, @@ -86,19 +83,13 @@ class _NextActionTile extends StatelessWidget { 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), - ), + 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_forward_rounded, - color: StudyOsColors.accent, - ), + const Icon(Icons.arrow_upward_rounded, color: StudyOsColors.accent), const SizedBox(width: StudyOsSpacing.md), Expanded( child: Column( @@ -118,10 +109,12 @@ class _NextActionTile extends StatelessWidget { ), if (isRefresh) ...[ const SizedBox(width: StudyOsSpacing.sm), - IconButton( - tooltip: action.label, + TextButton( onPressed: () => onRefresh(), - icon: const Icon(Icons.refresh_rounded), + style: TextButton.styleFrom( + foregroundColor: StudyOsColors.accent, + ), + child: Text(action.label), ), ], ], @@ -173,8 +166,8 @@ class _SourceFreshnessPill extends StatelessWidget { Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( - border: Border.all(color: StudyOsColors.border), - borderRadius: BorderRadius.circular(StudyOsRadii.sm), + color: StudyOsColors.background, + borderRadius: BorderRadius.circular(999), ), child: Padding( padding: const EdgeInsets.symmetric( diff --git a/flutter_app/lib/src/widgets/schedule_components.dart b/flutter_app/lib/src/widgets/schedule_components.dart index bd601e6..13f246e 100644 --- a/flutter_app/lib/src/widgets/schedule_components.dart +++ b/flutter_app/lib/src/widgets/schedule_components.dart @@ -20,25 +20,55 @@ class ScheduleDayStrip extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( - height: 78, + height: 70, child: ListView.separated( scrollDirection: Axis.horizontal, itemBuilder: (context, index) { final day = days[index]; final selected = scheduleSameDay(day, selectedDay); final count = eventsFor(day).length; - return ChoiceChip( - selected: selected, - onSelected: (_) => onSelected(day), - label: SizedBox( - width: 68, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(scheduleShortWeekday(day), maxLines: 1), - Text('${day.day}.${day.month}.', maxLines: 1), - Text('$count', style: Theme.of(context).textTheme.bodyMedium), - ], + return Material( + color: selected ? StudyOsColors.accent : StudyOsColors.surface, + borderRadius: BorderRadius.circular(StudyOsRadii.md), + child: InkWell( + onTap: () => onSelected(day), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + child: SizedBox( + width: 62, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + scheduleShortWeekday(day), + maxLines: 1, + style: TextStyle( + color: selected + ? Colors.white + : StudyOsColors.textMuted, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + Text( + '${day.day}', + maxLines: 1, + style: TextStyle( + color: selected ? Colors.white : StudyOsColors.text, + fontSize: 19, + fontWeight: FontWeight.w700, + ), + ), + Text( + '$count classes', + style: TextStyle( + color: selected + ? Colors.white.withValues(alpha: 0.78) + : StudyOsColors.textMuted, + fontSize: 11, + ), + ), + ], + ), ), ), ); @@ -86,20 +116,17 @@ class ScheduleLectureCard extends StatelessWidget { final timeLeftColor = event.hasEndedAt(now) ? StudyOsColors.textMuted : color; - return Container( - margin: const EdgeInsets.only(bottom: StudyOsSpacing.md), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.10), - border: Border.all(color: color.withValues(alpha: 0.35)), - borderRadius: BorderRadius.circular(StudyOsRadii.md), + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.md, + vertical: StudyOsSpacing.lg, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - width: 6, - height: 112, - margin: const EdgeInsets.all(StudyOsSpacing.sm), + width: 4, + height: 82, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(999), @@ -107,12 +134,7 @@ class ScheduleLectureCard extends StatelessWidget { ), Expanded( child: Padding( - padding: const EdgeInsets.fromLTRB( - StudyOsSpacing.sm, - StudyOsSpacing.md, - StudyOsSpacing.md, - StudyOsSpacing.md, - ), + padding: const EdgeInsets.only(left: StudyOsSpacing.md), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/flutter_app/lib/src/widgets/settings_card.dart b/flutter_app/lib/src/widgets/settings_card.dart index bf808bc..dea6ba5 100644 --- a/flutter_app/lib/src/widgets/settings_card.dart +++ b/flutter_app/lib/src/widgets/settings_card.dart @@ -11,10 +11,7 @@ class SettingsCard extends StatelessWidget { Widget build(BuildContext context) { return Material( color: StudyOsColors.surface, - shape: RoundedRectangleBorder( - side: const BorderSide(color: StudyOsColors.border), - borderRadius: BorderRadius.circular(StudyOsRadii.lg), - ), + borderRadius: BorderRadius.circular(StudyOsRadii.md), clipBehavior: Clip.antiAlias, child: Padding( padding: const EdgeInsets.all(StudyOsSpacing.lg), diff --git a/flutter_app/lib/src/widgets/shell_app_bar.dart b/flutter_app/lib/src/widgets/shell_app_bar.dart index 46b7bfc..8b47fe6 100644 --- a/flutter_app/lib/src/widgets/shell_app_bar.dart +++ b/flutter_app/lib/src/widgets/shell_app_bar.dart @@ -7,37 +7,25 @@ import '../models.dart'; import '../studyos_theme.dart'; class ShellAppBar extends StatelessWidget { - const ShellAppBar({required this.title, super.key}); - - final String title; + const ShellAppBar({super.key}); @override Widget build(BuildContext context) { final controller = AppShellScope.of(context); final profile = controller.profile; - final isReady = assistantIsReady(controller.status); return Padding( - padding: const EdgeInsets.fromLTRB(0, StudyOsSpacing.lg, 0, 10), + padding: const EdgeInsets.fromLTRB(0, StudyOsSpacing.lg, 0, 8), child: Row( children: [ - _HeaderIconButton( - tooltip: 'Open menu', - icon: Icons.menu_rounded, - onPressed: Scaffold.of(context).openDrawer, - ), - const SizedBox(width: StudyOsSpacing.md), - Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleLarge, + Text( + 'StudyOS', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.4, ), ), - _StatusDot( - isReady: isReady, - label: assistantStatusLabel(controller.status), - ), + const Spacer(), + _MoreMenu(status: controller.status), const SizedBox(width: StudyOsSpacing.sm), _AvatarMenu(profile: profile, onLogout: controller.onLogout), ], @@ -46,6 +34,66 @@ class ShellAppBar extends StatelessWidget { } } +class _MoreMenu extends StatelessWidget { + const _MoreMenu({required this.status}); + + final String status; + + @override + Widget build(BuildContext context) { + return PopupMenuButton<_MoreAction>( + tooltip: 'More', + onSelected: (action) { + switch (action) { + case _MoreAction.notes: + context.push('/memories'); + break; + case _MoreAction.map: + context.push('/maps'); + break; + case _MoreAction.assistant: + context.push('/settings'); + break; + } + }, + itemBuilder: (context) => >[ + PopupMenuItem<_MoreAction>( + value: _MoreAction.notes, + child: const ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.notes_outlined), + title: Text('Notes'), + ), + ), + const PopupMenuItem<_MoreAction>( + value: _MoreAction.map, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.map_outlined), + title: Text('Map'), + ), + ), + PopupMenuItem<_MoreAction>( + value: _MoreAction.assistant, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + assistantIsReady(status) + ? Icons.auto_awesome_outlined + : Icons.error_outline_rounded, + ), + title: const Text('Assistant settings'), + ), + ), + ], + child: const _HeaderIconButton( + tooltip: 'More', + icon: Icons.more_horiz_rounded, + ), + ); + } +} + class _AvatarMenu extends StatelessWidget { const _AvatarMenu({required this.profile, required this.onLogout}); @@ -121,52 +169,30 @@ class _AvatarMenu extends StatelessWidget { } } -class _StatusDot extends StatelessWidget { - const _StatusDot({required this.isReady, required this.label}); - - final bool isReady; - final String label; - - @override - Widget build(BuildContext context) { - final color = isReady ? StudyOsColors.success : StudyOsColors.warning; - return Tooltip( - message: label, - child: Icon(Icons.circle, size: 10, color: color), - ); - } -} - class _HeaderIconButton extends StatelessWidget { - const _HeaderIconButton({ - required this.tooltip, - required this.icon, - required this.onPressed, - }); + const _HeaderIconButton({required this.tooltip, required this.icon}); final String tooltip; final IconData icon; - final VoidCallback onPressed; @override Widget build(BuildContext context) { - return SizedBox.square( - dimension: 44, - child: IconButton( - tooltip: tooltip, - style: IconButton.styleFrom( - backgroundColor: StudyOsColors.surface, - foregroundColor: StudyOsColors.text, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(StudyOsRadii.md), - side: const BorderSide(color: StudyOsColors.border), - ), + return Tooltip( + message: tooltip, + child: Container( + width: 36, + height: 36, + decoration: const BoxDecoration( + color: StudyOsColors.surface, + shape: BoxShape.circle, ), - onPressed: onPressed, - icon: Icon(icon), + alignment: Alignment.center, + child: Icon(icon, size: 21, color: StudyOsColors.text), ), ); } } enum _AvatarAction { settings, profile, logout } + +enum _MoreAction { notes, map, assistant } diff --git a/flutter_app/lib/src/widgets/study_bottom_bar.dart b/flutter_app/lib/src/widgets/study_bottom_bar.dart index 1e552ad..4c36782 100644 --- a/flutter_app/lib/src/widgets/study_bottom_bar.dart +++ b/flutter_app/lib/src/widgets/study_bottom_bar.dart @@ -6,107 +6,60 @@ class StudyBottomBar extends StatelessWidget { const StudyBottomBar({ required this.selectedIndex, required this.onDestinationSelected, + required this.onAssistantPressed, + required this.onAssistantLongPress, super.key, }); final int selectedIndex; final ValueChanged onDestinationSelected; + final VoidCallback onAssistantPressed; + final VoidCallback onAssistantLongPress; @override Widget build(BuildContext context) { return BottomAppBar( - shape: const CircularNotchedRectangle(), - notchMargin: 10, color: StudyOsColors.surface, - clipBehavior: Clip.antiAlias, - padding: const EdgeInsets.symmetric(horizontal: StudyOsSpacing.xs), + elevation: 0, + surfaceTintColor: Colors.transparent, + padding: EdgeInsets.zero, child: SizedBox( - height: 72, + height: 66, child: Row( children: [ - Expanded( - child: Row( - children: [ - _BarItem( - selected: selectedIndex == 0, - icon: Icons.home_outlined, - selectedIcon: Icons.home_rounded, - label: 'Home', - onTap: () => onDestinationSelected(0), - ), - _BarItem( - selected: selectedIndex == 1, - icon: Icons.calendar_month_outlined, - selectedIcon: Icons.calendar_month_rounded, - label: 'Schedule', - onTap: () => onDestinationSelected(1), - ), - ], - ), + _BarItem( + selected: selectedIndex == 0, + icon: Icons.home_outlined, + selectedIcon: Icons.home_rounded, + label: 'Home', + onTap: () => onDestinationSelected(0), ), - const SizedBox(width: 104), - Expanded( - child: Row( - children: [ - _BarItem( - selected: selectedIndex == 2, - icon: Icons.mail_outline_rounded, - selectedIcon: Icons.mail_rounded, - label: 'Mail', - onTap: () => onDestinationSelected(2), - ), - _BarItem( - selected: selectedIndex == 3, - icon: Icons.restaurant_outlined, - selectedIcon: Icons.restaurant_rounded, - label: 'Campus', - onTap: () => onDestinationSelected(3), - ), - ], - ), + _BarItem( + selected: selectedIndex == 1, + icon: Icons.calendar_month_outlined, + selectedIcon: Icons.calendar_month_rounded, + label: 'Schedule', + onTap: () => onDestinationSelected(1), ), - ], - ), - ), - ); - } -} - -class AskFab extends StatelessWidget { - const AskFab({required this.onPressed, required this.onLongPress, super.key}); - - final VoidCallback onPressed; - final VoidCallback onLongPress; - - @override - Widget build(BuildContext context) { - return GestureDetector( - key: const ValueKey('ask-fab-surface'), - behavior: HitTestBehavior.opaque, - onTap: onPressed, - onLongPress: onLongPress, - child: Semantics( - button: true, - label: 'Ask StudyOS', - child: Material( - elevation: 6, - color: StudyOsColors.accentStrong, - borderRadius: BorderRadius.circular(StudyOsRadii.lg), - clipBehavior: Clip.antiAlias, - child: const SizedBox( - height: 56, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: StudyOsSpacing.lg), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.auto_awesome), - SizedBox(width: StudyOsSpacing.sm), - Text('Ask'), - ], - ), + _BarItem( + selected: selectedIndex == 2, + icon: Icons.mail_outline_rounded, + selectedIcon: Icons.mail_rounded, + label: 'Mail', + onTap: () => onDestinationSelected(2), ), - ), + _BarItem( + selected: selectedIndex == 3, + icon: Icons.restaurant_outlined, + selectedIcon: Icons.restaurant_rounded, + label: 'Campus', + onTap: () => onDestinationSelected(3), + ), + _AssistantItem( + onTap: onAssistantPressed, + onLongPress: onAssistantLongPress, + ), + ], ), ), ); @@ -137,18 +90,18 @@ class _BarItem extends StatelessWidget { button: true, label: label, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2), child: Material( color: selected ? StudyOsColors.accent.withValues(alpha: 0.10) : Colors.transparent, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(12), clipBehavior: Clip.antiAlias, child: InkWell( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(12), onTap: onTap, child: SizedBox( - height: 56, + height: 52, child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, @@ -176,3 +129,46 @@ class _BarItem extends StatelessWidget { ); } } + +class _AssistantItem extends StatelessWidget { + const _AssistantItem({required this.onTap, required this.onLongPress}); + + final VoidCallback onTap; + final VoidCallback onLongPress; + + @override + Widget build(BuildContext context) => Expanded( + child: Semantics( + button: true, + label: 'Open StudyOS assistant', + child: InkWell( + key: const ValueKey('assistant-tab'), + onTap: onTap, + onLongPress: onLongPress, + child: const SizedBox( + height: 52, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.auto_awesome_outlined, + color: StudyOsColors.accent, + size: 20, + ), + SizedBox(height: 2), + Text( + 'Assistant', + style: TextStyle( + color: StudyOsColors.accent, + fontSize: 10, + height: 1, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ), + ); +} diff --git a/flutter_app/test/navigation_test.dart b/flutter_app/test/navigation_test.dart index d1a05cd..9a119d2 100644 --- a/flutter_app/test/navigation_test.dart +++ b/flutter_app/test/navigation_test.dart @@ -13,7 +13,7 @@ import 'package:studyos_agent/src/views/schedule_view.dart'; import 'package:studyos_agent/src/widgets/study_bottom_bar.dart'; void main() { - testWidgets('bottom navigation exposes four tabs and centered ask action', ( + testWidgets('bottom navigation exposes stable tabs and an assistant entry', ( WidgetTester tester, ) async { tester.view.physicalSize = const Size(430, 932); @@ -31,16 +31,12 @@ void main() { home: StatefulBuilder( builder: (context, setState) { return Scaffold( - floatingActionButton: AskFab( - onPressed: () => askPressed = true, - onLongPress: () => askLongPressed = true, - ), - floatingActionButtonLocation: - FloatingActionButtonLocation.centerDocked, bottomNavigationBar: StudyBottomBar( selectedIndex: selectedIndex, onDestinationSelected: (value) => setState(() => selectedIndex = value), + onAssistantPressed: () => askPressed = true, + onAssistantLongPress: () => askLongPressed = true, ), ); }, @@ -53,7 +49,7 @@ void main() { expect(find.text('Schedule'), findsOneWidget); expect(find.text('Mail'), findsOneWidget); expect(find.text('Campus'), findsOneWidget); - expect(find.text('Ask'), findsOneWidget); + expect(find.text('Assistant'), findsOneWidget); expect(find.text('Map'), findsNothing); expect(find.text('Notes'), findsNothing); @@ -62,14 +58,12 @@ void main() { expect(selectedIndex, 1); - await tester.tap(find.text('Ask')); + await tester.tap(find.byKey(const ValueKey('assistant-tab'))); await tester.pumpAndSettle(); expect(askPressed, isTrue); - await tester.longPress( - find.byKey(const ValueKey('ask-fab-surface')), - ); + await tester.longPress(find.byKey(const ValueKey('assistant-tab'))); await tester.pumpAndSettle(); expect(askLongPressed, isTrue); @@ -141,8 +135,7 @@ void main() { ), ); - expect(find.text('Hi Ada'), findsOneWidget); - expect(find.text('M.Sc. AI · Semester 2'), findsOneWidget); + expect(find.textContaining('Ada'), findsOneWidget); final campusCard = find.byKey(const ValueKey('home-campus-card')); await tester.scrollUntilVisible( @@ -287,7 +280,7 @@ void main() { await tapStatus('home-status-map', 'maps'); }); - testWidgets('home renders proactive snapshot before cards', ( + testWidgets('home presents the daily focus before study tools', ( WidgetTester tester, ) async { await tester.pumpWidget( @@ -321,13 +314,14 @@ void main() { expect(find.text('Set up your StudyOS'), findsOneWidget); expect(find.textContaining('Connect your profile'), findsOneWidget); expect(find.text('Timetable: Unavailable'), findsOneWidget); - expect(find.text('Generated component preview'), findsOneWidget); - expect(find.text('Leave for class'), findsOneWidget); - - await tester.tap(find.byTooltip('Next component')); - await tester.pump(); - - expect(find.text('Compact day'), findsOneWidget); + expect(find.text('For you'), findsOneWidget); + await tester.scrollUntilVisible( + find.text('Study tools'), + 300, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text('Study tools'), findsOneWidget); + expect(find.text('Generated component preview'), findsNothing); expect(find.byType(RefreshIndicator), findsOneWidget); }); @@ -429,7 +423,7 @@ void main() { expect(router.routeInformationProvider.value.uri.path, '/home'); }); - testWidgets('ask button long press opens voice input spike', ( + testWidgets('assistant tab long press opens voice input spike', ( WidgetTester tester, ) async { SharedPreferencesAsyncPlatform.instance = @@ -469,9 +463,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.longPress( - find.byKey(const ValueKey('ask-fab-surface')), - ); + await tester.longPress(find.byKey(const ValueKey('assistant-tab'))); await tester.pumpAndSettle(); expect(router.routeInformationProvider.value.uri.path, '/voice');