feat(flutter): GoRouter shell + version gate + auth-aware redirects
Cold launch flow: no server-url -> /server-url. URL set, no token -> /login. Token present -> /home with shell. VersionGate runs once when the URL changes, hits /healthz, blocks if min_client_version > package version. /home is a placeholder until Task 14. Replaces Navigator.pushReplacementNamed in server_url_screen + login_screen with context.go now that go_router owns the routes.
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LoginScreen> {
|
||||
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<LoginScreen> {
|
||||
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<LoginScreen> {
|
||||
: const Text('Sign in'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pushReplacementNamed('/server-url'),
|
||||
onPressed: () => context.go('/server-url'),
|
||||
child: const Text('Change server URL'),
|
||||
),
|
||||
]),
|
||||
|
||||
@@ -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<ServerUrlScreen> {
|
||||
}
|
||||
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 (_) {
|
||||
|
||||
@@ -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')));
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user