diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-21 23:22:57 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-21 23:22:57 +0300 |
| commit | d40a585b5848cc5bfe788c372a2bd1c028b9308d (patch) | |
| tree | 46a7cbd0669b1e5e512922a4843dc74abaf93975 | |
| parent | b33983f51c96db1d1b9e4e18d1478a3a993beb03 (diff) | |
Implement FolderBrowserScreen with browseSet endpoint and breadcrumb navigation (va)
Adds a FolderBrowserScreen that calls browseSet on init and pull-to-refresh,
renders subfolders first (with cover via setFolderCoverUrl) then media items,
and provides a scrollable breadcrumb bar for navigating the folder hierarchy.
Key changes:
- player-android/lib/screens/folder_browser_screen.dart: new screen with
loading/empty/error/refresh states, generation-counter cancellation, and
no Dio import in the screen layer (DIP).
- player-android/lib/api/player_api_client.dart: add setFolderCoverUrl() so
screens never access rawDio directly for URL construction (DIP).
- player-android/lib/utils/duration_formatter.dart: extract shared
formatDuration() from MediaGridScreen to eliminate the DRY violation.
- player-android/lib/screens/media_grid_screen.dart: delegate to
formatDuration() from the shared utility.
- player-android/lib/utils/error_mappers.dart: add folderErrorMessage().
- player-android/lib/app_routes.dart: add folderBrowser and folderBrowserPath().
- player-android/lib/router.dart: wire /browse/:setId GoRoute.
- player-android/test/screens/folder_browser_screen_test.dart: 16 widget tests
covering renders, breadcrumbs, folder/media tap navigation, empty, error,
retry, and pull-to-refresh.
All 298 tests pass; flutter analyze reports no issues.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | player-android/lib/api/player_api_client.dart | 13 | ||||
| -rw-r--r-- | player-android/lib/app_routes.dart | 15 | ||||
| -rw-r--r-- | player-android/lib/router.dart | 22 | ||||
| -rw-r--r-- | player-android/lib/screens/folder_browser_screen.dart | 758 | ||||
| -rw-r--r-- | player-android/lib/screens/media_grid_screen.dart | 16 | ||||
| -rw-r--r-- | player-android/lib/utils/duration_formatter.dart | 29 | ||||
| -rw-r--r-- | player-android/lib/utils/error_mappers.dart | 20 | ||||
| -rw-r--r-- | player-android/test/screens/folder_browser_screen_test.dart | 624 |
8 files changed, 1485 insertions, 12 deletions
diff --git a/player-android/lib/api/player_api_client.dart b/player-android/lib/api/player_api_client.dart index 7f41ec2..a1f4fe9 100644 --- a/player-android/lib/api/player_api_client.dart +++ b/player-android/lib/api/player_api_client.dart @@ -153,6 +153,19 @@ class PlayerApiClient { String streamUrl(int mediaId) => '${rawDio.options.baseUrl}/api/v1/media/$mediaId/stream'; + /// Returns the URL for the cover image of a set's root or subfolder. + /// + /// Mirrors [thumbnailUrl] and [streamUrl]: the API path + /// `/api/v1/sets/{id}/cover` is kept in one place so the UI layer never + /// needs to access Dio internals or hard-code URL segments (DIP). + /// + /// [folder] is the optional subfolder path (empty or null = set root cover). + String setFolderCoverUrl(int setId, {String? folder}) { + final base = '${rawDio.options.baseUrl}/api/v1/sets/$setId/cover'; + if (folder == null || folder.isEmpty) return base; + return '$base?folder=${Uri.encodeComponent(folder)}'; + } + /// Returns the public share URL for a share [token]. /// /// Mirrors [thumbnailUrl] and [streamUrl]: the share path `/s/{token}` is diff --git a/player-android/lib/app_routes.dart b/player-android/lib/app_routes.dart index 32998aa..533c9b8 100644 --- a/player-android/lib/app_routes.dart +++ b/player-android/lib/app_routes.dart @@ -60,6 +60,21 @@ abstract final class AppRoutes { /// Opens [MySharesScreen] from Settings. static const shares = '/shares'; + /// Route for browsing folders within a set. + /// + /// The ':setId' path segment identifies the set; the optional 'path' query + /// parameter specifies the current subfolder (empty or absent = root). + static const folderBrowser = '/browse/:setId'; + + /// Returns the concrete path for the folder browser of a given [setId]. + /// + /// [path] is the optional subfolder path; omit or pass null for the root. + static String folderBrowserPath(int setId, {String? path}) { + final base = '/browse/$setId'; + if (path == null || path.isEmpty) return base; + return '$base?path=${Uri.encodeComponent(path)}'; + } + /// Returns the concrete path for the notes editor of a given [mediaId]. static String notesPath(String mediaId) => '/notes/$mediaId'; } diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart index 03f14bc..170cfd5 100644 --- a/player-android/lib/router.dart +++ b/player-android/lib/router.dart @@ -18,6 +18,7 @@ import 'screens/settings_screen.dart'; import 'screens/share_screen.dart'; import 'screens/my_shares_screen.dart'; import 'screens/notes_editor_screen.dart'; +import 'screens/folder_browser_screen.dart'; import 'screens/video_player_screen.dart'; // Re-export AppRoutes so existing callers that import router.dart for routes @@ -187,6 +188,27 @@ final routerProvider = Provider<GoRouter>((ref) { path: AppRoutes.shares, builder: (context, state) => const MySharesScreen(), ), + GoRoute( + // Folder browser — shows subfolders and media at the current path + // within a set. The ':setId' path segment identifies the set; + // the optional 'path' query parameter identifies the current subfolder + // (absent or empty means root). + path: AppRoutes.folderBrowser, + builder: (context, state) { + final raw = state.pathParameters['setId']!; + final setId = int.tryParse(raw) ?? 0; + final path = state.uri.queryParameters['path']; + // setName is optionally passed as a String extra so the screen can + // show the set name in the app bar without an extra API call. + final setName = + state.extra is String ? state.extra as String : null; + return FolderBrowserScreen( + setId: setId, + path: path, + setName: setName, + ); + }, + ), ], ); }); diff --git a/player-android/lib/screens/folder_browser_screen.dart b/player-android/lib/screens/folder_browser_screen.dart new file mode 100644 index 0000000..175c66b --- /dev/null +++ b/player-android/lib/screens/folder_browser_screen.dart @@ -0,0 +1,758 @@ +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/duration_formatter.dart'; +import '../utils/error_mappers.dart'; + +// --------------------------------------------------------------------------- +// Data models (file-private) +// --------------------------------------------------------------------------- + +/// Represents a subfolder returned by the browseSet API endpoint. +/// +/// [name] is the folder's display name and last path component. +/// [hasCover] indicates whether the server has a cover image for this folder. +class _BrowseFolder { + const _BrowseFolder({required this.name, required this.hasCover}); + + final String name; + final bool hasCover; +} + +/// Parsed result from the browseSet API response. +/// +/// Holds the canonical current path returned by the server along with lists +/// of subfolders and media items at that path. +class _BrowseResult { + const _BrowseResult({ + required this.currentPath, + required this.folders, + required this.media, + }); + + final String currentPath; + final List<_BrowseFolder> folders; + final List<Media> media; +} + +// --------------------------------------------------------------------------- +// Parsing helpers (file-private, pure functions — easy to unit-test) +// --------------------------------------------------------------------------- + +/// Parses the raw JSON map from [PlayerApiClient.browseSet] into a typed +/// [_BrowseResult]. +/// +/// Unknown or missing fields are handled gracefully: the result defaults to +/// empty lists rather than throwing, so partial server responses degrade +/// gracefully (resilience principle). +_BrowseResult _parseBrowseResult(Map<String, dynamic> raw) { + final currentPath = raw['current_path'] as String? ?? ''; + final rawFolders = raw['folders'] as List<dynamic>? ?? []; + final rawMedia = raw['media'] as List<dynamic>? ?? []; + + final folders = rawFolders + .whereType<Map<String, dynamic>>() + .map( + (f) => _BrowseFolder( + name: f['name'] as String? ?? '', + hasCover: f['has_cover'] as bool? ?? false, + ), + ) + .where((f) => f.name.isNotEmpty) + .toList(); + + final media = rawMedia + .whereType<Map<String, dynamic>>() + .map(Media.fromJson) + .toList(); + + return _BrowseResult( + currentPath: currentPath, + folders: folders, + media: media, + ); +} + +/// Splits a slash-delimited [path] into breadcrumb segments. +/// +/// Returns a list of `(label, accumulatedPath)` pairs — the first entry is +/// always the root `('Home', '')`, followed by one entry per path component. +/// This pure function makes the breadcrumb logic independently testable. +List<({String label, String path})> _buildBreadcrumbs(String path) { + final crumbs = <({String label, String path})>[ + (label: 'Home', path: ''), + ]; + if (path.isEmpty) return crumbs; + + final parts = path.split('/').where((p) => p.isNotEmpty).toList(); + var accumulated = ''; + for (final part in parts) { + accumulated = accumulated.isEmpty ? part : '$accumulated/$part'; + crumbs.add((label: part, path: accumulated)); + } + return crumbs; +} + +// --------------------------------------------------------------------------- +// FolderBrowserScreen +// --------------------------------------------------------------------------- + +/// Displays the contents of a single folder within a [MediaSet]. +/// +/// Shows subfolders first (with cover thumbnail) followed by media items. +/// A breadcrumb bar at the top lets the user navigate back up the tree. +/// +/// Design notes: +/// - Accepts [setId] and optional [path] as constructor parameters so the +/// screen is independently testable and reusable (DIP / ISP). +/// - No `dio` import: errors are mapped by [folderErrorMessage] (DIP). +/// - All async continuations guard on [mounted] to prevent setState after +/// disposal (Flutter best practice). +/// - The generation counter prevents stale responses from overwriting +/// fresher data when navigations happen in quick succession. +class FolderBrowserScreen extends ConsumerStatefulWidget { + /// The numeric identifier of the set to browse. + final int setId; + + /// The current subfolder path (empty or null = root of the set). + final String? path; + + /// Optional human-readable set name shown in the app bar. + /// + /// Passed as a route extra by callers so the bar shows the name immediately + /// without an extra API call. + final String? setName; + + const FolderBrowserScreen({ + super.key, + required this.setId, + this.path, + this.setName, + }); + + @override + ConsumerState<FolderBrowserScreen> createState() => + _FolderBrowserScreenState(); +} + +class _FolderBrowserScreenState extends ConsumerState<FolderBrowserScreen> { + // Nullable: null means "not yet loaded" (loading indicator is shown). + _BrowseResult? _result; + + // Non-null when the last load attempt failed. + String? _error; + + // True while the initial or refresh load is in flight. + bool _isLoading = false; + + // Generation counter used to discard stale async responses (cancellation- + // by-generation pattern, consistent with MediaGridScreen). + int _loadGeneration = 0; + + @override + void initState() { + super.initState(); + // Defer the first load until after the first frame so [ref] is fully + // bound and any test-environment provider overrides are applied. + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } + + // --------------------------------------------------------------------------- + // Data loading + // --------------------------------------------------------------------------- + + /// Fetches subfolder and media content for [widget.setId] at [widget.path] + /// and updates local state. + /// + /// The generation counter ensures responses from superseded loads are + /// silently discarded, preventing race conditions on rapid navigations. + Future<void> _load() async { + if (!mounted) return; + + final generation = ++_loadGeneration; + + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final client = ref.read(apiClientProvider); + final raw = await client.browseSet( + widget.setId, + parent: widget.path, + ); + + if (!mounted || generation != _loadGeneration) return; + + setState(() { + _result = _parseBrowseResult(raw); + _isLoading = false; + }); + } catch (e) { + if (!mounted || generation != _loadGeneration) return; + setState(() { + _error = folderErrorMessage(e); + _isLoading = false; + }); + } + } + + // --------------------------------------------------------------------------- + // Navigation helpers + // --------------------------------------------------------------------------- + + /// Navigates into [folderName] by pushing a new [FolderBrowserScreen] route. + /// + /// The new path is the current path joined with [folderName]. Using + /// `context.push` (rather than `go`) keeps the back-stack intact so the + /// user can navigate back up to the parent folder. + void _openFolder(String folderName) { + final currentPath = widget.path ?? ''; + final newPath = + currentPath.isEmpty ? folderName : '$currentPath/$folderName'; + context.push( + AppRoutes.folderBrowserPath(widget.setId, path: newPath), + extra: widget.setName, + ); + } + + /// Navigates to a breadcrumb [crumbPath]. + /// + /// For the root ('') we pop until the route matching the root path; for + /// intermediate paths we push the new screen. This keeps the navigator + /// stack correct in all navigation directions. + void _navigateToBreadcrumb(String crumbPath) { + if (crumbPath == (widget.path ?? '')) return; // already here + context.push( + AppRoutes.folderBrowserPath(widget.setId, path: crumbPath), + extra: widget.setName, + ); + } + + /// Navigates to the [MediaDetailScreen] for [mediaId]. + void _openMedia(int mediaId) => + context.push(AppRoutes.mediaDetailPath(mediaId)); + + /// Returns the URL for the cover image of a folder at [folderPath] within + /// [widget.setId]. + /// + /// Delegates URL construction to [PlayerApiClient.setFolderCoverUrl] so this + /// screen never builds API paths or accesses Dio internals directly + /// (Dependency Inversion Principle, consistent with [thumbnailUrl] pattern). + String _coverUrl(String folderPath) { + final client = ref.read(apiClientProvider); + return client.setFolderCoverUrl(widget.setId, folder: folderPath); + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: _buildAppBar(), + body: _buildBody(), + ); + } + + /// Builds the app bar with the set name (or a default label). + AppBar _buildAppBar() { + return AppBar( + title: Text(widget.setName ?? 'Set ${widget.setId}'), + ); + } + + /// Dispatches to the appropriate body widget based on the current state. + Widget _buildBody() { + // Full-screen spinner only on the very first load (no data yet). + if (_isLoading && _result == null) { + return const Center( + key: Key('folder_loading'), + child: CircularProgressIndicator(), + ); + } + + // Error view with a retry button. + if (_error != null) { + return _ErrorView(message: _error!, onRetry: _load); + } + + return RefreshIndicator( + onRefresh: _load, + child: _buildContent(), + ); + } + + /// Builds the scrollable content: breadcrumb bar + folder list + media list. + Widget _buildContent() { + final result = _result; + final path = widget.path ?? ''; + final crumbs = _buildBreadcrumbs(path); + + return CustomScrollView( + key: const Key('folder_browser_scroll'), + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + // Breadcrumb navigation bar. + SliverToBoxAdapter( + child: _BreadcrumbBar( + crumbs: crumbs, + onTap: _navigateToBreadcrumb, + ), + ), + + // Content: empty state, or folders + media list. + if (result == null || (result.folders.isEmpty && result.media.isEmpty)) + const SliverFillRemaining( + hasScrollBody: false, + child: _EmptyView(), + ) + else ...[ + // Subfolder tiles shown before media items. + // Item 0 is the section header; items 1..n are the folder tiles. + if (result.folders.isNotEmpty) + SliverList.builder( + itemCount: result.folders.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return const _SectionHeader( + key: Key('folder_section_header'), + label: 'Folders', + ); + } + final folder = result.folders[index - 1]; + final folderPath = path.isEmpty + ? folder.name + : '$path/${folder.name}'; + return _FolderTile( + key: Key('folder_tile_${folder.name}'), + folder: folder, + coverUrl: folder.hasCover ? _coverUrl(folderPath) : '', + onTap: () => _openFolder(folder.name), + ); + }, + ), + + // Media items shown below subfolders. + // Item 0 is the section header; items 1..n are the media tiles. + if (result.media.isNotEmpty) + SliverList.builder( + itemCount: result.media.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return const _SectionHeader( + key: Key('media_section_header'), + label: 'Media', + ); + } + final item = result.media[index - 1]; + return _MediaTile( + key: Key('media_tile_${item.id}'), + item: item, + thumbnailUrl: _thumbnailUrl(item.id), + onTap: () => _openMedia(item.id), + ); + }, + ), + ], + ], + ); + } + + /// Returns the thumbnail URL for a media item. + /// + /// Delegates to [PlayerApiClient.thumbnailUrl] so no API path is + /// hard-coded in the screen layer (DIP). + String _thumbnailUrl(int mediaId) { + final client = ref.read(apiClientProvider); + return client.thumbnailUrl(mediaId); + } +} + +// --------------------------------------------------------------------------- +// Sub-widgets +// --------------------------------------------------------------------------- + +/// Horizontal scrollable breadcrumb bar showing the current folder hierarchy. +/// +/// Each crumb is a tappable [TextButton]; the last crumb (current folder) is +/// shown in a highlighted style and is not interactive. +class _BreadcrumbBar extends StatelessWidget { + const _BreadcrumbBar({required this.crumbs, required this.onTap}); + + /// Ordered list of `(label, path)` pairs, root-first. + final List<({String label, String path})> crumbs; + + /// Called when any non-last crumb is tapped; receives the target path. + final void Function(String path) onTap; + + @override + Widget build(BuildContext context) { + return Container( + key: const Key('breadcrumb_bar'), + color: Theme.of(context).colorScheme.surfaceContainerLow, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: _buildCrumbs(context), + ), + ), + ); + } + + /// Builds the crumb widgets interleaved with separator chevrons. + List<Widget> _buildCrumbs(BuildContext context) { + final widgets = <Widget>[]; + for (var i = 0; i < crumbs.length; i++) { + final crumb = crumbs[i]; + final isLast = i == crumbs.length - 1; + + if (i > 0) { + // Separator between crumbs. + widgets.add( + Icon( + Icons.chevron_right, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } + + widgets.add( + isLast + // Current folder: styled as body text, non-tappable. + ? Text( + crumb.label, + key: Key('breadcrumb_current_${crumb.label}'), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ) + // Ancestor folder: tappable TextButton. + : TextButton( + key: Key('breadcrumb_${crumb.label}'), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => onTap(crumb.path), + child: Text(crumb.label), + ), + ); + } + return widgets; + } +} + +/// Section header label (e.g. "Folders", "Media"). +/// +/// Separates the folder list from the media list with a lightweight divider +/// and label so the user understands the two sections at a glance. +class _SectionHeader extends StatelessWidget { + const _SectionHeader({super.key, required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +/// List tile for a single subfolder entry. +/// +/// Shows a folder cover image (if available) or a folder icon placeholder, +/// and the folder name. Tapping fires [onTap] to navigate into the folder. +class _FolderTile extends StatelessWidget { + const _FolderTile({ + super.key, + required this.folder, + required this.coverUrl, + required this.onTap, + }); + + final _BrowseFolder folder; + + /// Full URL of the folder cover image; empty string means no cover. + final String coverUrl; + + /// Called when the tile is tapped. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: _FolderCoverImage( + key: Key('folder_cover_${folder.name}'), + coverUrl: coverUrl, + ), + title: Text( + folder.name, + key: Key('folder_name_${folder.name}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ); + } +} + +/// Square cover thumbnail for a folder. +/// +/// Falls back to a folder icon placeholder when no cover URL is provided or +/// when the network request fails. Consistent with the [_ThumbnailImage] +/// pattern in [MediaGridScreen]. +class _FolderCoverImage extends StatelessWidget { + const _FolderCoverImage({super.key, required this.coverUrl}); + + final String coverUrl; + + @override + Widget build(BuildContext context) { + const size = 48.0; + + if (coverUrl.isEmpty) return _placeholder(context, size); + + return SizedBox( + width: size, + height: size, + child: CachedNetworkImage( + imageUrl: coverUrl, + fit: BoxFit.cover, + placeholder: (_, __) => + const Center(child: CircularProgressIndicator()), + errorWidget: (_, __, ___) => _placeholder(context, size), + ), + ); + } + + static Widget _placeholder(BuildContext context, double size) => SizedBox( + width: size, + height: size, + child: ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Icon( + Icons.folder_outlined, + size: size * 0.6, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); +} + +/// List tile for a single media item. +/// +/// Shows a thumbnail (or icon placeholder), the file name, type icon, and +/// formatted duration. Tapping fires [onTap] to navigate to the detail screen. +class _MediaTile extends StatelessWidget { + const _MediaTile({ + super.key, + required this.item, + required this.thumbnailUrl, + required this.onTap, + }); + + final Media item; + + /// Full URL of the media thumbnail; empty string means no thumbnail. + final String thumbnailUrl; + + /// Called when the tile is tapped. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: _MediaThumbnail( + key: Key('media_thumb_${item.id}'), + thumbnailUrl: thumbnailUrl, + type: item.type, + ), + title: Text( + item.fileName, + key: Key('media_tile_name_${item.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Row( + children: [ + Icon(_typeIcon(item.type), size: 12), + const SizedBox(width: 4), + Text( + _formatDuration(item.duration), + key: Key('media_tile_duration_${item.id}'), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + onTap: onTap, + ); + } + + /// Returns an appropriate icon for the given media [type]. + static IconData _typeIcon(String type) { + switch (type) { + case 'video': + return Icons.videocam_outlined; + case 'audio': + return Icons.headphones_outlined; + case 'image': + return Icons.image_outlined; + default: + return Icons.insert_drive_file_outlined; + } + } + + /// Formats [seconds] as `h:mm:ss` or `m:ss`, omitting leading zeros. + /// + /// Delegates to the shared [formatDuration] helper (DRY) so the formatting + /// logic lives in exactly one place across all screen widgets. + static String _formatDuration(double seconds) => formatDuration(seconds); +} + +/// Small square thumbnail for a media tile. +/// +/// Mirrors the [_ThumbnailImage] approach from [MediaGridScreen], using +/// [CachedNetworkImage] with placeholder and error fallback. +class _MediaThumbnail extends StatelessWidget { + const _MediaThumbnail({ + super.key, + required this.thumbnailUrl, + required this.type, + }); + + final String thumbnailUrl; + + /// Media type string used to pick the placeholder icon. + final String type; + + @override + Widget build(BuildContext context) { + const size = 48.0; + + if (thumbnailUrl.isEmpty) return _placeholder(context, size); + + return SizedBox( + width: size, + height: size, + child: CachedNetworkImage( + imageUrl: thumbnailUrl, + fit: BoxFit.cover, + placeholder: (_, __) => + const Center(child: CircularProgressIndicator()), + errorWidget: (_, __, ___) => _placeholder(context, size), + ), + ); + } + + static Widget _placeholder(BuildContext context, double size) => SizedBox( + width: size, + height: size, + child: ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Icon( + Icons.image_outlined, + size: size * 0.6, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); +} + +/// Full-screen empty-state view shown when the current folder is empty. +/// +/// Wrapped in a layout that fills the sliver so pull-to-refresh gestures are +/// still captured by [RefreshIndicator]. +class _EmptyView extends StatelessWidget { + const _EmptyView(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.folder_open_outlined, + size: 72, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'This folder is empty', + key: const Key('folder_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 [browseSet] throws (network error, server error, etc.). +/// The [message] comes from [folderErrorMessage], which maps exceptions to +/// human-readable strings without exposing Dio to the screen layer. +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('folder_error'), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + key: const Key('folder_retry'), + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } +} diff --git a/player-android/lib/screens/media_grid_screen.dart b/player-android/lib/screens/media_grid_screen.dart index 9e58b15..9d75fd5 100644 --- a/player-android/lib/screens/media_grid_screen.dart +++ b/player-android/lib/screens/media_grid_screen.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import '../app_routes.dart'; import '../models/models.dart'; import '../providers/api_client_provider.dart'; +import '../utils/duration_formatter.dart'; import '../utils/error_mappers.dart'; import '../widgets/search_filter_bar.dart'; @@ -599,18 +600,9 @@ class _InfoOverlay extends StatelessWidget { /// Formats [seconds] as `h:mm:ss` or `m:ss`, omitting leading zeros. /// - /// Uses integer arithmetic only — no Duration formatting dependency — to - /// keep this helper lightweight and independently testable. - static String _formatDuration(double seconds) { - final total = seconds.truncate(); - final h = total ~/ 3600; - final m = (total % 3600) ~/ 60; - final s = total % 60; - if (h > 0) { - return '$h:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; - } - return '$m:${s.toString().padLeft(2, '0')}'; - } + /// Delegates to the shared [formatDuration] helper in `duration_formatter.dart` + /// (DRY) so the formatting logic lives in exactly one place. + static String _formatDuration(double seconds) => formatDuration(seconds); } /// Full-screen empty-state view, shown when [listMedia] returns an empty list. diff --git a/player-android/lib/utils/duration_formatter.dart b/player-android/lib/utils/duration_formatter.dart new file mode 100644 index 0000000..72e810b --- /dev/null +++ b/player-android/lib/utils/duration_formatter.dart @@ -0,0 +1,29 @@ +// Duration formatting utilities shared across screen widgets. +// +// Centralises the conversion of fractional seconds to a human-readable string +// so that [MediaGridScreen] and [FolderBrowserScreen] do not each carry their +// own copy of the same logic (DRY / Single Responsibility). +// +// All functions are pure data-transformations: no widget state, no Riverpod +// reads, no BuildContext — making them easy to unit-test in isolation. + +/// Formats [seconds] as `h:mm:ss` or `m:ss`, omitting leading zeros in the +/// hours and minutes positions. +/// +/// Examples: +/// - 7320.0 → "2:02:00" +/// - 210.5 → "3:30" +/// - 30.0 → "0:30" +/// +/// Uses integer arithmetic only — no [Duration] dependency — so the helper +/// is lightweight and independently testable. +String formatDuration(double seconds) { + final total = seconds.truncate(); + final h = total ~/ 3600; + final m = (total % 3600) ~/ 60; + final s = total % 60; + if (h > 0) { + return '$h:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; + } + return '$m:${s.toString().padLeft(2, '0')}'; +} diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart index 6ce5f33..3e54ce7 100644 --- a/player-android/lib/utils/error_mappers.dart +++ b/player-android/lib/utils/error_mappers.dart @@ -238,3 +238,23 @@ String sharesErrorMessage(Object error) { } return 'Unexpected error. Please try again.'; } + +/// Maps any thrown object from [PlayerApiClient.browseSet] to a UI string. +/// +/// Adds a 403-specific message (permission denied) and a 404 message (set not +/// found) on top of the generic connection-error fallback, so +/// FolderBrowserScreen can surface actionable guidance rather than a raw code. +/// Kept as a separate top-level function (Open-Closed, DRY) so it can evolve +/// independently of the other mappers. +String folderErrorMessage(Object error) { + if (error is DioException) { + if (error.response?.statusCode == 404) { + return 'Folder not found. It may have been removed.'; + } + if (error.response?.statusCode == 403) { + return 'You do not have permission to browse this folder.'; + } + return dioConnectionErrorMessage(error); + } + return 'Unexpected error. Please try again.'; +} diff --git a/player-android/test/screens/folder_browser_screen_test.dart b/player-android/test/screens/folder_browser_screen_test.dart new file mode 100644 index 0000000..b645253 --- |
