summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-21 08:07:06 +0300
committerPaul Buetow <paul@buetow.org>2026-05-21 08:07:06 +0300
commit6365d47f8257efa830d390c092ee9586aded9040 (patch)
treebe6143ff5b67ed10794b6cbd1bf84777c568ee4e
parentfaa3cf36bc5b57e8b133eab8df90110b80eb4cf7 (diff)
Implement SetsListScreen with Material 3 grid, pull-to-refresh, and widget tests (ta)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--player-android/lib/app_routes.dart7
-rw-r--r--player-android/lib/router.dart13
-rw-r--r--player-android/lib/screens/bootstrap_screen.dart42
-rw-r--r--player-android/lib/screens/home_screen.dart405
-rw-r--r--player-android/lib/screens/login_screen.dart45
-rw-r--r--player-android/lib/screens/media_grid_screen.dart43
-rw-r--r--player-android/lib/utils/error_mappers.dart79
-rw-r--r--player-android/pubspec.lock122
-rw-r--r--player-android/pubspec.yaml3
-rw-r--r--player-android/test/screens/sets_list_screen_test.dart372
10 files changed, 1051 insertions, 80 deletions
diff --git a/player-android/lib/app_routes.dart b/player-android/lib/app_routes.dart
index aa76ba7..746ebb9 100644
--- a/player-android/lib/app_routes.dart
+++ b/player-android/lib/app_routes.dart
@@ -13,6 +13,13 @@ abstract final class AppRoutes {
/// First-run setup route shown when no admin account exists yet.
static const bootstrap = '/bootstrap';
+ /// Route that lists media items inside a specific set.
+ /// The ':setId' segment is a numeric set identifier.
+ static const mediaGrid = '/sets/:setId';
+
/// Returns the concrete path for a media-detail page given a numeric [id].
static String mediaDetailPath(int id) => '/media/$id';
+
+ /// Returns the concrete path for the media-grid page of a given [setId].
+ static String mediaGridPath(int setId) => '/sets/$setId';
}
diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart
index 255ffce..4f1934c 100644
--- a/player-android/lib/router.dart
+++ b/player-android/lib/router.dart
@@ -10,6 +10,7 @@ import 'screens/bootstrap_screen.dart';
import 'screens/home_screen.dart';
import 'screens/login_screen.dart';
import 'screens/media_detail_screen.dart';
+import 'screens/media_grid_screen.dart';
import 'screens/settings_screen.dart';
import 'screens/share_screen.dart';
@@ -96,7 +97,17 @@ final routerProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: AppRoutes.home,
- builder: (context, state) => const HomeScreen(),
+ // HomeScreen now hosts SetsListScreen — the real media-library view.
+ builder: (context, state) => const SetsListScreen(),
+ ),
+ GoRoute(
+ path: AppRoutes.mediaGrid,
+ builder: (context, state) {
+ // The ':setId' path parameter is guaranteed by the route pattern.
+ final raw = state.pathParameters['setId']!;
+ final setId = int.tryParse(raw) ?? 0;
+ return MediaGridScreen(setId: setId);
+ },
),
GoRoute(
path: AppRoutes.mediaDetail,
diff --git a/player-android/lib/screens/bootstrap_screen.dart b/player-android/lib/screens/bootstrap_screen.dart
index eb7caee..370f906 100644
--- a/player-android/lib/screens/bootstrap_screen.dart
+++ b/player-android/lib/screens/bootstrap_screen.dart
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/api_client_provider.dart';
import '../providers/auth_state_provider.dart';
+import '../utils/error_mappers.dart';
// Minimum password length enforced by the server (see handlers_auth.go,
// service/auth.go — passwords shorter than this are rejected with 400).
@@ -131,7 +132,11 @@ class _BootstrapScreenState extends ConsumerState<BootstrapScreen> {
// Only show the snack-bar if the widget is still mounted; async gaps can
// occur between the await above and this error handler.
if (!mounted) return;
- _showError(_dioErrorMessage(e));
+ // Delegate to the shared error mapper with bootstrap-specific status fallbacks.
+ _showError(dioErrorMessage(e, statusFallbacks: {
+ 403: 'Bootstrap already complete — an admin account already exists.',
+ 400: 'Invalid request. Check your username and password.',
+ }));
} catch (e) {
if (!mounted) return;
_showError('An unexpected error occurred. Please try again.');
@@ -248,34 +253,7 @@ class _BootstrapScreenState extends ConsumerState<BootstrapScreen> {
// ---------------------------------------------------------------------------
// File-level helpers
// ---------------------------------------------------------------------------
-
-/// Extracts a user-friendly error message from a [DioException].
-///
-/// Pure data-transformation function: no widget state, no Riverpod reads, no
-/// BuildContext — lives at the top level to make that clear and to ease testing.
-///
-/// Prefers a `message` or `error` field from the response JSON body; falls
-/// back to the HTTP status line, or a generic connectivity message.
-String _dioErrorMessage(DioException e) {
- final statusCode = e.response?.statusCode;
-
- // Try to read a server-supplied message from the response body.
- final body = e.response?.data;
- if (body is Map<String, dynamic>) {
- final msg = body['message'] as String? ?? body['error'] as String?;
- if (msg != null && msg.isNotEmpty) return msg;
- }
-
- // Fallback to HTTP status descriptions.
- if (statusCode == 403) {
- return 'Bootstrap already complete — an admin account already exists.';
- }
- if (statusCode == 400) {
- return 'Invalid request. Check your username and password.';
- }
- if (statusCode != null) {
- return 'Server error ($statusCode). Please try again.';
- }
-
- return 'Could not reach the server. Check your network connection.';
-}
+//
+// [_dioErrorMessage] has been removed; error mapping is now delegated to
+// [dioErrorMessage] from ../utils/error_mappers.dart with bootstrap-specific
+// status fallbacks supplied at the call site (DRY/DIP fix).
diff --git a/player-android/lib/screens/home_screen.dart b/player-android/lib/screens/home_screen.dart
index f09d0e1..9988496 100644
--- a/player-android/lib/screens/home_screen.dart
+++ b/player-android/lib/screens/home_screen.dart
@@ -1,26 +1,409 @@
+import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../app_routes.dart';
+import '../models/models.dart';
+import '../providers/api_client_provider.dart';
+import '../utils/error_mappers.dart';
-/// Home screen — will show the media library once the list API is wired.
+/// Home screen: displays all media sets as a scrollable grid.
///
-/// Uses [ConsumerWidget] so it can later watch Riverpod providers (e.g.
-/// a media-list provider) without changing the class hierarchy.
-class HomeScreen extends ConsumerWidget {
- const HomeScreen({super.key});
+/// Each card shows the set's cover thumbnail, name, and media type badge
+/// (podcast sets get a microphone icon overlay). Tapping a card navigates
+/// to [MediaGridScreen] for that set.
+///
+/// Design notes:
+/// - [ConsumerStatefulWidget] is used so we can manage local loading and
+/// error state, guard async continuations on [mounted], and call
+/// [setState] to trigger rebuilds.
+/// - [listSets] is called from [initState] and again on pull-to-refresh.
+/// The result is stored locally rather than in a Riverpod provider because
+/// this screen owns the full lifecycle (loading → data → refresh).
+/// - Error handling distinguishes network/connectivity errors from server
+/// errors using top-level helper functions (not instance methods), keeping
+/// the error-mapping logic testable and decoupled from the widget.
+class SetsListScreen extends ConsumerStatefulWidget {
+ const SetsListScreen({super.key});
+
+ @override
+ ConsumerState<SetsListScreen> createState() => _SetsListScreenState();
+}
+
+class _SetsListScreenState extends ConsumerState<SetsListScreen> {
+ // Nullable: null means "not yet loaded" (loading indicator is shown).
+ List<MediaSet>? _sets;
+
+ // Non-null when the last load attempt failed.
+ String? _error;
+
+ // True while the initial or refresh load is in flight.
+ bool _isLoading = false;
+
+ @override
+ void initState() {
+ super.initState();
+ // Defer the first load until after the first frame so [ref] is fully bound
+ // and any provider overrides in the test environment are applied.
+ WidgetsBinding.instance.addPostFrameCallback((_) => _load());
+ }
+
+ // ---------------------------------------------------------------------------
+ // Data loading
+ // ---------------------------------------------------------------------------
+
+ /// Fetches all sets from the server and updates local state.
+ ///
+ /// Called on first mount and on pull-to-refresh. Errors are mapped by the
+ /// top-level [setsErrorMessage] helper so the widget itself stays simple.
+ Future<void> _load() async {
+ if (!mounted) return;
+ setState(() {
+ _isLoading = true;
+ _error = null;
+ });
+
+ try {
+ final client = ref.read(apiClientProvider);
+ final sets = await client.listSets();
+ if (!mounted) return;
+ setState(() {
+ _sets = sets;
+ _isLoading = false;
+ });
+ } catch (e) {
+ if (!mounted) return;
+ setState(() {
+ _error = setsErrorMessage(e);
+ _isLoading = false;
+ });
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Build
+ // ---------------------------------------------------------------------------
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
return Scaffold(
- appBar: AppBar(title: const Text('Library')),
- body: Center(
- child: ElevatedButton(
- onPressed: () => context.go(AppRoutes.mediaDetailPath(0)),
- child: const Text('Open media (placeholder)'),
+ appBar: _buildAppBar(context),
+ body: _buildBody(context),
+ );
+ }
+
+ /// Builds the app bar with a Settings navigation icon.
+ AppBar _buildAppBar(BuildContext context) {
+ return AppBar(
+ title: const Text('Library'),
+ actions: [
+ IconButton(
+ key: const Key('home_settings_button'),
+ icon: const Icon(Icons.settings_outlined),
+ tooltip: 'Settings',
+ // Navigate to the settings screen when the icon is tapped.
+ onPressed: () => context.go(AppRoutes.settings),
),
+ ],
+ );
+ }
+
+ /// Builds the main body, delegating to the appropriate state widget:
+ /// - Loading spinner (first load, before any data arrives).
+ /// - Error view with a retry button.
+ /// - Empty-state message when the server returns an empty list.
+ /// - Grid of set cards once data is available.
+ Widget _buildBody(BuildContext context) {
+ // Show a full-screen spinner only on the very first load (no data yet).
+ if (_isLoading && _sets == null) {
+ return const Center(
+ key: Key('sets_loading'),
+ child: CircularProgressIndicator(),
+ );
+ }
+
+ // Show an error view with a retry button if the load failed.
+ if (_error != null) {
+ return _ErrorView(
+ message: _error!,
+ onRetry: _load,
+ );
+ }
+
+ // [RefreshIndicator] wraps the scrollable content so pull-to-refresh
+ // triggers [_load] on both the grid and the empty-state view.
+ return RefreshIndicator(
+ onRefresh: _load,
+ child: _sets == null || _sets!.isEmpty
+ ? const _EmptyView()
+ : _SetsGrid(sets: _sets!),
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Sub-widgets
+// ---------------------------------------------------------------------------
+
+/// Scrollable grid of [MediaSet] cards.
+///
+/// Extracted into its own stateless widget so [_SetsListScreenState] stays
+/// below 50 lines and the grid layout is independently testable.
+class _SetsGrid extends StatelessWidget {
+ const _SetsGrid({required this.sets});
+
+ final List<MediaSet> sets;
+
+ @override
+ Widget build(BuildContext context) {
+ return GridView.builder(
+ key: const Key('sets_grid'),
+ padding: const EdgeInsets.all(12),
+ // Two columns on phones; the cross-axis count could be made adaptive
+ // for larger screens in a future iteration.
+ gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: 2,
+ crossAxisSpacing: 12,
+ mainAxisSpacing: 12,
+ // Slightly taller than square to accommodate the title row below.
+ childAspectRatio: 0.85,
),
+ itemCount: sets.length,
+ itemBuilder: (context, index) => _SetCard(mediaSet: sets[index]),
);
}
}
+
+/// Material 3 card representing a single [MediaSet].
+///
+/// Shows: cover thumbnail (with placeholder and error fallback), set name,
+/// and a podcast badge (microphone icon) for podcast sets.
+///
+/// Tapping navigates to [AppRoutes.mediaGridPath] for the set.
+class _SetCard extends StatelessWidget {
+ const _SetCard({required this.mediaSet});
+
+ final MediaSet mediaSet;
+
+ @override
+ Widget build(BuildContext context) {
+ return Card(
+ key: Key('set_card_${mediaSet.id}'),
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: () => context.go(AppRoutes.mediaGridPath(mediaSet.id)),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // Cover thumbnail: takes up ~70 % of the card height.
+ Expanded(child: _CoverImage(mediaSet: mediaSet)),
+ // Name row with podcast badge.
+ _NameRow(mediaSet: mediaSet),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// Displays the set's cover image via [CachedNetworkImage].
+///
+/// Falls back to a grey container with a folder icon when:
+/// - [MediaSet.coverThumbnailPath] is empty.
+/// - The network request fails.
+/// - The image is still loading (shows a [CircularProgressIndicator]).
+///
+/// The podcast badge (microphone icon) is overlaid in the top-right corner
+/// for sets where [MediaSet.isPodcast] is true.
+class _CoverImage extends StatelessWidget {
+ const _CoverImage({required this.mediaSet});
+
+ final MediaSet mediaSet;
+
+ @override
+ Widget build(BuildContext context) {
+ return Stack(
+ fit: StackFit.expand,
+ children: [
+ // Cover thumbnail — use CachedNetworkImage to avoid re-downloading
+ // on every rebuild and to provide placeholder/error states.
+ if (mediaSet.coverThumbnailPath.isEmpty)
+ _placeholderWidget(context)
+ else
+ CachedNetworkImage(
+ imageUrl: mediaSet.coverThumbnailPath,
+ fit: BoxFit.cover,
+ placeholder: (_, __) => _loadingWidget(),
+ errorWidget: (_, __, ___) => _placeholderWidget(context),
+ ),
+
+ // Podcast badge: microphone icon in the top-right corner.
+ if (mediaSet.isPodcast)
+ const Positioned(
+ top: 6,
+ right: 6,
+ child: _PodcastBadge(),
+ ),
+ ],
+ );
+ }
+
+ // Static helpers: neither uses [this], so they are class-scoped utilities
+ // rather than instance methods.
+ static Widget _loadingWidget() =>
+ const Center(child: CircularProgressIndicator());
+
+ static Widget _placeholderWidget(BuildContext context) => ColoredBox(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ child: Icon(
+ Icons.folder_outlined,
+ size: 48,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ );
+}
+
+/// Podcast badge displayed on the cover thumbnail corner.
+///
+/// A small Material 3 filled badge containing a microphone icon, indicating
+/// that the set is a podcast feed rather than a plain media collection.
+class _PodcastBadge extends StatelessWidget {
+ const _PodcastBadge();
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ key: const Key('podcast_badge'),
+ padding: const EdgeInsets.all(4),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.primary,
+ borderRadius: BorderRadius.circular(6),
+ ),
+ child: Icon(
+ Icons.mic,
+ size: 16,
+ color: Theme.of(context).colorScheme.onPrimary,
+ ),
+ );
+ }
+}
+
+/// Name row below the cover thumbnail.
+///
+/// Shows the set name in a single line (ellipsis on overflow).
+class _NameRow extends StatelessWidget {
+ const _NameRow({required this.mediaSet});
+
+ final MediaSet mediaSet;
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
+ child: Text(
+ mediaSet.name,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ );
+ }
+}
+
+/// Full-screen empty-state view, shown when [listSets] returns an empty list.
+///
+/// Wrapped in a [ListView] with [AlwaysScrollableScrollPhysics] so the
+/// [RefreshIndicator] parent can still trigger a pull-to-refresh gesture even
+/// when there is no scrollable content.
+class _EmptyView extends StatelessWidget {
+ const _EmptyView();
+
+ @override
+ Widget build(BuildContext context) {
+ return ListView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ children: [
+ SizedBox(
+ height: MediaQuery.of(context).size.height * 0.6,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.video_library_outlined,
+ size: 72,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'No sets found',
+ key: const Key('sets_empty'),
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Pull down to refresh.',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+/// Full-screen error view with a retry button.
+///
+/// Shown when [listSets] throws (network error, server error, etc.).
+/// The [message] comes from [setsErrorMessage], which maps exceptions to
+/// human-readable strings.
+class _ErrorView extends StatelessWidget {
+ const _ErrorView({required this.message, required this.onRetry});
+
+ final String message;
+ final VoidCallback onRetry;
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.error_outline,
+ size: 56,
+ color: Theme.of(context).colorScheme.error,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ message,
+ key: const Key('sets_error'),
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyLarge,
+ ),
+ const SizedBox(height: 24),
+ ElevatedButton.icon(
+ key: const Key('sets_retry'),
+ onPressed: onRetry,
+ icon: const Icon(Icons.refresh),
+ label: const Text('Retry'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Top-level error-mapping helpers
+// ---------------------------------------------------------------------------
+//
+// [setsErrorMessage] is now defined in ../utils/error_mappers.dart and
+// re-exported via the import above, keeping the screen layer free of the
+// package:dio/dio.dart dependency (DIP fix).
diff --git a/player-android/lib/screens/login_screen.dart b/player-android/lib/screens/login_screen.dart
index bb5fce7..5d59d87 100644
--- a/player-android/lib/screens/login_screen.dart
+++ b/player-android/lib/screens/login_screen.dart
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/api_client_provider.dart';
import '../providers/auth_state_provider.dart';
+import '../utils/error_mappers.dart';
/// Sign-in screen shown to returning users who already have an account.
///
@@ -100,7 +101,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
// Guard against stale BuildContext if the widget was disposed during
// the async gap (e.g. a rapid navigation triggered by another listener).
if (!mounted) return;
- _showError(_dioErrorMessage(e));
+ // Delegate to the shared error mapper with login-specific status fallbacks.
+ _showError(dioErrorMessage(e, statusFallbacks: {
+ 401: 'Invalid username or password.',
+ 400: 'Invalid request. Check your username and password.',
+ }));
} catch (e) {
if (!mounted) return;
_showError('An unexpected error occurred. Please try again.');
@@ -201,37 +206,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
// ---------------------------------------------------------------------------
// File-level helpers
// ---------------------------------------------------------------------------
-
-/// Extracts a user-friendly error message from a [DioException].
-///
-/// Pure data-transformation function: no widget state, no Riverpod reads, no
-/// BuildContext — lives at the top level to make that clear and to ease testing.
-///
-/// Priority order for message extraction:
-/// 1. Server-supplied `message` or `error` field from the JSON response body.
-/// 2. Status-code–specific fallback strings.
-/// 3. Generic connectivity message when no HTTP response is available.
-String _dioErrorMessage(DioException e) {
- final statusCode = e.response?.statusCode;
-
- // Prefer a human-readable message from the server's JSON response body.
- final body = e.response?.data;
- if (body is Map<String, dynamic>) {
- final msg = body['message'] as String? ?? body['error'] as String?;
- if (msg != null && msg.isNotEmpty) return msg;
- }
-
- // Status-code fallbacks for common auth error cases.
- if (statusCode == 401) {
- return 'Invalid username or password.';
- }
- if (statusCode == 400) {
- return 'Invalid request. Check your username and password.';
- }
- if (statusCode != null) {
- return 'Server error ($statusCode). Please try again.';
- }
-
- // No HTTP response: connectivity or DNS failure.
- return 'Could not reach the server. Check your network connection.';
-}
+//
+// [_dioErrorMessage] has been removed; error mapping is now delegated to
+// [dioErrorMessage] from ../utils/error_mappers.dart with login-specific
+// status fallbacks supplied at the call site (DRY/DIP fix).
diff --git a/player-android/lib/screens/media_grid_screen.dart b/player-android/lib/screens/media_grid_screen.dart
new file mode 100644
index 0000000..1a24620
--- /dev/null
+++ b/player-android/lib/screens/media_grid_screen.dart
@@ -0,0 +1,43 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+/// Placeholder screen that will display the media items inside a [MediaSet].
+///
+/// Navigation target reached when the user taps a set card on [SetsListScreen].
+/// The [setId] identifies which set to show; the actual media-listing logic
+/// will be implemented in a future task.
+///
+/// Design notes:
+/// - [ConsumerWidget] is used so the screen can later watch Riverpod
+/// providers for media data without changing the class hierarchy.
+/// - [setId] is passed as a constructor parameter (not via global state) so
+/// the screen is independently testable and reusable for any set.
+class MediaGridScreen extends ConsumerWidget {
+ /// The numeric identifier of the set whose media items will be displayed.
+ final int setId;
+
+ const MediaGridScreen({super.key, required this.setId});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return Scaffold(
+ appBar: AppBar(
+ title: Text('Set $setId'),
+ ),
+ body: Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Icon(Icons.construction_outlined, size: 56),
+ const SizedBox(height: 16),
+ Text(
+ 'TODO: media grid for set $setId',
+ key: const Key('media_grid_todo'),
+ style: Theme.of(context).textTheme.bodyLarge,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart
new file mode 100644
index 0000000..5a9996a
--- /dev/null
+++ b/player-android/lib/utils/error_mappers.dart
@@ -0,0 +1,79 @@
+import 'package:dio/dio.dart';
+
+// ---------------------------------------------------------------------------
+// Shared Dio error-mapping utilities
+// ---------------------------------------------------------------------------
+//
+// These top-level functions centralise the conversion of [DioException]
+// values into human-readable UI strings, eliminating duplicate
+// _dioErrorMessage implementations that previously existed in
+// bootstrap_screen.dart, login_screen.dart, and home_screen.dart (DRY/DIP).
+//
+// All functions are pure data-transformations: no widget state, no Riverpod
+// reads, no BuildContext — making them easy to unit-test in isolation.
+
+/// Maps an exception thrown by any API call to a human-readable UI string.
+///
+/// Prefers messages extracted from the [DioException] response body; falls
+/// back to status-code–specific text; finally uses a generic connectivity
+/// message. Pass [statusFallbacks] to supply caller-specific status-code
+/// messages (e.g. 401 → "Invalid username or password." for login).
+String dioErrorMessage(
+ DioException e, {
+ Map<int, String> statusFallbacks = const {},
+}) {
+ // Prefer a human-readable message from the server's JSON response body.
+ final body = e.response?.data;
+ if (body is Map<String, dynamic>) {
+ final msg = body['message'] as String? ?? body['error'] as String?;
+ if (msg != null && msg.isNotEmpty) return msg;
+ }
+
+ // Apply caller-specific status-code fallbacks (e.g. auth screens).
+ final statusCode = e.response?.statusCode;
+ if (statusCode != null) {
+ final fallback = statusFallbacks[statusCode];
+ if (fallback != null) return fallback;
+ }
+
+ // Generic status-code fallback.
+ if (statusCode != null) {
+ return 'Server error ($statusCode). Please try again.';
+ }
+
+ // No HTTP response: connectivity or DNS failure.
+ return 'Could not reach the server. Check your network connection.';
+}
+
+/// Maps a [DioException] using connection-type heuristics instead of status
+/// codes — suited for read-only data-fetching calls (e.g. listing sets)
+/// where there is no login-specific 401/403 semantics.
+///
+/// Distinguishes between connectivity/timeout failures and server-side HTTP
+/// errors so the user knows whether to check their network or contact support.
+String dioConnectionErrorMessage(DioException e) {
+ switch (e.type) {
+ case DioExceptionType.connectionError:
+ case DioExceptionType.sendTimeout:
+ case DioExceptionType.receiveTimeout:
+ case DioExceptionType.connectionTimeout:
+ return 'Could not reach the server. Check your connection and try again.';
+ case DioExceptionType.badResponse:
+ final code = e.response?.statusCode ?? 0;
+ if (code == 401) return 'Session expired. Please log in again.';
+ return 'Server error ($code). Please try again.';
+ default:
+ return 'Unexpected error. Please try again.';
+ }
+}
+
+/// Maps any thrown object from [PlayerApiClient.listSets] to a UI string.
+///
+/// Delegates to [dioConnectionErrorMessage] for [DioException]; returns a
+/// generic fallback for all other exception types.
+String setsErrorMessage(Object error) {
+ if (error is DioException) {
+ return dioConnectionErrorMessage(error);
+ }
+ return 'Unexpected error. Please try again.';
+}
diff --git a/player-android/pubspec.lock b/player-android/pubspec.lock
index 44e8eff..91d9b07 100644
--- a/player-android/pubspec.lock
+++ b/player-android/pubspec.lock
@@ -25,6 +25,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
+ cached_network_image:
+ dependency: "direct main"
+ description:
+ name: cached_network_image
+ sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.4.1"
+ cached_network_image_platform_interface:
+ dependency: transitive
+ description:
+ name: cached_network_image_platform_interface
+ sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.1.1"
+ cached_network_image_web:
+ dependency: transitive
+ description:
+ name: cached_network_image_web
+ sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.1"
characters:
dependency: transitive
description:
@@ -105,11 +129,27 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
+ fixnum:
+ dependency: transitive
+ description:
+ name: fixnum
+ sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
+ flutter_cache_manager:
+ dependency: transitive
+ description:
+ name: flutter_cache_manager
+ sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.4.1"
flutter_lints:
dependency: "direct dev"
description:
@@ -208,6 +248,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.3"
+ http:
+ dependency: transitive
+ description:
+ name: http
+ sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.6.0"
http_mock_adapter:
dependency: "direct dev"
description:
@@ -344,6 +392,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "9.3.0"
+ octo_image:
+ dependency: transitive
+ description:
+ name: octo_image
+ sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.0"
package_config:
dependency: transitive
description:
@@ -448,6 +504,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.1"
+ rxdart:
+ dependency: transitive
+ description:
+ name: rxdart
+ sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.28.0"
shared_preferences:
dependency: "direct main"
description:
@@ -517,6 +581,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.2"
+ sqflite:
+ dependency: transitive
+ description:
+ name: sqflite
+ sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.2+1"
+ sqflite_android:
+ dependency: transitive
+ description:
+ name: sqflite_android
+ sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.2+3"
+ sqflite_common:
+ dependency: transitive
+ description:
+ name: sqflite_common
+ sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.8"
+ sqflite_darwin:
+ dependency: transitive
+ description:
+ name: sqflite_darwin
+ sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.2"
+ sqflite_platform_interface:
+ dependency: transitive
+ description:
+ name: sqflite_platform_interface
+ sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.0"
stack_trace:
dependency: transitive
description:
@@ -549,6 +653,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
+ synchronized:
+ dependency: transitive
+ description:
+ name: synchronized
+ sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.4.0+1"
term_glyph:
dependency: transitive
description:
@@ -573,6 +685,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
+ uuid:
+ dependency: transitive
+ description:
+ name: uuid
+ sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.5.3"
vector_math:
dependency: transitive
description:
@@ -622,5 +742,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
- dart: ">=3.10.3 <4.0.0"
+ dart: ">=3.11.0 <4.0.0"
flutter: ">=3.38.4"
diff --git a/player-android/pubspec.yaml b/player-android/pubspec.yaml
index c693cff..d66852c 100644
--- a/player-android/pubspec.yaml
+++ b/player-android/pubspec.yaml
@@ -20,6 +20,9 @@ dependencies: