mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
Preserve romanization and translation metadata through Rust exports and cache, match rounded line times, and retain word endings. Highlight romanization with the main lyric clock in Material and Mornye. Clear selection overlay ownership safely when collection screens are disposed. Add parser, rendering, cache and overlay lifecycle regression coverage.
371 lines
12 KiB
Dart
371 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/scheduler.dart';
|
|
import 'package:spotiflac_android/theme/app_tokens.dart';
|
|
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
|
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
|
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
|
|
/// Hosts selection bars above the shell navigation while keeping modal routes
|
|
/// above the bar. A raw entry in the root [Overlay] stays above routes pushed
|
|
/// later, which made download pickers appear behind the selection toolbar.
|
|
class SelectionOverlayHost extends StatefulWidget {
|
|
const SelectionOverlayHost({super.key, required this.child});
|
|
|
|
final Widget child;
|
|
|
|
static _SelectionOverlayHostState? _maybeOf(BuildContext context) =>
|
|
context.findAncestorStateOfType<_SelectionOverlayHostState>();
|
|
|
|
@override
|
|
State<SelectionOverlayHost> createState() => _SelectionOverlayHostState();
|
|
}
|
|
|
|
class _SelectionOverlayHostState extends State<SelectionOverlayHost> {
|
|
SelectionOverlayController? _owner;
|
|
WidgetBuilder? _builder;
|
|
|
|
bool owns(SelectionOverlayController owner) =>
|
|
_owner == owner && _builder != null;
|
|
|
|
void show(SelectionOverlayController owner, WidgetBuilder builder) {
|
|
_update(() {
|
|
_owner = owner;
|
|
_builder = builder;
|
|
});
|
|
}
|
|
|
|
void hide(SelectionOverlayController owner) {
|
|
if (_owner != owner) return;
|
|
_update(() {
|
|
_owner = null;
|
|
_builder = null;
|
|
});
|
|
}
|
|
|
|
/// A screen can remove its bar from dispose while Flutter has locked the
|
|
/// tree. Clear ownership immediately, then rebuild after that frame so the
|
|
/// bar cannot outlive the screen or retain its disposed callbacks.
|
|
void _update(VoidCallback change) {
|
|
if (!mounted) return;
|
|
if (SchedulerBinding.instance.schedulerPhase !=
|
|
SchedulerPhase.persistentCallbacks) {
|
|
setState(change);
|
|
return;
|
|
}
|
|
change();
|
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) setState(() {});
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final owner = _owner;
|
|
final builder = _builder;
|
|
// A root modal makes the shell route non-current. Removing the toolbar
|
|
// while that route is covered both prevents visual overlap and avoids the
|
|
// hidden toolbar intercepting taps during the modal transition. The stored
|
|
// builder is kept so the selection returns if the modal is dismissed.
|
|
final routeIsCurrent = ModalRoute.of(context)?.isCurrent ?? true;
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
widget.child,
|
|
if (routeIsCurrent && owner != null && builder != null)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
child: KeyedSubtree(
|
|
key: ObjectKey(owner),
|
|
child: AnimatedSelectionBottomBar(
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: Builder(builder: builder),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Mounts a selection bar in [SelectionOverlayHost]. Screens rendered outside
|
|
/// the main shell (including isolated widget tests) retain a root-overlay
|
|
/// fallback.
|
|
class SelectionOverlayController {
|
|
OverlayEntry? _entry;
|
|
_SelectionOverlayHostState? _host;
|
|
WidgetBuilder? _builder;
|
|
|
|
bool get isVisible => _entry != null || (_host?.owns(this) ?? false);
|
|
|
|
/// Shows the bar, or rebuilds it in place when already visible so the
|
|
/// entrance animation does not replay on every selection change.
|
|
void show(BuildContext context, WidgetBuilder builder) {
|
|
if (!TickerMode.valuesOf(context).enabled) {
|
|
hide();
|
|
return;
|
|
}
|
|
|
|
_builder = builder;
|
|
final host = SelectionOverlayHost._maybeOf(context);
|
|
if (host != null) {
|
|
_entry?.remove();
|
|
_entry = null;
|
|
if (_host != null && _host != host) {
|
|
_host!.hide(this);
|
|
}
|
|
_host = host;
|
|
host.show(this, builder);
|
|
return;
|
|
}
|
|
|
|
_host?.hide(this);
|
|
_host = null;
|
|
if (_entry != null) {
|
|
_entry!.markNeedsBuild();
|
|
return;
|
|
}
|
|
_entry = OverlayEntry(
|
|
builder: (overlayContext) => Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
child: AnimatedSelectionBottomBar(
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: _builder!(overlayContext),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
Overlay.of(context, rootOverlay: true).insert(_entry!);
|
|
}
|
|
|
|
void hide() {
|
|
_host?.hide(this);
|
|
_host = null;
|
|
_entry?.remove();
|
|
_entry = null;
|
|
_builder = null;
|
|
}
|
|
|
|
/// Call from the host `State.dispose`; an entry left in the root overlay
|
|
/// outlives the screen that created it.
|
|
void dispose() => hide();
|
|
}
|
|
|
|
/// Entrance animation shared by selection bars mounted in the root overlay.
|
|
class AnimatedSelectionBottomBar extends StatefulWidget {
|
|
const AnimatedSelectionBottomBar({super.key, required this.child});
|
|
|
|
final Widget child;
|
|
|
|
@override
|
|
State<AnimatedSelectionBottomBar> createState() =>
|
|
_AnimatedSelectionBottomBarState();
|
|
}
|
|
|
|
class _AnimatedSelectionBottomBarState extends State<AnimatedSelectionBottomBar>
|
|
with SingleTickerProviderStateMixin {
|
|
late final AnimationController _controller;
|
|
late final Animation<Offset> _slideAnimation;
|
|
late final Animation<double> _fadeAnimation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 240),
|
|
);
|
|
final curve = CurvedAnimation(
|
|
parent: _controller,
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
_slideAnimation = Tween<Offset>(
|
|
begin: const Offset(0, 0.08),
|
|
end: Offset.zero,
|
|
).animate(curve);
|
|
_fadeAnimation = Tween<double>(begin: 0, end: 1).animate(curve);
|
|
_controller.forward();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return FadeTransition(
|
|
opacity: _fadeAnimation,
|
|
child: SlideTransition(position: _slideAnimation, child: widget.child),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Shared chrome for the multi-select bottom bar: rounded surface, drag
|
|
/// handle, close button, "N selected" header and select-all toggle.
|
|
/// The screen-specific action buttons go in [children].
|
|
class SelectionBottomBar extends StatelessWidget {
|
|
const SelectionBottomBar({
|
|
super.key,
|
|
required this.selectedCount,
|
|
required this.allSelected,
|
|
required this.onClose,
|
|
required this.onToggleSelectAll,
|
|
required this.bottomPadding,
|
|
required this.children,
|
|
this.allSelectedLabel,
|
|
this.tapToSelectLabel,
|
|
});
|
|
|
|
final int selectedCount;
|
|
final bool allSelected;
|
|
final VoidCallback onClose;
|
|
final VoidCallback onToggleSelectAll;
|
|
final double bottomPadding;
|
|
final List<Widget> children;
|
|
|
|
/// Overrides the default "All tracks selected" subtitle.
|
|
final String? allSelectedLabel;
|
|
|
|
/// Overrides the default "Tap tracks to select" subtitle.
|
|
final String? tapToSelectLabel;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tokens = context.tokens;
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
final content = SafeArea(
|
|
top: false,
|
|
child: Padding(
|
|
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const AppSheetHandle(),
|
|
|
|
Row(
|
|
children: [
|
|
if (context.isMornye)
|
|
Tooltip(
|
|
message: MaterialLocalizations.of(
|
|
context,
|
|
).closeButtonTooltip,
|
|
child: CupertinoButton(
|
|
color: MornyeTheme.controlFill(context),
|
|
borderRadius: BorderRadius.circular(28),
|
|
padding: const EdgeInsets.all(12),
|
|
onPressed: onClose,
|
|
child: Icon(
|
|
CupertinoIcons.xmark,
|
|
color: colorScheme.onSurface,
|
|
size: 24,
|
|
),
|
|
),
|
|
)
|
|
else
|
|
IconButton.filledTonal(
|
|
onPressed: onClose,
|
|
tooltip: MaterialLocalizations.of(
|
|
context,
|
|
).closeButtonTooltip,
|
|
icon: const Icon(Icons.close),
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
context.l10n.selectionSelected(selectedCount),
|
|
style: Theme.of(context).textTheme.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
Text(
|
|
allSelected
|
|
? allSelectedLabel ??
|
|
context.l10n.selectionAllSelected
|
|
: tapToSelectLabel ??
|
|
context.l10n.downloadedAlbumTapToSelect,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
if (context.isMornye)
|
|
CupertinoButton(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
onPressed: onToggleSelectAll,
|
|
child: Text(
|
|
allSelected
|
|
? context.l10n.actionDeselect
|
|
: context.l10n.actionSelectAll,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.primary,
|
|
),
|
|
),
|
|
)
|
|
else
|
|
TextButton.icon(
|
|
onPressed: onToggleSelectAll,
|
|
icon: Icon(
|
|
allSelected ? Icons.deselect : Icons.select_all,
|
|
size: 20,
|
|
),
|
|
label: Text(
|
|
allSelected
|
|
? context.l10n.actionDeselect
|
|
: context.l10n.actionSelectAll,
|
|
),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: colorScheme.primary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
...children,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
if (context.isMornye) {
|
|
return MornyeGlassPanel.overlay(child: content);
|
|
}
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.vertical(
|
|
top: Radius.circular(tokens.radiusSheet),
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: colorScheme.shadow.withValues(alpha: 0.15),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, -4),
|
|
),
|
|
],
|
|
),
|
|
child: content,
|
|
);
|
|
}
|
|
}
|