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:
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user