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:
2026-05-02 17:24:08 -04:00
parent 9cbbfcff6c
commit d115c267f4
6 changed files with 144 additions and 13 deletions
+7 -6
View File
@@ -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,
);
}
}
+4 -3
View File
@@ -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 (_) {
+59
View File
@@ -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,
),
],
),
),
),
),
);
}
}
+5 -3
View File
@@ -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);
});
}