feat(flutter/track-actions): HideTrackSheet for reason + notes pick

Modal bottom sheet with five reason chips (mirroring server vocabulary
bad_rip / wrong_file / wrong_tags / duplicate / other) plus an optional
notes field. Returns (reason, notes) on Hide, null on Cancel. Default
selection is bad_rip.

Used by TrackActionsSheet's Hide action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 13:41:48 -04:00
parent 5f239f05a5
commit 2944288050
2 changed files with 208 additions and 0 deletions
@@ -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<HideTrackSheet> createState() => _HideTrackSheetState();
}
class _HideTrackSheetState extends State<HideTrackSheet> {
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<FabledSwordTheme>()!;
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'),
),
],
),
],
),
),
),
);
}
}
@@ -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);
});
}