diff --git a/flutter_client/lib/shared/widgets/track_actions/hide_track_sheet.dart b/flutter_client/lib/shared/widgets/track_actions/hide_track_sheet.dart new file mode 100644 index 00000000..2c3ee4d0 --- /dev/null +++ b/flutter_client/lib/shared/widgets/track_actions/hide_track_sheet.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; + +import '../../../theme/theme_extension.dart'; + +/// Modal bottom sheet for picking a hide reason + optional notes. +/// Returns ({reason, notes}) on submit, null on cancel. +class HideTrackSheet extends StatefulWidget { + const HideTrackSheet({super.key}); + + static Future<({String reason, String notes})?> show(BuildContext context) { + return showModalBottomSheet<({String reason, String notes})>( + context: context, + isScrollControlled: true, + builder: (_) => const HideTrackSheet(), + ); + } + + @override + State createState() => _HideTrackSheetState(); +} + +class _HideTrackSheetState extends State { + String _reason = 'bad_rip'; + final _notesCtrl = TextEditingController(); + + // Wire values mirror the server vocabulary; display labels mirror the + // existing _QuarantineTile in library_screen.dart. + static const _options = [ + ('bad_rip', 'Bad rip'), + ('wrong_file', 'Wrong file'), + ('wrong_tags', 'Wrong tags'), + ('duplicate', 'Duplicate'), + ('other', 'Other'), + ]; + + @override + void dispose() { + _notesCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return SafeArea( + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + color: fs.iron, + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hide this track', + style: TextStyle( + color: fs.parchment, + fontFamily: 'Fraunces', + fontSize: 20, + ), + ), + const SizedBox(height: 12), + Text( + 'Pick a reason. Optional notes are visible to admins.', + style: TextStyle(color: fs.ash), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final (value, label) in _options) + ChoiceChip( + key: Key('hide_reason_$value'), + label: Text(label), + selected: _reason == value, + onSelected: (_) => setState(() => _reason = value), + ), + ], + ), + const SizedBox(height: 12), + TextField( + key: const Key('hide_notes_input'), + controller: _notesCtrl, + decoration: const InputDecoration( + labelText: 'Notes (optional)', + ), + maxLines: 2, + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + ElevatedButton( + key: const Key('hide_confirm'), + style: ElevatedButton.styleFrom( + backgroundColor: fs.oxblood, + foregroundColor: fs.parchment, + ), + onPressed: () => Navigator.pop( + context, + (reason: _reason, notes: _notesCtrl.text.trim()), + ), + child: const Text('Hide'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter_client/test/shared/widgets/track_actions/hide_track_sheet_test.dart b/flutter_client/test/shared/widgets/track_actions/hide_track_sheet_test.dart new file mode 100644 index 00000000..4cca8654 --- /dev/null +++ b/flutter_client/test/shared/widgets/track_actions/hide_track_sheet_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/shared/widgets/track_actions/hide_track_sheet.dart'; +import 'package:minstrel/theme/theme_data.dart'; + +void main() { + Future<({String reason, String notes})?> openAndPick( + WidgetTester t, { + required String chipKey, + String notes = '', + }) async { + Future<({String reason, String notes})?>? future; + await t.pumpWidget(MaterialApp( + theme: buildThemeData(), + home: Builder(builder: (ctx) { + return Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () { + future = HideTrackSheet.show(ctx); + }, + child: const Text('open'), + ), + ), + ); + }), + )); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + if (chipKey != 'hide_reason_bad_rip') { + await t.tap(find.byKey(Key(chipKey))); + await t.pumpAndSettle(); + } + if (notes.isNotEmpty) { + await t.enterText(find.byKey(const Key('hide_notes_input')), notes); + } + await t.tap(find.byKey(const Key('hide_confirm'))); + await t.pumpAndSettle(); + return future; + } + + testWidgets('default reason is bad_rip', (t) async { + final result = await openAndPick(t, chipKey: 'hide_reason_bad_rip'); + expect(result, isNotNull); + expect(result!.reason, 'bad_rip'); + expect(result.notes, ''); + }); + + testWidgets('selecting wrong_tags returns wrong_tags', (t) async { + final result = await openAndPick(t, chipKey: 'hide_reason_wrong_tags'); + expect(result?.reason, 'wrong_tags'); + }); + + testWidgets('notes get trimmed and returned', (t) async { + final result = await openAndPick( + t, + chipKey: 'hide_reason_other', + notes: ' cracks at 2:13 ', + ); + expect(result?.reason, 'other'); + expect(result?.notes, 'cracks at 2:13'); + }); + + testWidgets('cancel returns null', (t) async { + Future<({String reason, String notes})?>? future; + await t.pumpWidget(MaterialApp( + theme: buildThemeData(), + home: Builder(builder: (ctx) { + return Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () { future = HideTrackSheet.show(ctx); }, + child: const Text('open'), + ), + ), + ); + }), + )); + await t.tap(find.text('open')); + await t.pumpAndSettle(); + await t.tap(find.text('Cancel')); + await t.pumpAndSettle(); + expect(await future, isNull); + }); +}