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.
This commit is contained in:
2026-05-02 17:41:46 -04:00
parent f675782bf5
commit ec0c10f312
4 changed files with 62 additions and 8 deletions
+18 -7
View File
@@ -1,15 +1,20 @@
import 'package:dio/dio.dart';
typedef TokenResolver = Future<String?> Function();
typedef OnUnauthenticated = Future<void> 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;
}
}
+12 -1
View File
@@ -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),
@@ -25,6 +25,7 @@ final dioProvider = FutureProvider<Dio>((ref) async {
return ApiClient.buildDio(
baseUrl: url,
tokenResolver: () async => storage.read(key: 'session_token'),
on401: () async => ref.read(authControllerProvider.notifier).clearSession(),
);
});
@@ -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<FabledSwordTheme>()!;
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'),
),
]),
);
}
}