summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-21 18:47:51 +0300
committerPaul Buetow <paul@buetow.org>2026-05-21 18:47:51 +0300
commitd9887828685db241e01436e99885056eb61f06c1 (patch)
tree445772f1f901ada768ed7d9c18312d7de02a1b33
parente58a12ee773da39d639ebb0e26fdb1b782941c56 (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.dart41
-rw-r--r--player-android/lib/screens/media_detail_screen.dart72
-rw-r--r--player-android/lib/utils/error_mappers.dart22
-rw-r--r--player-android/lib/widgets/tag_picker.dart386
-rw-r--r--player-android/test/screens/media_detail_screen_test.dart48
-rw-r--r--player-android/test/widgets/tag_picker_test.dart430
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