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/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'shared/routing.dart';
|
||||||
import 'theme/theme_data.dart';
|
import 'theme/theme_data.dart';
|
||||||
|
|
||||||
class MinstrelApp extends StatelessWidget {
|
class MinstrelApp extends ConsumerWidget {
|
||||||
const MinstrelApp({super.key});
|
const MinstrelApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return MaterialApp(
|
final router = buildRouter(ref);
|
||||||
|
return MaterialApp.router(
|
||||||
title: 'Minstrel',
|
title: 'Minstrel',
|
||||||
theme: buildThemeData(),
|
theme: buildThemeData(),
|
||||||
home: const Scaffold(
|
routerConfig: router,
|
||||||
body: Center(child: Text('Minstrel — scaffold')),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../api/client.dart';
|
import '../api/client.dart';
|
||||||
import '../api/endpoints/auth.dart';
|
import '../api/endpoints/auth.dart';
|
||||||
@@ -31,7 +32,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
final url = await ref.read(serverUrlProvider.future);
|
final url = await ref.read(serverUrlProvider.future);
|
||||||
if (url == null) {
|
if (url == null) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context).pushReplacementNamed('/server-url');
|
context.go('/server-url');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final dio = ApiClient.buildDio(
|
final dio = ApiClient.buildDio(
|
||||||
@@ -47,7 +48,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
userJson: res.rawUserJson,
|
userJson: res.rawUserJson,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context).pushReplacementNamed('/home');
|
context.go('/home');
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
final code = ApiError.fromDio(e).code;
|
final code = ApiError.fromDio(e).code;
|
||||||
final copy = (await ErrorCopy.load()).forCode(code);
|
final copy = (await ErrorCopy.load()).forCode(code);
|
||||||
@@ -83,7 +84,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
: const Text('Sign in'),
|
: const Text('Sign in'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pushReplacementNamed('/server-url'),
|
onPressed: () => context.go('/server-url'),
|
||||||
child: const Text('Change server URL'),
|
child: const Text('Change server URL'),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../api/endpoints/health.dart';
|
import '../api/endpoints/health.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
@@ -36,7 +37,7 @@ class _ServerUrlScreenState extends ConsumerState<ServerUrlScreen> {
|
|||||||
}
|
}
|
||||||
await ref.read(authControllerProvider.notifier).setServerUrl(url);
|
await ref.read(authControllerProvider.notifier).setServerUrl(url);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context).pushReplacementNamed('/login');
|
context.go('/login');
|
||||||
} on DioException catch (_) {
|
} on DioException catch (_) {
|
||||||
setState(() => _error = "Couldn't reach that server.");
|
setState(() => _error = "Couldn't reach that server.");
|
||||||
} catch (_) {
|
} 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/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:minstrel/app.dart';
|
import 'package:minstrel/app.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('app boots and renders scaffold marker', (tester) async {
|
testWidgets('cold launch lands on server-url screen when no URL stored', (tester) async {
|
||||||
await tester.pumpWidget(const MaterialApp(home: MinstrelApp()));
|
await tester.pumpWidget(const ProviderScope(child: MinstrelApp()));
|
||||||
expect(find.text('Minstrel — scaffold'), findsOneWidget);
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Connect to your Minstrel'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user