From dbe3b42ce618e7e8b99490b73bf377a85a4978eb Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Wed, 8 Jul 2026 09:21:47 +0000 Subject: [PATCH 1/2] feat: expose voice input spike --- flutter_app/lib/src/app_router.dart | 6 +- flutter_app/lib/src/views/shell_scaffold.dart | 4 +- .../lib/src/views/voice_assist_view.dart | 247 ++++++++++++++++++ .../lib/src/widgets/study_bottom_bar.dart | 31 ++- flutter_app/test/navigation_test.dart | 62 ++++- 5 files changed, 339 insertions(+), 11 deletions(-) create mode 100644 flutter_app/lib/src/views/voice_assist_view.dart diff --git a/flutter_app/lib/src/app_router.dart b/flutter_app/lib/src/app_router.dart index 709fbde..88581ed 100644 --- a/flutter_app/lib/src/app_router.dart +++ b/flutter_app/lib/src/app_router.dart @@ -15,6 +15,7 @@ import 'views/profile_edit_view.dart'; import 'views/schedule_view.dart'; import 'views/settings_view.dart'; import 'views/shell_scaffold.dart'; +import 'views/voice_assist_view.dart'; class AuthRouterState extends ChangeNotifier { AuthRouterState({ @@ -179,8 +180,9 @@ GoRouter buildAppRouter({ ), GoRoute( path: '/voice', - builder: (context, state) => const Scaffold( - body: Center(child: Text('Voice assist coming soon')), + builder: (context, state) => _ScopedAppRoute( + controller: shellController(), + child: const VoiceAssistView(), ), ), ], diff --git a/flutter_app/lib/src/views/shell_scaffold.dart b/flutter_app/lib/src/views/shell_scaffold.dart index 012814a..0eb0f13 100644 --- a/flutter_app/lib/src/views/shell_scaffold.dart +++ b/flutter_app/lib/src/views/shell_scaffold.dart @@ -30,9 +30,7 @@ class _ShellScaffoldState extends State { drawer: const SecondaryDestinationsDrawer(), floatingActionButton: AskFab( onPressed: () => context.push('/chat'), - onLongPress: () { - // TODO(voice): route to /voice when push-to-talk is implemented. - }, + onLongPress: () => context.go('/voice'), ), floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, diff --git a/flutter_app/lib/src/views/voice_assist_view.dart b/flutter_app/lib/src/views/voice_assist_view.dart new file mode 100644 index 0000000..b57312f --- /dev/null +++ b/flutter_app/lib/src/views/voice_assist_view.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../app_shell_scope.dart'; +import '../studyos_theme.dart'; +import '../voice_controller.dart'; +import '../widgets/voice_listening_overlay.dart'; + +class VoiceAssistView extends StatelessWidget { + const VoiceAssistView({super.key}); + + @override + Widget build(BuildContext context) { + final controller = AppShellScope.of(context); + return ListenableBuilder( + listenable: controller.voice, + builder: (context, _) { + final voice = controller.voice; + return Scaffold( + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.lg, + ), + child: ListView( + padding: const EdgeInsets.only( + top: StudyOsSpacing.lg, + bottom: 96, + ), + children: [ + _VoiceHeader(onBack: () => context.go('/home')), + const SizedBox(height: StudyOsSpacing.lg), + VoiceListeningOverlay(voice: voice), + _VoiceControlCard(voice: voice), + const SizedBox(height: StudyOsSpacing.lg), + const _VoiceFeasibilityMatrix(), + const SizedBox(height: StudyOsSpacing.lg), + OutlinedButton.icon( + onPressed: () => context.push('/chat'), + icon: const Icon(Icons.chat_bubble_outline_rounded), + label: const Text('Open chat'), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } +} + +class _VoiceHeader extends StatelessWidget { + const _VoiceHeader({required this.onBack}); + + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + IconButton( + tooltip: 'Back', + onPressed: onBack, + icon: const Icon(Icons.arrow_back_ios_new_rounded), + ), + const SizedBox(width: StudyOsSpacing.sm), + Expanded( + child: Text( + 'Voice input spike', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ], + ); + } +} + +class _VoiceControlCard extends StatelessWidget { + const _VoiceControlCard({required this.voice}); + + final VoiceController voice; + + @override + Widget build(BuildContext context) { + final available = voice.available; + return Material( + color: StudyOsColors.surface, + shape: RoundedRectangleBorder( + side: const BorderSide(color: StudyOsColors.border), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + available ? 'Ready for local voice input' : 'Voice unavailable', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: StudyOsSpacing.sm), + Text( + available + ? 'Hold to dictate one request, or toggle conversation mode for repeated turns.' + : 'Speech recognition is unavailable or permission was denied on this platform.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: StudyOsSpacing.lg), + Row( + children: [ + Expanded( + child: GestureDetector( + onLongPressStart: available + ? (_) => voice.startHold() + : null, + onLongPressEnd: available ? (_) => voice.stopHold() : null, + child: FilledButton.icon( + onPressed: available ? () {} : null, + icon: const Icon(Icons.mic_rounded), + label: const Text('Hold to talk'), + ), + ), + ), + const SizedBox(width: StudyOsSpacing.md), + Expanded( + child: OutlinedButton.icon( + onPressed: available ? voice.toggleConversation : null, + icon: const Icon(Icons.graphic_eq_rounded), + label: Text( + voice.conversationMode ? 'Stop loop' : 'Conversation', + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _VoiceFeasibilityMatrix extends StatelessWidget { + const _VoiceFeasibilityMatrix(); + + @override + Widget build(BuildContext context) { + const rows = <_VoiceFeasibilityRow>[ + _VoiceFeasibilityRow( + title: 'Push-to-talk', + status: 'Prototype path', + body: 'Lowest battery risk. Reuses the existing chat pipeline and only records while the user presses the mic.', + ), + _VoiceFeasibilityRow( + title: 'Conversation mode', + status: 'Optional demo', + body: 'Hands-free after explicit opt-in. Good for testing turn-taking, but should stay foreground-only.', + ), + _VoiceFeasibilityRow( + title: 'Custom hotword', + status: 'Research gate', + body: 'Needs platform-specific checks for background mic access, battery, model size, and offline wake-word support.', + ), + _VoiceFeasibilityRow( + title: 'Passive listener', + status: 'Defer', + body: 'High battery and permission risk. Do not make it the default prototype path.', + ), + ]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Feasibility gates', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: StudyOsSpacing.md), + for (final row in rows) ...[ + _FeasibilityTile(row: row), + if (row != rows.last) const SizedBox(height: StudyOsSpacing.sm), + ], + ], + ); + } +} + +class _VoiceFeasibilityRow { + const _VoiceFeasibilityRow({ + required this.title, + required this.status, + required this.body, + }); + + final String title; + final String status; + final String body; +} + +class _FeasibilityTile extends StatelessWidget { + const _FeasibilityTile({required this.row}); + + final _VoiceFeasibilityRow row; + + @override + Widget build(BuildContext context) { + return Material( + color: StudyOsColors.surface, + shape: RoundedRectangleBorder( + side: const BorderSide(color: StudyOsColors.border), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.check_circle_outline, color: StudyOsColors.accent), + const SizedBox(width: StudyOsSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(row.title, style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: StudyOsSpacing.xs), + Text(row.body, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ), + const SizedBox(width: StudyOsSpacing.sm), + Text( + row.status, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: StudyOsColors.success, + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter_app/lib/src/widgets/study_bottom_bar.dart b/flutter_app/lib/src/widgets/study_bottom_bar.dart index 6831ed1..1e552ad 100644 --- a/flutter_app/lib/src/widgets/study_bottom_bar.dart +++ b/flutter_app/lib/src/widgets/study_bottom_bar.dart @@ -81,12 +81,33 @@ class AskFab extends StatelessWidget { @override Widget build(BuildContext context) { return GestureDetector( + key: const ValueKey('ask-fab-surface'), + behavior: HitTestBehavior.opaque, + onTap: onPressed, onLongPress: onLongPress, - child: FloatingActionButton.extended( - tooltip: 'Ask StudyOS', - onPressed: onPressed, - icon: const Icon(Icons.auto_awesome), - label: const Text('Ask'), + 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'), + ], + ), + ), + ), + ), ), ); } diff --git a/flutter_app/test/navigation_test.dart b/flutter_app/test/navigation_test.dart index 44b292d..86ddf0f 100644 --- a/flutter_app/test/navigation_test.dart +++ b/flutter_app/test/navigation_test.dart @@ -22,6 +22,7 @@ void main() { var selectedIndex = 0; var askPressed = false; + var askLongPressed = false; await tester.pumpWidget( MaterialApp( @@ -31,7 +32,7 @@ void main() { return Scaffold( floatingActionButton: AskFab( onPressed: () => askPressed = true, - onLongPress: () {}, + onLongPress: () => askLongPressed = true, ), floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, @@ -64,6 +65,13 @@ void main() { await tester.pumpAndSettle(); expect(askPressed, isTrue); + + await tester.longPress( + find.byKey(const ValueKey('ask-fab-surface')), + ); + await tester.pumpAndSettle(); + + expect(askLongPressed, isTrue); }); testWidgets('home campus card opens campus view', ( @@ -391,6 +399,58 @@ void main() { expect(router.routeInformationProvider.value.uri.path, '/home'); }); + testWidgets('ask button long press opens voice input spike', ( + WidgetTester tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + addTearDown(() => SharedPreferencesAsyncPlatform.instance = null); + + const profile = OnboardingProfile( + displayName: 'Ada', + username: 'ada42', + email: null, + degreeProgram: 'M.Sc. AI', + semester: 2, + livesInTuebingen: true, + ); + final authState = AuthRouterState( + initialSession: const UserSession(username: 'ada42'), + initialProfile: profile, + initialLoading: false, + ); + final controller = AppShellController( + initialProfile: profile, + initialOnLogout: () {}, + initialOnSaveProfile: (_) async {}, + ); + final router = buildAppRouter( + authState: authState, + shellController: () => controller, + onLogin: (_, _) async {}, + onOnboardingComplete: (_) async {}, + ); + addTearDown(router.dispose); + addTearDown(controller.dispose); + addTearDown(authState.dispose); + + await tester.pumpWidget( + MaterialApp.router(theme: buildStudyOsTheme(), routerConfig: router), + ); + await tester.pumpAndSettle(); + + await tester.longPress( + find.byKey(const ValueKey('ask-fab-surface')), + ); + await tester.pumpAndSettle(); + + expect(router.routeInformationProvider.value.uri.path, '/voice'); + expect(find.text('Voice input spike'), findsOneWidget); + expect(find.text('Push-to-talk'), findsOneWidget); + expect(find.text('Custom hotword'), findsOneWidget); + expect(find.text('Passive listener'), findsOneWidget); + }); + testWidgets('chat route prompt is applied after route build', ( WidgetTester tester, ) async { From 5fdd582b96713c02d6ad5749686a113e4af2abf0 Mon Sep 17 00:00:00 2001 From: SebastianBoehler <27767932+SebastianBoehler@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:00:19 +0200 Subject: [PATCH 2/2] chore(format): apply dart formatter to voice spike --- .../lib/src/views/voice_assist_view.dart | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/flutter_app/lib/src/views/voice_assist_view.dart b/flutter_app/lib/src/views/voice_assist_view.dart index b57312f..db6c6df 100644 --- a/flutter_app/lib/src/views/voice_assist_view.dart +++ b/flutter_app/lib/src/views/voice_assist_view.dart @@ -155,22 +155,26 @@ class _VoiceFeasibilityMatrix extends StatelessWidget { _VoiceFeasibilityRow( title: 'Push-to-talk', status: 'Prototype path', - body: 'Lowest battery risk. Reuses the existing chat pipeline and only records while the user presses the mic.', + body: + 'Lowest battery risk. Reuses the existing chat pipeline and only records while the user presses the mic.', ), _VoiceFeasibilityRow( title: 'Conversation mode', status: 'Optional demo', - body: 'Hands-free after explicit opt-in. Good for testing turn-taking, but should stay foreground-only.', + body: + 'Hands-free after explicit opt-in. Good for testing turn-taking, but should stay foreground-only.', ), _VoiceFeasibilityRow( title: 'Custom hotword', status: 'Research gate', - body: 'Needs platform-specific checks for background mic access, battery, model size, and offline wake-word support.', + body: + 'Needs platform-specific checks for background mic access, battery, model size, and offline wake-word support.', ), _VoiceFeasibilityRow( title: 'Passive listener', status: 'Defer', - body: 'High battery and permission risk. Do not make it the default prototype path.', + body: + 'High battery and permission risk. Do not make it the default prototype path.', ), ]; return Column( @@ -226,7 +230,10 @@ class _FeasibilityTile extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(row.title, style: Theme.of(context).textTheme.labelLarge), + Text( + row.title, + style: Theme.of(context).textTheme.labelLarge, + ), const SizedBox(height: StudyOsSpacing.xs), Text(row.body, style: Theme.of(context).textTheme.bodyMedium), ], @@ -235,9 +242,9 @@ class _FeasibilityTile extends StatelessWidget { const SizedBox(width: StudyOsSpacing.sm), Text( row.status, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: StudyOsColors.success, - ), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: StudyOsColors.success), ), ], ),