diff --git a/flutter_client/lib/app.dart b/flutter_client/lib/app.dart index 4a870c5e..f0c01e29 100644 --- a/flutter_client/lib/app.dart +++ b/flutter_client/lib/app.dart @@ -1,18 +1,19 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'shared/routing.dart'; import 'theme/theme_data.dart'; -class MinstrelApp extends StatelessWidget { +class MinstrelApp extends ConsumerWidget { const MinstrelApp({super.key}); @override - Widget build(BuildContext context) { - return MaterialApp( + Widget build(BuildContext context, WidgetRef ref) { + final router = buildRouter(ref); + return MaterialApp.router( title: 'Minstrel', theme: buildThemeData(), - home: const Scaffold( - body: Center(child: Text('Minstrel — scaffold')), - ), + routerConfig: router, ); } } diff --git a/flutter_client/lib/auth/login_screen.dart b/flutter_client/lib/auth/login_screen.dart index 3847248c..c2fee1ed 100644 --- a/flutter_client/lib/auth/login_screen.dart +++ b/flutter_client/lib/auth/login_screen.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../api/client.dart'; import '../api/endpoints/auth.dart'; @@ -31,7 +32,7 @@ class _LoginScreenState extends ConsumerState { final url = await ref.read(serverUrlProvider.future); if (url == null) { if (!mounted) return; - Navigator.of(context).pushReplacementNamed('/server-url'); + context.go('/server-url'); return; } final dio = ApiClient.buildDio( @@ -47,7 +48,7 @@ class _LoginScreenState extends ConsumerState { userJson: res.rawUserJson, ); if (!mounted) return; - Navigator.of(context).pushReplacementNamed('/home'); + context.go('/home'); } on DioException catch (e) { final code = ApiError.fromDio(e).code; final copy = (await ErrorCopy.load()).forCode(code); @@ -83,7 +84,7 @@ class _LoginScreenState extends ConsumerState { : const Text('Sign in'), ), TextButton( - onPressed: () => Navigator.of(context).pushReplacementNamed('/server-url'), + onPressed: () => context.go('/server-url'), child: const Text('Change server URL'), ), ]), diff --git a/flutter_client/lib/auth/server_url_screen.dart b/flutter_client/lib/auth/server_url_screen.dart index f2caa62b..47d1d544 100644 --- a/flutter_client/lib/auth/server_url_screen.dart +++ b/flutter_client/lib/auth/server_url_screen.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../api/endpoints/health.dart'; import '../theme/theme_extension.dart'; @@ -36,7 +37,7 @@ class _ServerUrlScreenState extends ConsumerState { } await ref.read(authControllerProvider.notifier).setServerUrl(url); if (!mounted) return; - Navigator.of(context).pushReplacementNamed('/login'); + context.go('/login'); } on DioException catch (_) { setState(() => _error = "Couldn't reach that server."); } catch (_) { diff --git a/flutter_client/lib/shared/routing.dart b/flutter_client/lib/shared/routing.dart new file mode 100644 index 00000000..5ad1b0c3 --- /dev/null +++ b/flutter_client/lib/shared/routing.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../auth/auth_provider.dart'; +import '../auth/login_screen.dart'; +import '../auth/server_url_screen.dart'; +import 'widgets/version_gate.dart'; + +GoRouter buildRouter(Ref ref) { + return GoRouter( + initialLocation: '/', + redirect: (ctx, state) async { + final url = await ref.read(serverUrlProvider.future); + if (url == null) return '/server-url'; + final user = await ref.read(authControllerProvider.future); + final loc = state.matchedLocation; + if (user == null && loc != '/login' && loc != '/server-url') { + return '/login'; + } + if (user != null && (loc == '/login' || loc == '/server-url')) { + return '/home'; + } + return null; + }, + routes: [ + GoRoute(path: '/', redirect: (_, __) => '/home'), + GoRoute(path: '/server-url', builder: (_, __) => const ServerUrlScreen()), + GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), + ShellRoute( + builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)), + routes: [ + GoRoute(path: '/home', builder: (_, __) => const _HomePlaceholder()), + ], + ), + ], + ); +} + +class _ShellWithPlayerBar extends StatelessWidget { + const _ShellWithPlayerBar({required this.child}); + final Widget child; + @override + Widget build(BuildContext context) { + // PlayerBar is wired in Task 18; this placeholder leaves the shell slot. + return Column( + children: [ + Expanded(child: child), + ], + ); + } +} + +class _HomePlaceholder extends StatelessWidget { + const _HomePlaceholder(); + @override + Widget build(BuildContext context) => + const Scaffold(body: Center(child: Text('Home — coming in Task 14'))); +} diff --git a/flutter_client/lib/shared/widgets/version_gate.dart b/flutter_client/lib/shared/widgets/version_gate.dart new file mode 100644 index 00000000..6c78e5dc --- /dev/null +++ b/flutter_client/lib/shared/widgets/version_gate.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:pub_semver/pub_semver.dart'; + +import '../../api/client.dart'; +import '../../api/endpoints/health.dart'; +import '../../auth/auth_provider.dart'; + +final _versionCheckProvider = FutureProvider<_VersionResult>((ref) async { + final url = await ref.watch(serverUrlProvider.future); + if (url == null) return _VersionResult.skipped; + final dio = ApiClient.buildDio(baseUrl: url, tokenResolver: () async => null); + final body = await HealthApi(dio).check(); + final min = body['min_client_version']; + if (min == null || min.isEmpty) return _VersionResult.skipped; + final info = await PackageInfo.fromPlatform(); + final mine = Version.parse(info.version); + final required = Version.parse(min); + return mine < required ? _VersionResult.tooOld : _VersionResult.ok; +}); + +enum _VersionResult { ok, tooOld, skipped } + +class VersionGate extends ConsumerWidget { + const VersionGate({required this.child, super.key}); + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ref.watch(_versionCheckProvider).when( + data: (r) => r == _VersionResult.tooOld ? const _TooOldScreen() : child, + error: (_, __) => child, // soft-fail if /healthz is unreachable; let the rest of the flow surface a real error + loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())), + ); + } +} + +class _TooOldScreen extends StatelessWidget { + const _TooOldScreen(); + @override + Widget build(BuildContext context) { + return const Scaffold( + body: SafeArea( + child: Padding( + padding: EdgeInsets.all(24), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Update required', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.w500), + ), + SizedBox(height: 12), + Text( + 'This client is too old for that Minstrel server. Install the latest build to continue.', + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/flutter_client/test/smoke_test.dart b/flutter_client/test/smoke_test.dart index 8da85115..51c1407c 100644 --- a/flutter_client/test/smoke_test.dart +++ b/flutter_client/test/smoke_test.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:minstrel/app.dart'; void main() { - testWidgets('app boots and renders scaffold marker', (tester) async { - await tester.pumpWidget(const MaterialApp(home: MinstrelApp())); - expect(find.text('Minstrel — scaffold'), findsOneWidget); + testWidgets('cold launch lands on server-url screen when no URL stored', (tester) async { + await tester.pumpWidget(const ProviderScope(child: MinstrelApp())); + await tester.pumpAndSettle(); + expect(find.text('Connect to your Minstrel'), findsOneWidget); }); }