diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-21 18:47:51 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-21 18:47:51 +0300 |
| commit | d9887828685db241e01436e99885056eb61f06c1 (patch) | |
| tree | 445772f1f901ada768ed7d9c18312d7de02a1b33 | |
| parent | e58a12ee773da39d639ebb0e26fdb1b782941c56 (diff) | |
Implement Tag picker with optimistic UI, autocomplete, and delete chips on MediaDetailScreen (5b)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | player-android/lib/api/dio_player_api_client.dart | 41 | ||||
| -rw-r--r-- | player-android/lib/screens/media_detail_screen.dart | 72 | ||||
| -rw-r--r-- | player-android/lib/utils/error_mappers.dart | 22 | ||||
| -rw-r--r-- | player-android/lib/widgets/tag_picker.dart | 386 | ||||
| -rw-r--r-- | player-android/test/screens/media_detail_screen_test.dart | 48 | ||||
| -rw-r--r-- | player-android/test/widgets/tag_picker_test.dart | 430 |
6 files changed, 951 insertions, 48 deletions
diff --git a/player-android/lib/api/dio_player_api_client.dart b/player-android/lib/api/dio_player_api_client.dart index f591608..0b76b60 100644 --- a/player-android/lib/api/dio_player_api_client.dart +++ b/player-android/lib/api/dio_player_api_client.dart @@ -345,6 +345,47 @@ class DioPlayerApiClient extends PlayerApiClient { } // --------------------------------------------------------------------------- + // Tags + // --------------------------------------------------------------------------- + + /// Returns all tag names visible to the authenticated user. + /// + /// GET /api/v1/tags — returns [{"id": 1, "name": "documentary"}, ...] + /// Used by the tag-picker autocomplete to offer suggestions. + @override + Future<List<Tag>> listTags() async { + final response = await rawDio.get<List<dynamic>>('$_kApiV1/tags'); + return (response.data ?? []) + .cast<Map<String, dynamic>>() + .map(Tag.fromJson) + .toList(); + } + + /// Attaches a tag to a media item by name. + /// + /// POST /api/v1/media/{id}/tags body: {"tag": "<name>"} + /// Returns 200 {"status": "ok"} on success; throws [DioException] on error. + @override + Future<void> addTag(int mediaId, String tag) async { + await rawDio.post<void>( + '$_kApiV1/media/$mediaId/tags', + data: {'tag': tag}, + ); + } + + /// Removes a named tag from a media item. + /// + /// DELETE /api/v1/media/{id}/tags/{tag} where {tag} is URL-encoded. + /// Returns 200 {"status": "ok"} on success; throws [DioException] on error. + @override + Future<void> removeTag(int mediaId, String tag) async { + // Uri.encodeComponent encodes the tag name so characters like spaces or + // slashes in tag names do not break the URL path segment. + final encoded = Uri.encodeComponent(tag); + await rawDio.delete<void>('$_kApiV1/media/$mediaId/tags/$encoded'); + } + + // --------------------------------------------------------------------------- // Favourites // --------------------------------------------------------------------------- diff --git a/player-android/lib/screens/media_detail_screen.dart b/player-android/lib/screens/media_detail_screen.dart index a0fc46c..bee0d0e 100644 --- a/player-android/lib/screens/media_detail_screen.dart +++ b/player-android/lib/screens/media_detail_screen.dart @@ -3,10 +3,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../api/player_api_client.dart'; import '../app_routes.dart'; import '../models/models.dart'; import '../providers/api_client_provider.dart'; import '../utils/error_mappers.dart'; +import '../widgets/tag_picker.dart'; import 'create_share_dialog.dart'; // --------------------------------------------------------------------------- @@ -15,7 +17,8 @@ import 'create_share_dialog.dart'; /// Displays a single media item with its title, full metadata (codec, /// resolution, duration, file size), a thumbnail banner, a favourite toggle, -/// tag chips, and a play button that routes to the correct player. +/// an interactive tag picker, and a play button that routes to the correct +/// player. /// /// Design notes: /// - [ConsumerStatefulWidget] is used so we can hold local loading/error @@ -27,6 +30,8 @@ import 'create_share_dialog.dart'; /// in `error_mappers.dart` (Dependency Inversion Principle). /// - The screen is split into multiple focused sub-widgets so the state /// class stays well under 50 lines. +/// - Tag management (add/remove/autocomplete) is extracted to [TagPicker] +/// (Single Responsibility); the screen only wires the client through. class MediaDetailScreen extends ConsumerStatefulWidget { /// The string form of the media ID extracted from the '/media/:id' route. final String mediaId; @@ -288,13 +293,17 @@ class _MediaDetailScreenState extends ConsumerState<MediaDetailScreen> { return const SizedBox.shrink(); } + final client = ref.read(apiClientProvider); return RefreshIndicator( onRefresh: _load, child: _MediaDetailContent( media: _media!, - thumbnailUrl: ref.read(apiClientProvider).thumbnailUrl(_media!.id), + thumbnailUrl: client.thumbnailUrl(_media!.id), onFavoriteToggle: _toggleFavorite, onPlay: _play, + // Pass the client so _MediaDetailContent can hand it to TagPicker; + // this avoids TagPicker needing its own provider read (DIP). + client: client, ), ); } @@ -318,15 +327,16 @@ enum _MenuAction { share } /// Scrollable body of the media detail screen. /// /// Extracted from [_MediaDetailScreenState] so the state class stays concise -/// and this widget is independently testable. All callbacks are injected so -/// this widget has no direct dependency on providers or navigation -/// (Dependency Inversion, Single Responsibility). +/// and this widget is independently testable. All callbacks and the API +/// client are injected so this widget has no direct dependency on providers +/// or navigation (Dependency Inversion, Single Responsibility). class _MediaDetailContent extends StatelessWidget { const _MediaDetailContent({ required this.media, required this.thumbnailUrl, required this.onFavoriteToggle, required this.onPlay, + required this.client, }); final Media media; @@ -340,6 +350,10 @@ class _MediaDetailContent extends StatelessWidget { /// Called when the play button is tapped. final VoidCallback onPlay; + /// API client injected so [TagPicker] can call [addTag] / [removeTag] / + /// [listTags] without reading from a provider directly (DIP). + final PlayerApiClient client; + @override Widget build(BuildContext context) { return SingleChildScrollView( @@ -367,11 +381,16 @@ class _MediaDetailContent extends StatelessWidget { // Codec · resolution · duration · file size. _MetadataRow(media: media), - // Tag chips (hidden when no tags). - if (media.tags.isNotEmpty) ...[ - const SizedBox(height: 12), - _TagChips(tags: media.tags), - ], + // Interactive tag picker: existing tags as deletable chips + // plus an autocomplete input for adding new tags. + // Always shown so users can add tags even when none exist yet. + const SizedBox(height: 12), + TagPicker( + key: const Key('media_detail_tags'), + mediaId: media.id, + tags: media.tags, + client: client, + ), const SizedBox(height: 24), ], @@ -571,39 +590,6 @@ class _MetadataRow extends StatelessWidget { } // --------------------------------------------------------------------------- -// _TagChips -// --------------------------------------------------------------------------- - -/// Horizontally wrapping row of tag chips. -/// -/// Uses [Chip] (non-interactive, display-only) rather than [FilterChip] -/// because the detail screen does not filter — it merely shows what tags -/// are attached to the item. -class _TagChips extends StatelessWidget { - const _TagChips({required this.tags}); - - final List<String> tags; - - @override - Widget build(BuildContext context) { - return Wrap( - key: const Key('media_detail_tags'), - spacing: 8, - runSpacing: 4, - children: [ - for (final tag in tags) - Chip( - label: Text(tag), - labelStyle: Theme.of(context).textTheme.labelSmall, - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - ), - ], - ); - } -} - -// --------------------------------------------------------------------------- // _PlayButton // --------------------------------------------------------------------------- diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart index 2d6f446..ea20412 100644 --- a/player-android/lib/utils/error_mappers.dart +++ b/player-android/lib/utils/error_mappers.dart @@ -158,6 +158,28 @@ String continueWatchingErrorMessage(Object error) { return 'Unexpected error. Please try again.'; } +/// Maps any thrown object from [PlayerApiClient.addTag] or +/// [PlayerApiClient.removeTag] to a UI string. +/// +/// Adds human-readable messages for the common failure modes: +/// - 400: the tag name is invalid (empty, too long, etc.). +/// - 404: the media item no longer exists. +/// +/// Kept as a separate top-level function (Open-Closed, DRY) so it can evolve +/// independently of the other mappers without touching unrelated screens. +String tagErrorMessage(Object error) { + if (error is DioException) { + if (error.response?.statusCode == 404) { + return 'Media not found. It may have been deleted.'; + } + if (error.response?.statusCode == 400) { + return 'Invalid tag name. Please try a different tag.'; + } + return dioConnectionErrorMessage(error); + } + return 'Unexpected error. Please try again.'; +} + /// Maps any thrown object from [PlayerApiClient.subscribePodcast] to a UI string. /// /// Adds human-readable messages for the common failure modes: diff --git a/player-android/lib/widgets/tag_picker.dart b/player-android/lib/widgets/tag_picker.dart new file mode 100644 index 0000000..f863d26 --- /dev/null +++ b/player-android/lib/widgets/tag_picker.dart @@ -0,0 +1,386 @@ +import 'package:flutter/material.dart'; + +import '../api/player_api_client.dart'; +import '../utils/error_mappers.dart'; + +// --------------------------------------------------------------------------- +// TagPicker +// --------------------------------------------------------------------------- + +/// Interactive tag-management widget for a single media item. +/// +/// Displays existing tags as chips with a delete button and provides an +/// autocomplete text field for adding new tags from the global tag list. +/// +/// Design notes: +/// - Pure callback-based interface: [tags], [mediaId], and [client] are +/// injected so this widget is provider-free and independently testable +/// (Dependency Inversion, Single Responsibility). +/// - Optimistic UI: the tag list is updated immediately on user action; +/// the API call is awaited in the background, and the change is reverted +/// (with a SnackBar) if the call fails. +/// - A per-tag loading guard ([_loadingTags]) prevents concurrent operations +/// on the same tag while still allowing independent tags to be acted on. +/// - No `dio` import: errors are mapped by [tagErrorMessage] in +/// `error_mappers.dart` (Dependency Inversion Principle). +/// - [mounted] is checked after every `await` to prevent setState calls on +/// a disposed widget. +class TagPicker extends StatefulWidget { + /// Creates a [TagPicker] for the given [mediaId]. + const TagPicker({ + super.key, + required this.mediaId, + required this.tags, + required this.client, + }); + + /// The ID of the media item whose tags are being managed. + final int mediaId; + + /// The initial list of tag strings attached to this media item. + /// + /// The widget maintains its own internal copy; the caller's list is not + /// mutated and does not need to be updated after add/remove operations. + final List<String> tags; + + /// The API client used to call [listTags], [addTag], and [removeTag]. + /// + /// Injected rather than read from a provider so the widget has no Riverpod + /// dependency and can be tested with a plain fake/stub (DIP, testability). + final PlayerApiClient client; + + @override + State<TagPicker> createState() => _TagPickerState(); +} + +class _TagPickerState extends State<TagPicker> { + // Current list of tags for this media item; updated optimistically. + late List<String> _tags; + + // Set of tag names currently being added or removed (prevents concurrent + // duplicate operations on the same tag while allowing others to proceed). + final Set<String> _loadingTags = {}; + + // All known tag names across the library; populated once during [initState] + // via [_fetchAllTags] to seed the autocomplete dropdown. + List<String> _allTagNames = []; + + // True while the initial [listTags] call is in flight. + bool _tagListLoading = false; + + @override + void initState() { + super.initState(); + // Take a mutable copy of the injected tag list so optimistic updates + // do not mutate the caller's data. + _tags = List<String>.from(widget.tags); + _fetchAllTags(); + } + + // --------------------------------------------------------------------------- + // Data loading + // --------------------------------------------------------------------------- + + /// Fetches the global tag list for autocomplete suggestions. + /// + /// Called once during [initState]. Failures are silently ignored so a + /// network hiccup does not prevent the existing chips from rendering — the + /// user can still delete tags even without autocomplete suggestions. + Future<void> _fetchAllTags() async { + if (!mounted) return; + setState(() => _tagListLoading = true); + try { + final tagObjects = await widget.client.listTags(); + if (!mounted) return; + setState(() { + _allTagNames = tagObjects.map((t) => t.name).toList(); + _tagListLoading = false; + }); + } catch (_) { + // Non-fatal: autocomplete simply shows no suggestions on error. + if (mounted) setState(() => _tagListLoading = false); + } + } + + // --------------------------------------------------------------------------- + // Tag removal + // --------------------------------------------------------------------------- + + /// Optimistically removes [tag] from the UI and calls [removeTag] on the + /// server. Reverts and shows a SnackBar if the API call fails. + Future<void> _removeTag(String tag) async { + // Guard: skip if already being acted on (prevents double-tap race). + if (_loadingTags.contains(tag)) return; + + // Optimistic remove — update the chip list immediately so the UI feels + // responsive without waiting for the network round-trip. + setState(() { + _tags.remove(tag); + _loadingTags.add(tag); + }); + + try { + await widget.client.removeTag(widget.mediaId, tag); + } catch (e) { + if (!mounted) return; + // Revert the optimistic removal and surface the error to the user. + setState(() => _tags.add(tag)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(tagErrorMessage(e))), + ); + } finally { + if (mounted) setState(() => _loadingTags.remove(tag)); + } + } + + // --------------------------------------------------------------------------- + // Tag addition + // --------------------------------------------------------------------------- + + /// Optimistically adds [tag] to the UI and calls [addTag] on the server. + /// Reverts and shows a SnackBar if the API call fails. + /// + /// Duplicate and empty tags are silently ignored to keep the UI consistent + /// with the server's deduplication behaviour. + Future<void> _addTag(String tag) async { + final trimmed = tag.trim(); + // Silently ignore empty input or tags that are already attached. + if (trimmed.isEmpty || _tags.contains(trimmed)) return; + // Guard: skip if this tag name is already being acted on. + if (_loadingTags.contains(trimmed)) return; + + // Optimistic add — append the chip immediately. + setState(() { + _tags.add(trimmed); + _loadingTags.add(trimmed); + }); + + try { + await widget.client.addTag(widget.mediaId, trimmed); + } catch (e) { + if (!mounted) return; + // Revert the optimistic addition and surface the error. + setState(() => _tags.remove(trimmed)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(tagErrorMessage(e))), + ); + } finally { + if (mounted) setState(() => _loadingTags.remove(trimmed)); + } + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Existing tags as deletable chips. + _TagChipRow( + tags: _tags, + loadingTags: _loadingTags, + onDelete: _removeTag, + ), + + const SizedBox(height: 8), + + // Autocomplete input for adding new tags. + _TagAutocomplete( + allTagNames: _allTagNames, + isLoading: _tagListLoading, + currentTags: _tags, + onTagSelected: _addTag, + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// _TagChipRow +// --------------------------------------------------------------------------- + +/// Wrapping row of deletable tag chips. +/// +/// Each chip shows a ✕ delete button that calls [onDelete]. Tags listed in +/// [loadingTags] render as slightly translucent to indicate an in-flight +/// operation (without blocking any other chip's delete button). +class _TagChipRow extends StatelessWidget { + const _TagChipRow({ + required this.tags, + required this.loadingTags, + required this.onDelete, + }); + + final List<String> tags; + + /// Names of tags currently being added or removed. + final Set<String> loadingTags; + + /// Called with the tag name when the user taps the delete (✕) button. + final void Function(String tag) onDelete; + + @override + Widget build(BuildContext context) { + return Wrap( + key: const Key('tag_picker_chips'), + spacing: 8, + runSpacing: 4, + children: [ + for (final tag in tags) + _DeletableTagChip( + tag: tag, + isLoading: loadingTags.contains(tag), + onDelete: () => onDelete(tag), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// _DeletableTagChip +// --------------------------------------------------------------------------- + +/// Single deletable tag chip. +/// +/// Shows [tag] with a trailing ✕ button. The button is disabled while +/// [isLoading] is true to prevent duplicate concurrent operations. +class _DeletableTagChip extends StatelessWidget { + const _DeletableTagChip({ + required this.tag, + required this.isLoading, + required this.onDelete, + }); + + final String tag; + + /// Whether a remove-tag API call is currently in flight for this chip. + final bool isLoading; + + /// Called when the user taps the ✕ button. + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return Opacity( + // Dim the chip while a remove operation is in flight (visual feedback). + opacity: isLoading ? 0.5 : 1.0, + child: Chip( + key: Key('tag_chip_$tag'), + label: Text(tag), + labelStyle: Theme.of(context).textTheme.labelSmall, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + // The delete icon triggers optimistic removal. + deleteIcon: Icon( + Icons.close, + key: Key('tag_chip_delete_$tag'), + size: 14, + ), + onDeleted: isLoading ? null : onDelete, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// _TagAutocomplete +// --------------------------------------------------------------------------- + +/// Text field with autocomplete suggestions for adding new tags. +/// +/// Suggestions come from [allTagNames] minus tags already in [currentTags] so +/// the user only sees tags that can actually be added. Selecting a suggestion +/// or submitting the field calls [onTagSelected] and clears the input. +/// +/// This is a [StatelessWidget] because [Autocomplete] manages its own +/// internal text controller and focus node — no local mutable state is needed. +class _TagAutocomplete extends StatelessWidget { + const _TagAutocomplete({ + required this.allTagNames, + required this.isLoading, + required this.currentTags, + required this.onTagSelected, + }); + + /// The full list of tag names available globally (from listTags). + final List<String> allTagNames; + + /// True while [allTagNames] is still being fetched from the server. + final bool isLoading; + + /// Tags already attached to this item; excluded from autocomplete suggestions. + final List<String> currentTags; + + /// Called with the confirmed tag name when the user selects or submits. + final Future<void> Function(String tag) onTagSelected; + + @override + Widget build(BuildContext context) { + return Autocomplete<String>( + optionsBuilder: (editing) { + // Return an empty iterable when the field is blank to avoid showing + // the full list unprompted (avoids overwhelming the user). + final query = editing.text.trim().toLowerCase(); + if (query.isEmpty) return const Iterable<String>.empty(); + + // Filter suggestions: match by query substring and exclude + // already-applied tags so only addable tags are suggested. + return allTagNames + .where((name) => !currentTags.contains(name)) + .where((name) => name.toLowerCase().contains(query)); + }, + onSelected: (selected) { + // Autocomplete automatically clears the field text after onSelected, + // so we only need to trigger our own add-tag callback. + onTagSelected(selected); + }, + fieldViewBuilder: (context, textController, focusNode, onFieldSubmitted) { + // The Autocomplete widget manages textController and focusNode; we + // read from textController in the suffix button's onPressed so the + // manually typed text is submitted with the same controller. + return TextField( + key: const Key('tag_add_input'), + controller: textController, + focusNode: focusNode, + decoration: InputDecoration( + hintText: isLoading ? 'Loading tags…' : 'Add a tag…', + isDense: true, + suffixIcon: IconButton( + key: const Key('tag_add_button'), + icon: const Icon(Icons.add, size: 18), + tooltip: 'Add tag', + // Submit using the Autocomplete-managed controller text. + onPressed: () { + final text = textController.text.trim(); + if (text.isEmpty) return; + textController.clear(); + onTagSelected(text); + }, + ), + border: const OutlineInputBorder(), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + onSubmitted: (value) { + // onFieldSubmitted closes the Autocomplete overlay (keyboard Enter). + // When the text does NOT match any highlighted suggestion the + // Autocomplete's onSelected fires only for the highlighted item, so + // we additionally submit the raw typed text here so the user can + // enter a brand-new tag name without selecting from the list. + final trimmed = value.trim(); + if (trimmed.isNotEmpty) { + textController.clear(); + onTagSelected(trimmed); + } + onFieldSubmitted(); + }, + ); + }, + ); + } +} diff --git a/player-android/test/screens/media_detail_screen_test.dart b/player-android/test/screens/media_detail_screen_test.dart index be8f373..23dace4 100644 --- a/player-android/test/screens/media_detail_screen_test.dart +++ b/player-android/test/screens/media_detail_screen_test.dart @@ -53,8 +53,10 @@ class _FakeTokenStorage implements TokenStorage { /// Controllable [PlayerApiClient] stub for [MediaDetailScreen] tests. /// -/// Only [getMedia], [toggleFavorite], and [thumbnailUrl] are implemented; -/// all other methods remain [UnimplementedError]. +/// Implements [getMedia], [toggleFavorite], [listTags], [addTag], +/// [removeTag], and [thumbnailUrl]; all other methods remain +/// [UnimplementedError]. Tag methods return empty/no-op defaults so tests +/// that do not exercise tag behaviour do not need to configure them. class _FakeApiClient extends PlayerApiClient { _FakeApiClient() : super(dio: Dio()); @@ -90,6 +92,19 @@ class _FakeApiClient extends PlayerApiClient { return toggleResult!; } + /// Returns an empty list so the autocomplete in [TagPicker] shows no + /// suggestions — keeps existing tests hermetic without extra setup. + @override + Future<List<Tag>> listTags() async => []; + + /// No-op: tag add operations succeed silently in these tests. + @override + Future<void> addTag(int mediaId, String tag) async {} + + /// No-op: tag remove operations succeed silently in these tests. + @override + Future<void> removeTag(int mediaId, String tag) async {} + /// Returns an empty string so [_ThumbnailBanner] shows the static /// placeholder instead of making a network request — keeps tests hermetic. @override @@ -99,6 +114,7 @@ class _FakeApiClient extends PlayerApiClient { /// [PlayerApiClient] stub that delays [getMedia] until [complete] is called. /// /// Used to inspect mid-flight loading state before the response arrives. +/// Tag methods return empty/no-op defaults so tests stay hermetic. class _DelayedFakeApiClient extends PlayerApiClient { _DelayedFakeApiClient() : super(dio: Dio()); @@ -111,6 +127,15 @@ class _DelayedFakeApiClient extends PlayerApiClient { Future<Media> getMedia(int mediaId) => _completer.future; @override + Future<List<Tag>> listTags() async => []; + + @override + Future<void> addTag(int mediaId, String tag) async {} + + @override + Future<void> removeTag(int mediaId, String tag) async {} + + @override String thumbnailUrl(int mediaId) => ''; } @@ -118,7 +143,7 @@ class _DelayedFakeApiClient extends PlayerApiClient { /// [completeToggle] is called. /// /// Allows tests to inspect the UI state between the tap and the API response -/// (the optimistic update window). +/// (the optimistic update window). Tag methods return empty/no-op defaults. class _DelayedToggleFakeApiClient extends PlayerApiClient { _DelayedToggleFakeApiClient({required this.mediaResult}) : super(dio: Dio()); @@ -141,6 +166,15 @@ class _DelayedToggleFakeApiClient extends PlayerApiClient { Future<bool> toggleFavorite(int mediaId) => _toggleCompleter.future; @override + Future<List<Tag>> listTags() async => []; + + @override + Future<void> addTag(int mediaId, String tag) async {} + + @override + Future<void> removeTag(int mediaId, String tag) async {} + + @override String thumbnailUrl(int mediaId) => ''; } @@ -325,13 +359,17 @@ void main() { expect(find.text('english'), findsOneWidget); }); - testWidgets('hides tag chips row when tags list is empty', (tester) async { + testWidgets('shows tag picker (no chips) when tags list is empty', + (tester) async { final fakeClient = _FakeApiClient()..mediaResult = _kAudio; await _pumpScreen(tester, fakeClient, mediaId: '7'); await tester.pumpAndSettle(); - expect(find.byKey(const Key('media_detail_tags')), findsNothing); + // TagPicker is always rendered so users can add tags to untagged items. + expect(find.byKey(const Key('media_detail_tags')), findsOneWidget); + // The chip row is empty — no individual tag chips should be present. + expect(find.byKey(const Key('tag_picker_chips')), findsOneWidget); }); testWidgets('renders play button', (tester) async { diff --git a/player-android/test/widgets/tag_picker_test.dart b/player-android/test/widgets/tag_picker_test.dart new file mode 100644 index 0000000..ffc7f71 --- /dev/null +++ b/player-android/test/widgets/tag_picker_test.dart @@ -0,0 +1,430 @@ +// Widget tests for TagPicker (widgets/tag_picker.dart). +// +// Tests cover: +// 1. Displays existing tags as deletable chips. +// 2. Hides chips when the tags list is empty (chip row is empty). +// 3. Deleting a tag removes it from the UI immediately (optimistic). +// 4. Delete reverts and shows SnackBar when removeTag fails. +// 5. Adding a tag via the add button adds it to the UI immediately. +// 6. Adding via autocomplete suggestion adds the tag immediately. +// 7. Revert on addTag failure: tag is removed and SnackBar shown. +// 8. Duplicate tag is silently ignored (not added twice). +// 9. Empty string submission is silently ignored. +// 10. Loading guard: tapping delete twice on the same chip does not call +// removeTag twice. +// +// The [TagPicker] is tested in isolation inside a plain [MaterialApp] with +// no Riverpod dependency — it receives a [PlayerApiClient] directly so a +// simple controllable fake is sufficient. +// +// Run with: flutter test test/widgets/tag_picker_test.dart + +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:player_android/api/player_api_client.dart'; +import 'package:player_android/models/models.dart'; +import 'package:player_android/models/tag.dart'; +import 'package:player_android/widgets/tag_picker.dart'; + +// --------------------------------------------------------------------------- +// Fake API client +// --------------------------------------------------------------------------- + +/// Controllable [PlayerApiClient] stub for [TagPicker] tests. +/// +/// All tag methods have configurable return values and error injection; other +/// methods remain [UnimplementedError] since [TagPicker] does not call them. +class _FakeTagClient extends PlayerApiClient { + _FakeTagClient() : super(dio: Dio()); + + // ---- listTags ---- + + /// Tags returned by [listTags]. Defaults to empty. + List<Tag> listTagsResult = []; + + /// When non-null, [listTags] throws this. + Object? listTagsError; + + @override + Future<List<Tag>> listTags() async { + if (listTagsError != null) throw listTagsError!; + return listTagsResult; + } + + // ---- addTag ---- + + /// When non-null, [addTag] throws this. + Object? addTagError; + + /// Number of times [addTag] was called. + int addTagCallCount = 0; + + /// The last tag name passed to [addTag]. + String? lastAddedTag; + + @override + Future<void> addTag(int mediaId, String tag) async { + addTagCallCount++; + lastAddedTag = tag; + if (addTagError != null) throw addTagError!; + } + + // ---- removeTag ---- + + /// When non-null, [removeTag] throws this. + Object? removeTagError; + + /// Number of times [removeTag] was called. + int removeTagCallCount = 0; + + /// The last tag name passed to [removeTag]. + String? lastRemovedTag; + + @override + Future<void> removeTag(int mediaId, String tag) async { + removeTagCallCount++; + lastRemovedTag = tag; + if (removeTagError != null) throw removeTagError!; + } + + @override + String thumbnailUrl(int mediaId) => ''; +} + +/// [PlayerApiClient] stub where [addTag] is delayed until [completeAdd] is +/// called. Used to verify the optimistic-add window and the loading guard. +class _DelayedTagClient extends PlayerApiClient { + _DelayedTagClient({List<Tag>? tags}) : super(dio: Dio()) { + _listTagsResult = tags ?? []; + } + + late List<Tag> _listTagsResult; + final _addCompleter = Completer<void>(); + final _removeCompleter = Completer<void>(); + + int addTagCallCount = 0; + int removeTagCallCount = 0; + + void completeAdd() => _addCompleter.complete(); + void failAdd(Object error) => _addCompleter.completeError(error); + + void completeRemove() => _removeCompleter.complete(); + void failRemove(Object error) => _removeCompleter.completeError(error); + + @override + Future<List<Tag>> listTags() async => _listTagsResult; + + @override + Future<void> addTag(int mediaId, String tag) { + addTagCallCount++; + return _addCompleter.future; + } + + @override + Future<void> removeTag(int mediaId, String tag) { + removeTagCallCount++; + return _removeCompleter.future; + } + + @override + String thumbnailUrl(int mediaId) => ''; +} + +// --------------------------------------------------------------------------- +// Helper: pump TagPicker in isolation +// --------------------------------------------------------------------------- + +/// Pumps a [TagPicker] inside a minimal [MaterialApp] + [Scaffold]. +/// +/// [tags] seeds the initial tag list. [mediaId] is 1 by default. +Future<void> _pumpPicker( + WidgetTester tester, { + required PlayerApiClient client, + List<String> tags = const [], + int mediaId = 1, + Key? pickerKey, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TagPicker( + key: pickerKey ?? const Key('picker'), + mediaId: mediaId, + tags: tags, + client: client, + ), + ), + ), + ), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // -------------------------------------------------------------------------- + // Display + // -------------------------------------------------------------------------- + + group('display', () { + testWidgets('renders a chip for each initial tag', (tester) async { + final client = _FakeTagClient(); + await _pumpPicker(tester, client: client, tags: ['action', 'english']); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('tag_chip_action')), findsOneWidget); + expect(find.byKey(const Key('tag_chip_english')), findsOneWidget); + expect(find.text('action'), findsOneWidget); + expect(find.text('english'), findsOneWidget); + }); + + testWidgets('chip row is empty when no tags are supplied', (tester) async { + final client = _FakeTagClient(); + await _pumpPicker(tester, client: client, tags: []); + await tester.pumpAndSettle(); + + // Chip row is always rendered, but contains no chip widgets. + expect(find.byKey(const Key('tag_picker_chips')), findsOneWidget); + expect(find.byType(Chip), findsNothing); + }); + + testWidgets('shows the add-tag text field', (tester) async { + final client = _FakeTagClient(); + await _pumpPicker(tester, client: client, tags: []); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('tag_add_input')), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Delete (optimistic remov |
