diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-21 08:07:06 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-21 08:07:06 +0300 |
| commit | 6365d47f8257efa830d390c092ee9586aded9040 (patch) | |
| tree | be6143ff5b67ed10794b6cbd1bf84777c568ee4e | |
| parent | faa3cf36bc5b57e8b133eab8df90110b80eb4cf7 (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.dart | 7 | ||||
| -rw-r--r-- | player-android/lib/router.dart | 13 | ||||
| -rw-r--r-- | player-android/lib/screens/bootstrap_screen.dart | 42 | ||||
| -rw-r--r-- | player-android/lib/screens/home_screen.dart | 405 | ||||
| -rw-r--r-- | player-android/lib/screens/login_screen.dart | 45 | ||||
| -rw-r--r-- | player-android/lib/screens/media_grid_screen.dart | 43 | ||||
| -rw-r--r-- | player-android/lib/utils/error_mappers.dart | 79 | ||||
| -rw-r--r-- | player-android/pubspec.lock | 122 | ||||
| -rw-r--r-- | player-android/pubspec.yaml | 3 | ||||
| -rw-r--r-- | player-android/test/screens/sets_list_screen_test.dart | 372 |
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: |
