From 6365d47f8257efa830d390c092ee9586aded9040 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 21 May 2026 08:07:06 +0300 Subject: Implement SetsListScreen with Material 3 grid, pull-to-refresh, and widget tests (ta) Co-Authored-By: Claude Sonnet 4.6 --- player-android/lib/app_routes.dart | 7 + player-android/lib/router.dart | 13 +- player-android/lib/screens/bootstrap_screen.dart | 42 +-- player-android/lib/screens/home_screen.dart | 405 ++++++++++++++++++++- player-android/lib/screens/login_screen.dart | 45 +-- player-android/lib/screens/media_grid_screen.dart | 43 +++ player-android/lib/utils/error_mappers.dart | 79 ++++ player-android/pubspec.lock | 122 ++++++- player-android/pubspec.yaml | 3 + .../test/screens/sets_list_screen_test.dart | 372 +++++++++++++++++++ 10 files changed, 1051 insertions(+), 80 deletions(-) create mode 100644 player-android/lib/screens/media_grid_screen.dart create mode 100644 player-android/lib/utils/error_mappers.dart create mode 100644 player-android/test/screens/sets_list_screen_test.dart 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((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 { // 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 { // --------------------------------------------------------------------------- // 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) { - 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 createState() => _SetsListScreenState(); +} + +class _SetsListScreenState extends ConsumerState { + // Nullable: null means "not yet loaded" (loading indicator is shown). + List? _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 _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 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 { // 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 { // --------------------------------------------------------------------------- // 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) { - 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 statusFallbacks = const {}, +}) { + // Prefer a human-readable message from the server's JSON response body. + final body = e.response?.data; + if (body is Map) { + 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: # shared_preferences: persists simple key-value settings (e.g. server base URL) # using platform-native storage (SharedPreferences on Android, NSUserDefaults on iOS). shared_preferences: ^2.3.2 + # cached_network_image: downloads and caches remote images (e.g. set covers) + # so repeated visits do not re-fetch from the server. + cached_network_image: ^3.4.1 dev_dependencies: flutter_test: diff --git a/player-android/test/screens/sets_list_screen_test.dart b/player-android/test/screens/sets_list_screen_test.dart new file mode 100644 index 0000000..f868227 --- /dev/null +++ b/player-android/test/screens/sets_list_screen_test.dart @@ -0,0 +1,372 @@ +// Widget tests for SetsListScreen (home_screen.dart). +// +// Tests cover: +// 1. Renders a loading indicator while the data is in flight. +// 2. Renders a list/grid of sets after a successful load. +// 3. Shows a podcast badge (microphone icon) on podcast sets. +// 4. Navigates to the media-grid screen on set-card tap. +// 5. Shows an empty-state widget when listSets returns []. +// 6. Shows an error view when listSets throws. +// 7. Pull-to-refresh calls listSets again. +// +// Riverpod providers are overridden with fakes so tests run without a real +// server or OS keychain. +// +// Run with: flutter test test/screens/sets_list_screen_test.dart + +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:player_android/api/dio_client.dart'; +import 'package:player_android/api/player_api_client.dart'; +import 'package:player_android/models/models.dart'; +import 'package:player_android/providers/api_client_provider.dart'; +import 'package:player_android/screens/home_screen.dart'; +import 'package:player_android/utils/error_mappers.dart'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// In-memory [TokenStorage] used to avoid the platform-specific OS keychain. +class _FakeTokenStorage implements TokenStorage { + const _FakeTokenStorage(); + + @override + Future readToken() async => 'test-token'; + + @override + Future writeToken(String token) async {} + + @override + Future deleteToken() async {} +} + +/// Controllable [PlayerApiClient] stub for [SetsListScreen] tests. +/// +/// The [listSets] behaviour is configured per-test via [setsResult] or +/// [setsError]. Every other method remains [UnimplementedError] — the screen +/// only calls [listSets]. +class _FakeApiClient extends PlayerApiClient { + _FakeApiClient() : super(dio: Dio()); + + /// When non-null, [listSets] returns this list. + List? setsResult; + + /// When non-null, [listSets] throws this instead of returning. + Object? setsError; + + /// Number of times [listSets] has been called; useful for refresh tests. + int listSetsCallCount = 0; + + @override + Future> listSets() async { + listSetsCallCount++; + if (setsError != null) throw setsError!; + return setsResult!; + } +} + +/// [PlayerApiClient] stub that delays [listSets] until [complete] is called. +/// +/// Used to inspect mid-flight loading state. +class _DelayedFakeApiClient extends PlayerApiClient { + _DelayedFakeApiClient() : super(dio: Dio()); + + final _completer = Completer>(); + + /// Resolves the pending [listSets] call with [sets]. + void complete(List sets) => _completer.complete(sets); + + @override + Future> listSets() => _completer.future; +} + +// --------------------------------------------------------------------------- +// Sample data +// --------------------------------------------------------------------------- + +/// A regular (non-podcast) media set used across tests. +const _kMovies = MediaSet( + id: 1, + name: 'Movies', + rootPath: 'movies', + coverThumbnailPath: '', + isPodcast: false, +); + +/// A podcast set — should display the podcast badge. +const _kPodcasts = MediaSet( + id: 2, + name: 'Tech Podcasts', + rootPath: 'podcasts', + coverThumbnailPath: '', + isPodcast: true, +); + +// --------------------------------------------------------------------------- +// Helper: pump SetsListScreen inside a minimal ProviderScope. +// --------------------------------------------------------------------------- + +/// Pumps [SetsListScreen] inside a [ProviderScope] that overrides +/// [apiClientProvider] and [tokenStorageProvider] with fakes. +/// +/// Returns the pumped [WidgetTester] for further interaction. +Future _pumpSetsListScreen( + WidgetTester tester, + PlayerApiClient fakeClient, +) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + // Avoid OS keychain in tests. + tokenStorageProvider.overrideWithValue(const _FakeTokenStorage()), + // Use the controllable fake instead of a real HTTP client. + apiClientProvider.overrideWithValue(fakeClient), + ], + child: const MaterialApp( + home: SetsListScreen(), + ), + ), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // -------------------------------------------------------------------------- + // Loading state + // -------------------------------------------------------------------------- + + group('loading state', () { + testWidgets('shows loading indicator while listSets is in flight', + (tester) async { + // Delay the response so we can inspect mid-flight state. + final fakeClient = _DelayedFakeApiClient(); + + await _pumpSetsListScreen(tester, fakeClient); + + // Pump a single frame: initState → addPostFrameCallback fires, but the + // Future has not resolved yet. + await tester.pump(); + + // Before the first frame the widget shows a loading spinner. + expect(find.byKey(const Key('sets_loading')), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Resolve the fake to avoid "async work pending" warnings. + fakeClient.complete([_kMovies]); + await tester.pumpAndSettle(); + }); + }); + + // -------------------------------------------------------------------------- + // Renders sets + // -------------------------------------------------------------------------- + + group('renders sets', () { + testWidgets('shows a card for each set returned by listSets', + (tester) async { + final fakeClient = _FakeApiClient() + ..setsResult = [_kMovies, _kPodcasts]; + + await _pumpSetsListScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Both set names must be visible. + expect(find.text('Movies'), findsOneWidget); + expect(find.text('Tech Podcasts'), findsOneWidget); + + // A card widget is rendered for each set. + expect(find.byKey(const Key('set_card_1')), findsOneWidget); + expect(find.byKey(const Key('set_card_2')), findsOneWidget); + }); + + testWidgets('renders the sets grid after a successful load', (tester) async { + final fakeClient = _FakeApiClient()..setsResult = [_kMovies]; + + await _pumpSetsListScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // The grid widget is present once data is loaded. + expect(find.byKey(const Key('sets_grid')), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Podcast badge + // -------------------------------------------------------------------------- + + group('podcast badge', () { + testWidgets('shows podcast badge for podcast sets', (tester) async { + final fakeClient = _FakeApiClient() + ..setsResult = [_kMovies, _kPodcasts]; + + await _pumpSetsListScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // The podcast badge (microphone icon container) is rendered exactly once: + // on the podcast set card only. + expect(find.byKey(const Key('podcast_badge')), findsOneWidget); + }); + + testWidgets('does not show podcast badge for non-podcast sets', + (tester) async { + final fakeClient = _FakeApiClient()..setsResult = [_kMovies]; + + await _pumpSetsListScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // No podcast badge when all sets are regular media. + expect(find.byKey(const Key('podcast_badge')), findsNothing); + }); + }); + + // -------------------------------------------------------------------------- + // Empty state + // -------------------------------------------------------------------------- + + group('empty state', () { + testWidgets('shows empty-state widget when listSets returns []', + (tester) async { + final fakeClient = _FakeApiClient()..setsResult = []; + + await _pumpSetsListScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // The empty-state text is shown; the grid and loading indicator are not. + expect(find.byKey(const Key('sets_empty')), findsOneWidget); + expect(find.byKey(const Key('sets_grid')), findsNothing); + expect(find.byKey(const Key('sets_loading')), findsNothing); + }); + }); + + // -------------------------------------------------------------------------- + // Error state + // -------------------------------------------------------------------------- + + group('error state', () { + testWidgets('shows error message when listSets throws a network error', + (tester) async { + final fakeClient = _FakeApiClient() + ..setsError = DioException( + requestOptions: RequestOptions(path: '/api/v1/sets'), + type: DioExceptionType.connectionError, + ); + + await _pumpSetsListScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Error widget is visible; grid and loading indicator are not. + expect(find.byKey(const Key('sets_error')), findsOneWidget); + expect(find.byKey(const Key('sets_grid')), findsNothing); + + // The error message mentions the server/connection. + expect( + find.textContaining('Could not reach the server'), + findsOneWidget, + ); + }); + + testWidgets('shows retry button on error and retry calls listSets again', + (tester) async { + final fakeClient = _FakeApiClient() + ..setsError = DioException( + requestOptions: RequestOptions(path: '/api/v1/sets'), + type: DioExceptionType.connectionError, + ); + + await _pumpSetsListScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Retry button is present. + expect(find.byKey(const Key('sets_retry')), findsOneWidget); + + // Fix the error before tapping retry so the second call succeeds. + fakeClient + ..setsError = null + ..setsResult = [_kMovies]; + + await tester.tap(find.byKey(const Key('sets_retry'))); + await tester.pumpAndSettle(); + + // After a successful retry the grid is shown. + expect(find.byKey(const Key('sets_grid')), findsOneWidget); + // listSets was called twice: once on init, once on retry. + expect(fakeClient.listSetsCallCount, equals(2)); + }); + }); + + // -------------------------------------------------------------------------- + // Pull-to-refresh + // -------------------------------------------------------------------------- + + group('pull-to-refresh', () { + testWidgets('pull-to-refresh calls listSets a second time', (tester) async { + final fakeClient = _FakeApiClient()..setsResult = [_kMovies]; + + await _pumpSetsListScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Verify initial load. + expect(fakeClient.listSetsCallCount, equals(1)); + + // Simulate pull-to-refresh by dragging down on the grid. + await tester.drag( + find.byKey(const Key('sets_grid')), + const Offset(0, 300), + ); + await tester.pumpAndSettle(); + + // listSets must have been called a second time. + expect(fakeClient.listSetsCallCount, equals(2)); + }); + }); + + // -------------------------------------------------------------------------- + // setsErrorMessage helper + // -------------------------------------------------------------------------- + + group('setsErrorMessage', () { + test('returns connectivity message for connectionError', () { + final err = DioException( + requestOptions: RequestOptions(path: '/api/v1/sets'), + type: DioExceptionType.connectionError, + ); + expect(setsErrorMessage(err), contains('Could not reach the server')); + }); + + test('returns server-error message for 500 badResponse', () { + final err = DioException( + requestOptions: RequestOptions(path: '/api/v1/sets'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/sets'), + statusCode: 500, + ), + type: DioExceptionType.badResponse, + ); + expect(setsErrorMessage(err), contains('500')); + }); + + test('returns session-expired message for 401 badResponse', () { + final err = DioException( + requestOptions: RequestOptions(path: '/api/v1/sets'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/sets'), + statusCode: 401, + ), + type: DioExceptionType.badResponse, + ); + expect(setsErrorMessage(err), contains('Session expired')); + }); + + test('returns generic message for unknown error type', () { + expect(setsErrorMessage(Exception('boom')), contains('Unexpected error')); + }); + }); +} -- cgit v1.2.3