Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions app/lib/src/screens/dance_list_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import '../theme/keyboard_dismiss.dart';
import '../utils/confirm_delete.dart';
import '../widgets/add_to_program_sheet.dart';
import '../widgets/advanced_query_builder.dart';
import '../widgets/batch_level_dialog.dart';
import '../widgets/batch_tag_dialog.dart';
import '../widgets/brand_mark.dart';
import '../widgets/by_phrase_panel.dart';
Expand Down Expand Up @@ -822,6 +823,95 @@ class _DanceListScreenState extends State<DanceListScreen> {
if (mounted) await _boot();
}

/// Sets the difficulty level on the selected dances. Opens the level picker,
/// persists the choice via the batched, single-transaction
/// [DanceRepository.setLevelForMany], announces the result to AT, and offers
/// Undo. Unlike batch tagging (additive), setting a level replaces it, so the
/// picker is single-choice and includes an explicit "clear" option.
Future<void> _batchSetLevel() async {
final data = _data;
if (data == null || _selectedIds.isEmpty) return;

final selectedIds = Set<String>.of(_selectedIds);
final choice = await showBatchLevelDialog(context);
if (choice == null || !mounted) return;

// Capture prior levels so Undo can restore each dance individually (the
// batch write collapses them to a single target level).
final priorLevels = <String, DanceLevel?>{};
for (final id in selectedIds) {
final dance = await _repos.dances.getById(id);
if (dance == null) continue;
priorLevels[id] = dance.level;
}

final count = await _repos.dances.setLevelForMany(
priorLevels.keys,
level: choice.level,
clearLevel: choice.clear,
now: DateTime.now().toUtc(),
);

// Narrow the captured priors to only the dances that actually changed, so
// Undo doesn't rewrite (and re-stamp) untouched dances.
final target = choice.clear ? null : choice.level;
priorLevels.removeWhere((_, prior) => prior == target);

if (!mounted) return;
final label = choice.clear ? 'Cleared level on' : 'Set level on';
final message = count == 0
? 'No changes'
: '$label $count ${count == 1 ? 'dance' : 'dances'}';
SemanticsService.sendAnnouncement(
View.of(context),
message,
Directionality.of(context),
);

_exitSelectionMode();
await _boot();
if (!mounted) return;

final messenger = ScaffoldMessenger.of(context);
messenger.clearSnackBars();
if (count == 0) {
messenger.showSnackBar(
SnackBar(
key: const ValueKey('batch-level-snackbar'),
content: Text(message),
),
);
return;
}
messenger.showSnackBar(
SnackBar(
key: const ValueKey('batch-level-snackbar'),
content: Text(message),
action: SnackBarAction(
label: 'Undo',
onPressed: () => _undoBatchLevel(priorLevels),
),
),
);
}

/// Restores the captured [priorLevels] for each affected dance (app-side undo;
/// the repository has no batch-undo primitive).
Future<void> _undoBatchLevel(Map<String, DanceLevel?> priorLevels) async {
for (final entry in priorLevels.entries) {
final dance = await _repos.dances.getById(entry.key);
if (dance == null) continue;
await _repos.dances.update(
dance.copyWith(
level: entry.value,
clearLevel: entry.value == null,
updatedAt: DateTime.now().toUtc(),
),
);
}
if (mounted) await _boot();
}

@override
Widget build(BuildContext context) {
return Scaffold(
Expand Down Expand Up @@ -946,6 +1036,12 @@ class _DanceListScreenState extends State<DanceListScreen> {
icon: const Icon(Icons.label_off_outlined),
onPressed: hasSelection ? () => _batchTag(BatchTagMode.remove) : null,
),
IconButton(
key: const ValueKey('batch-set-level'),
tooltip: 'Set level',
icon: const Icon(Icons.signal_cellular_alt),
onPressed: hasSelection ? _batchSetLevel : null,
),
],
);
}
Expand Down
113 changes: 113 additions & 0 deletions app/lib/src/widgets/batch_level_dialog.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import 'package:compendium_core/compendium_core.dart';
import 'package:flutter/material.dart';

import '../search/facet_labels.dart';

/// The level chosen in the batch-level dialog: either a concrete [DanceLevel]
/// to set across the selection, or "clear" ([level] `null`, [clear] `true`) to
/// unset the level. Cancelling the dialog returns `null` instead of this.
class BatchLevelChoice {
const BatchLevelChoice({this.level, this.clear = false});

/// The level to set; `null` together with [clear] means "unset".
final DanceLevel? level;

/// Whether the user picked the explicit "Unspecified (clear)" option.
final bool clear;
}

/// Shows the batch **set level** picker for the Collection multi-select flow
/// (parallel to [showBatchTagDialog]). Unlike batch tagging, setting a level is
/// a *replace*, so the picker is a single-choice radio list — explicit and
/// reversible via the caller's Undo. Includes an explicit "Unspecified (clear)"
/// option so the selection's level can be removed.
///
/// Returns the [BatchLevelChoice], or `null` if the user cancelled. Choices use
/// [RadioListTile] so state is exposed to assistive tech (radio role + selected
/// state) paired with a text label — never color alone.
Future<BatchLevelChoice?> showBatchLevelDialog(BuildContext context) {
return showDialog<BatchLevelChoice>(
context: context,
builder: (_) => const _BatchLevelDialog(),
);
}

class _BatchLevelDialog extends StatefulWidget {
const _BatchLevelDialog();

@override
State<_BatchLevelDialog> createState() => _BatchLevelDialogState();
}

/// Sentinel for the "clear level" radio option, distinct from a concrete
/// [DanceLevel] and from "nothing selected yet".
enum _LevelSelection { unspecified }

class _BatchLevelDialogState extends State<_BatchLevelDialog> {
// Holds either a `DanceLevel` or `_LevelSelection.unspecified`; `null` until
// the user picks something (keeps the confirm button disabled).
Object? _selected;

void _select(Object value) => setState(() => _selected = value);

@override
Widget build(BuildContext context) {
return AlertDialog(
key: const ValueKey('batch-level-dialog'),
title: const Text('Set level'),
content: SizedBox(
width: 360,
child: RadioGroup<Object>(
groupValue: _selected,
onChanged: (value) {
if (value != null) _select(value);
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final v in DanceLevel.values)
RadioListTile<Object>(
key: ValueKey('batch-level-option-${v.name}'),
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
value: v,
title: Text(danceLevelLabel(v)),
),
RadioListTile<Object>(
key: const ValueKey('batch-level-option-unspecified'),
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
value: _LevelSelection.unspecified,
title: const Text('Unspecified (clear)'),
),
],
),
),
),
actions: [
TextButton(
key: const ValueKey('batch-level-cancel'),
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
key: const ValueKey('batch-level-confirm'),
onPressed: _selected == null
? null
: () {
final selected = _selected;
Navigator.of(context).pop(
selected is DanceLevel
? BatchLevelChoice(level: selected)
: const BatchLevelChoice(clear: true),
);
},
child: const Text('Set'),
),
],
);
}
}
Loading