From ec0c10f3122c834fcec7d5c8389c7b8b78172d71 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 2 May 2026 17:41:46 -0400 Subject: [PATCH] feat(flutter): connection-error banner + 401-clears-session interceptor ApiClient.buildDio takes on401; the library's dioProvider wires it to the auth controller's clearSession. The router redirect already handles navigation away from authed screens once the session goes null. HomeScreen surfaces the banner on connection_refused with Retry + Change URL. --- flutter_client/lib/api/client.dart | 25 ++++++++++----- flutter_client/lib/library/home_screen.dart | 13 +++++++- .../lib/library/library_providers.dart | 1 + .../widgets/connection_error_banner.dart | 31 +++++++++++++++++++ 4 files changed, 62 insertions(+), 8 deletions(-) create mode 100644 flutter_client/lib/shared/widgets/connection_error_banner.dart diff --git a/flutter_client/lib/api/client.dart b/flutter_client/lib/api/client.dart index 6dee5465..dd6363fa 100644 --- a/flutter_client/lib/api/client.dart +++ b/flutter_client/lib/api/client.dart @@ -1,15 +1,20 @@ import 'package:dio/dio.dart'; typedef TokenResolver = Future Function(); +typedef OnUnauthenticated = Future Function(); class ApiClient { /// Builds a dio instance pinned to [baseUrl] with a Bearer-auth /// interceptor that pulls the current token from [tokenResolver] /// on every request. Server already supports cookie OR bearer /// (internal/auth/session.go); we use bearer to skip cookie-jar. + /// + /// [on401] fires when any response comes back 401. Use it to clear + /// stored credentials so the router redirect can route back to login. static Dio buildDio({ required String baseUrl, required TokenResolver tokenResolver, + OnUnauthenticated? on401, }) { final d = Dio(BaseOptions( baseUrl: baseUrl, @@ -18,13 +23,19 @@ class ApiClient { contentType: Headers.jsonContentType, responseType: ResponseType.json, )); - d.interceptors.add(InterceptorsWrapper(onRequest: (opts, handler) async { - final t = await tokenResolver(); - if (t != null && t.isNotEmpty) { - opts.headers['Authorization'] = 'Bearer $t'; - } - handler.next(opts); - })); + d.interceptors.add(InterceptorsWrapper( + onRequest: (opts, h) async { + final t = await tokenResolver(); + if (t != null && t.isNotEmpty) opts.headers['Authorization'] = 'Bearer $t'; + h.next(opts); + }, + onError: (e, h) async { + if (e.response?.statusCode == 401 && on401 != null) { + await on401(); + } + h.next(e); + }, + )); return d; } } diff --git a/flutter_client/lib/library/home_screen.dart b/flutter_client/lib/library/home_screen.dart index 069b8ed7..b01fd579 100644 --- a/flutter_client/lib/library/home_screen.dart +++ b/flutter_client/lib/library/home_screen.dart @@ -1,11 +1,14 @@ +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/errors.dart'; import '../models/album.dart'; import '../models/artist.dart'; import '../models/track.dart'; import '../player/player_provider.dart'; +import '../shared/widgets/connection_error_banner.dart'; import '../theme/theme_extension.dart'; import 'library_providers.dart'; import 'widgets/album_card.dart'; @@ -23,7 +26,15 @@ class HomeScreen extends ConsumerWidget { backgroundColor: fs.obsidian, body: SafeArea( child: ref.watch(homeProvider).when( - error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))), + error: (e, _) { + final code = e is DioException ? ApiError.fromDio(e).code : 'unknown'; + if (code == 'connection_refused') { + return ConnectionErrorBanner( + onRetry: () => ref.refresh(homeProvider), + ); + } + return Center(child: Text('$e', style: TextStyle(color: fs.error))); + }, loading: () => const Center(child: CircularProgressIndicator()), data: (h) => RefreshIndicator( onRefresh: () async => ref.refresh(homeProvider.future), diff --git a/flutter_client/lib/library/library_providers.dart b/flutter_client/lib/library/library_providers.dart index d47707f5..5152171a 100644 --- a/flutter_client/lib/library/library_providers.dart +++ b/flutter_client/lib/library/library_providers.dart @@ -25,6 +25,7 @@ final dioProvider = FutureProvider((ref) async { return ApiClient.buildDio( baseUrl: url, tokenResolver: () async => storage.read(key: 'session_token'), + on401: () async => ref.read(authControllerProvider.notifier).clearSession(), ); }); diff --git a/flutter_client/lib/shared/widgets/connection_error_banner.dart b/flutter_client/lib/shared/widgets/connection_error_banner.dart new file mode 100644 index 00000000..57e62961 --- /dev/null +++ b/flutter_client/lib/shared/widgets/connection_error_banner.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../theme/theme_extension.dart'; + +class ConnectionErrorBanner extends StatelessWidget { + const ConnectionErrorBanner({required this.onRetry, super.key}); + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Container( + color: fs.iron, + padding: const EdgeInsets.all(12), + child: Row(children: [ + Expanded( + child: Text( + "Couldn't reach the server.", + style: TextStyle(color: fs.parchment), + ), + ), + TextButton(onPressed: onRetry, child: const Text('Retry')), + TextButton( + onPressed: () => context.go('/server-url'), + child: const Text('Change URL'), + ), + ]), + ); + } +}