The multi-select action bar (Files/Photos/Favorites) hid bulk actions behind a horizontally-scrolling row with no visible cue that there was more to find, once there were more than a handful (up to 9: favorite, share, download, delete, copy, move, rename, sync, details). Designed the replacement on the linked design canvas first (Selection action bar boards), then implemented it: NooSelectionBar now shows a fixed number of actions inline (3 on mobile, 4 on desktop) with the rest behind a trailing "More" button that opens the same overflow sheet a file row's own "..." menu already uses. Removed the now-dead scroll-hint plumbing from the three screens that render it.
309 lines
10 KiB
Dart
309 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
import '../../../models/selection_action.dart';
|
|
import '../../../theme/design_tokens.dart';
|
|
import '../files/noo_file_row.dart' show NooOverflowButton;
|
|
import '../overlays/noo_sheet.dart';
|
|
import 'noo_grouped_list.dart';
|
|
import 'noo_settings_row.dart';
|
|
|
|
/// Bulk actions shown inline before the rest collapse behind "More" - a
|
|
/// fixed, non-scrolling width on every platform (design canvas
|
|
/// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4, "Selection action
|
|
/// bar"). Mobile is tighter (icons share the bar with the close circle and
|
|
/// count on a ~360dp phone); desktop's labelled pills have a wider toolbar
|
|
/// to work with.
|
|
const int _kMobileInlineActions = 3;
|
|
const int _kDesktopInlineActions = 4;
|
|
|
|
/// The multi-select action bar Files/Photos/Favorites swap in for their own
|
|
/// sort/filter row while selecting - design canvas
|
|
/// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4 ("Selection action
|
|
/// bar"), since DESIGN_SYSTEM.md has no §4 recipe of its own for this.
|
|
/// Replaces the bare row of Material `IconButton`s each screen used to
|
|
/// build ad hoc.
|
|
///
|
|
/// A `surface` card (radius 20) - selection reads as a distinct mode, not
|
|
/// just a row of buttons floating on `bg` - with a close circle, the
|
|
/// count, and the bulk actions. This sits in the *content* pane's own
|
|
/// pinned header slot on every platform (the shell's desktop toolbar,
|
|
/// built separately in `main.dart`, never changes for selection), so
|
|
/// mobile and desktop share the same fixed 56px height; only the gutter
|
|
/// ([isDesktop] ? 24 : 12, matching `NooLayout.gutter`) and the actions'
|
|
/// presentation differ:
|
|
/// - Mobile: the first [_kMobileInlineActions] actions as plain 20px
|
|
/// accent-text icons, no fill.
|
|
/// - Desktop: the first [_kDesktopInlineActions] as labelled tonal pills -
|
|
/// danger-soft/danger for the one labelled "Delete", accent-soft/
|
|
/// accent-text otherwise - since desktop has the room for labels.
|
|
///
|
|
/// Any actions beyond that inline count sit behind a trailing "More"
|
|
/// button that opens the same grouped-list sheet a file row's own overflow
|
|
/// menu already uses (see `FilesView._showItemActions`) - never a second
|
|
/// scrolling row, which is what this replaced: with up to 9 bulk actions
|
|
/// (favorite, share, download, delete, copy, move, rename, sync,
|
|
/// details), a horizontally-scrolling row hid actions behind a swipe
|
|
/// gesture with no visible cue that there was more to find.
|
|
class NooSelectionBar extends StatelessWidget {
|
|
final int count;
|
|
final List<SelectionAction> actions;
|
|
final VoidCallback onClose;
|
|
final bool isDesktop;
|
|
final bool iosStyle;
|
|
|
|
const NooSelectionBar({
|
|
super.key,
|
|
required this.count,
|
|
required this.actions,
|
|
required this.onClose,
|
|
required this.isDesktop,
|
|
this.iosStyle = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = context.nooColors;
|
|
final gutter = isDesktop ? NooSpace.xl : NooSpace.sm;
|
|
final inlineCount = isDesktop
|
|
? _kDesktopInlineActions
|
|
: _kMobileInlineActions;
|
|
final inlineActions = actions.length > inlineCount
|
|
? actions.sublist(0, inlineCount)
|
|
: actions;
|
|
final overflowActions = actions.length > inlineCount
|
|
? actions.sublist(inlineCount)
|
|
: const <SelectionAction>[];
|
|
|
|
// Every screen pins this into a fixed 56px sliver-header slot (the same
|
|
// one the sort/filter row otherwise occupies) - the 4px top/bottom
|
|
// margin plus the card's own 48px must add up to exactly that, or the
|
|
// header overflows (see files_view.dart's `topRow` height comment for
|
|
// the same warning about this exact number).
|
|
return Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: gutter, vertical: NooSpace.xxs),
|
|
child: Container(
|
|
height: 48,
|
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
|
decoration: BoxDecoration(
|
|
color: colors.surface,
|
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Semantics(
|
|
button: true,
|
|
label: 'Cancel selection',
|
|
child: GestureDetector(
|
|
onTap: onClose,
|
|
child: Container(
|
|
width: 36,
|
|
height: 36,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: colors.surface2,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(LucideIcons.x, size: 18, color: colors.fg1),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: NooSpace.xs),
|
|
Text(
|
|
'$count selected',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: NooText.cardTitle.copyWith(
|
|
fontSize: 17,
|
|
color: colors.fg1,
|
|
),
|
|
),
|
|
const SizedBox(width: NooSpace.xs),
|
|
Expanded(
|
|
child: Align(
|
|
alignment: Alignment.centerRight,
|
|
child: isDesktop
|
|
? _desktopActions(
|
|
context,
|
|
colors,
|
|
inlineActions,
|
|
overflowActions,
|
|
)
|
|
: _mobileActions(
|
|
context,
|
|
colors,
|
|
inlineActions,
|
|
overflowActions,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _mobileActions(
|
|
BuildContext context,
|
|
NooColors colors,
|
|
List<SelectionAction> inline,
|
|
List<SelectionAction> overflow,
|
|
) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
for (final action in inline)
|
|
Semantics(
|
|
button: true,
|
|
label: action.label,
|
|
child: InkResponse(
|
|
onTap: action.onTap,
|
|
radius: 20,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(10),
|
|
child: Icon(action.icon, size: 20, color: colors.accentText),
|
|
),
|
|
),
|
|
),
|
|
if (overflow.isNotEmpty)
|
|
NooOverflowButton(
|
|
iosStyle: iosStyle,
|
|
onTap: () => _showOverflowSheet(context, overflow),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _desktopActions(
|
|
BuildContext context,
|
|
NooColors colors,
|
|
List<SelectionAction> inline,
|
|
List<SelectionAction> overflow,
|
|
) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
for (final action in inline) ...[
|
|
_DesktopActionPill(action: action, colors: colors),
|
|
const SizedBox(width: 6),
|
|
],
|
|
if (overflow.isNotEmpty)
|
|
_DesktopMorePill(
|
|
colors: colors,
|
|
onTap: () => _showOverflowSheet(context, overflow),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _showOverflowSheet(
|
|
BuildContext context,
|
|
List<SelectionAction> overflow,
|
|
) {
|
|
showNooSheet(
|
|
context,
|
|
children: [
|
|
NooGroupedList(
|
|
children: [
|
|
for (final action in overflow)
|
|
NooSettingsRow(
|
|
icon: action.icon,
|
|
label: Text(action.label),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
action.onTap();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DesktopActionPill extends StatelessWidget {
|
|
final SelectionAction action;
|
|
final NooColors colors;
|
|
|
|
const _DesktopActionPill({required this.action, required this.colors});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final danger = action.label == 'Delete';
|
|
final bg = danger ? colors.dangerSoft : colors.accentSoft;
|
|
final fg = danger ? colors.danger : colors.accentText;
|
|
return Material(
|
|
color: bg,
|
|
borderRadius: BorderRadius.circular(NooRadii.pill),
|
|
child: InkWell(
|
|
onTap: action.onTap,
|
|
borderRadius: BorderRadius.circular(NooRadii.pill),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: SizedBox(
|
|
height: 32,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(action.icon, size: 15, color: fg),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
action.label,
|
|
style: NooText.buttonSm.copyWith(color: fg, fontSize: 13),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The desktop overflow trigger - a secondary pill (matching the Button
|
|
/// component's "Secondary" variant) rather than a tonal one, so it reads
|
|
/// as "opens a menu" and not as another bulk action next to it. The
|
|
/// trailing `chevron-down` mirrors the same "opens a menu" cue `NooChip`
|
|
/// uses.
|
|
class _DesktopMorePill extends StatelessWidget {
|
|
final NooColors colors;
|
|
final VoidCallback onTap;
|
|
|
|
const _DesktopMorePill({required this.colors, required this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Semantics(
|
|
button: true,
|
|
label: 'More actions',
|
|
child: Material(
|
|
color: colors.surface2,
|
|
borderRadius: BorderRadius.circular(NooRadii.pill),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(NooRadii.pill),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: SizedBox(
|
|
height: 32,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'More',
|
|
style: NooText.buttonSm.copyWith(
|
|
color: colors.fg1,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Icon(LucideIcons.chevronDown, size: 14, color: colors.fg1),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|