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'), + ), + ]), + ); + } +}