860 lines
31 KiB
Dart
860 lines
31 KiB
Dart
import 'dart:async';
|
||
import 'dart:math';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||
|
||
import '../../l10n/app_localizations.dart';
|
||
import '../../models/bar_tab.dart';
|
||
import '../../utils/easter_eggs.dart';
|
||
import '../../utils/money.dart';
|
||
import '../../utils/name_index.dart';
|
||
import 'selected_tab_details.dart';
|
||
|
||
/// Which half of the panel gets most of the height.
|
||
enum _PanelFocus { balanced, names, items }
|
||
|
||
/// Space below every tab row in the names list.
|
||
const _tabRowGap = 8.0;
|
||
|
||
/// The search field's height plus the gap below it.
|
||
const _searchHeight = 56.0;
|
||
|
||
const _panelAnimation = Duration(milliseconds: 250);
|
||
const _panelCurve = Curves.easeOutCubic;
|
||
|
||
/// The selected tile sits on its items, so the corners where they meet are
|
||
/// squared off.
|
||
const _attachedTileRadius = BorderRadius.vertical(
|
||
top: Radius.circular(14),
|
||
bottom: Radius.circular(4),
|
||
);
|
||
const _attachedDetailsRadius = BorderRadius.vertical(
|
||
top: Radius.circular(4),
|
||
bottom: Radius.circular(16),
|
||
);
|
||
|
||
class TabPanel extends StatefulWidget {
|
||
final List<BarTab> tabs;
|
||
final BarTab? selectedTab;
|
||
final String? selectedTabId;
|
||
final bool showSearch;
|
||
final ValueChanged<String> onTabSelected;
|
||
final VoidCallback onTabDeselected;
|
||
final ItemQuantityChanged onItemQuantityChanged;
|
||
final VoidCallback onCloseTabPressed;
|
||
final Future<void> Function(BarTab) onTabEdited;
|
||
final Future<void> Function(BarTab) onTabDeleted;
|
||
|
||
const TabPanel({
|
||
super.key,
|
||
required this.tabs,
|
||
required this.selectedTab,
|
||
required this.selectedTabId,
|
||
required this.showSearch,
|
||
required this.onTabSelected,
|
||
required this.onTabDeselected,
|
||
required this.onItemQuantityChanged,
|
||
required this.onCloseTabPressed,
|
||
required this.onTabEdited,
|
||
required this.onTabDeleted,
|
||
});
|
||
|
||
@override
|
||
State<TabPanel> createState() => _TabPanelState();
|
||
}
|
||
|
||
class _TabPanelState extends State<TabPanel> {
|
||
final TextEditingController _searchController = TextEditingController();
|
||
final ScrollController _tabsScrollController = ScrollController();
|
||
String _searchQuery = '';
|
||
|
||
/// The letter under the finger while the index bar is pressed.
|
||
String? _touchedLetter;
|
||
|
||
_PanelFocus _focus = _PanelFocus.balanced;
|
||
|
||
/// The names-list offset the selected tile was last lined up at.
|
||
double? _attachedOffset;
|
||
|
||
/// The first row of the names list, measured for [_rowExtent].
|
||
final GlobalKey _firstRowKey = GlobalKey();
|
||
|
||
/// The height of every row in the names list, gap included. Measured, as
|
||
/// a short list can't tell it from its scroll range.
|
||
double? _rowExtent;
|
||
|
||
/// The row height is only known once the list is laid out (and changes
|
||
/// with the text size).
|
||
void _measureRow() {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
final box = _firstRowKey.currentContext?.findRenderObject() as RenderBox?;
|
||
if (box == null || !box.hasSize) return;
|
||
if (box.size.height != _rowExtent) {
|
||
setState(() => _rowExtent = box.size.height);
|
||
}
|
||
});
|
||
}
|
||
|
||
void _focusOn(_PanelFocus focus) {
|
||
if (focus != _focus) setState(() => _focus = focus);
|
||
}
|
||
|
||
/// Where the names list scrolls so the selected tab's tile sits right on
|
||
/// top of its items. Null while browsing the names, when the tab is
|
||
/// filtered out, or when its tile is too close to the top to get there.
|
||
double? _attachOffset(List<BarTab> tabs, double viewportHeight) {
|
||
final tab = widget.selectedTab;
|
||
if (tab == null || _focus == _PanelFocus.names) return null;
|
||
|
||
final index = tabs.indexWhere((candidate) => candidate.id == tab.id);
|
||
final extent = _rowExtent;
|
||
if (index == -1 || extent == null) return null;
|
||
|
||
final offset = (index + 1) * extent - _tabRowGap - viewportHeight;
|
||
// Rounding can leave a sliver below zero
|
||
return offset < -0.5 ? null : max(offset, 0);
|
||
}
|
||
|
||
void _lineUpSelectedTab(double? offset) {
|
||
if (offset == _attachedOffset) return;
|
||
|
||
_attachedOffset = offset;
|
||
if (offset == null) return;
|
||
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!_tabsScrollController.hasClients) return;
|
||
|
||
// A list that is still shrinking can't scroll that far yet; the end of
|
||
// the collapse animation finishes the job
|
||
final position = _tabsScrollController.position;
|
||
unawaited(
|
||
_tabsScrollController.animateTo(
|
||
min(offset, position.maxScrollExtent),
|
||
duration: _panelAnimation,
|
||
curve: _panelCurve,
|
||
),
|
||
);
|
||
});
|
||
}
|
||
|
||
void _finishLineUp() {
|
||
final offset = _attachedOffset;
|
||
if (offset == null || !_tabsScrollController.hasClients) return;
|
||
if ((_tabsScrollController.offset - offset).abs() < 0.5) return;
|
||
|
||
unawaited(
|
||
_tabsScrollController.animateTo(
|
||
offset,
|
||
duration: const Duration(milliseconds: 150),
|
||
curve: _panelCurve,
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(TabPanel oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
|
||
// Hiding the search bar shouldn't leave the list silently filtered
|
||
if (oldWidget.showSearch && !widget.showSearch) {
|
||
_searchController.clear();
|
||
_searchQuery = '';
|
||
}
|
||
|
||
// Adding products, or opening a tab from the Open Tab dialog, means
|
||
// working on the selected tab
|
||
final tab = widget.selectedTab;
|
||
final oldTab = oldWidget.selectedTab;
|
||
if (tab != null &&
|
||
(tab.id != oldTab?.id || tab.itemCount != oldTab?.itemCount)) {
|
||
_focus = _PanelFocus.items;
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_searchController.dispose();
|
||
_tabsScrollController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
/// Scrolls to the first tab at or after [letter], so a letter without names
|
||
/// lands on the next one.
|
||
void _jumpToLetter(String letter, List<BarTab> tabs) {
|
||
_focusOn(_PanelFocus.names);
|
||
if (letter != _touchedLetter) {
|
||
unawaited(HapticFeedback.selectionClick());
|
||
setState(() => _touchedLetter = letter);
|
||
}
|
||
|
||
final extent = _rowExtent;
|
||
if (extent == null || !_tabsScrollController.hasClients) return;
|
||
|
||
final position = _tabsScrollController.position;
|
||
final start = indexLetters.indexOf(letter);
|
||
final index = tabs.indexWhere(
|
||
(tab) => indexLetters.indexOf(indexLetterOf(tab.customerName)) >= start,
|
||
);
|
||
|
||
_tabsScrollController.jumpTo(
|
||
index == -1
|
||
? position.maxScrollExtent
|
||
: min(index * extent, position.maxScrollExtent),
|
||
);
|
||
}
|
||
|
||
String? _visibleLetter(List<BarTab> tabs) {
|
||
if (tabs.isEmpty) return null;
|
||
|
||
final extent = _rowExtent;
|
||
final index = extent == null || !_tabsScrollController.hasClients
|
||
? 0
|
||
: (_tabsScrollController.offset / extent).round().clamp(
|
||
0,
|
||
tabs.length - 1,
|
||
);
|
||
|
||
return indexLetterOf(tabs[index].customerName);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final scheme = Theme.of(context).colorScheme;
|
||
final l10n = AppLocalizations.of(context);
|
||
|
||
final filteredTabs = widget.tabs.where((tab) {
|
||
return tab.customerName.toLowerCase().contains(
|
||
_searchQuery.toLowerCase(),
|
||
);
|
||
}).toList()..sort((a, b) => compareByIndex(a.customerName, b.customerName));
|
||
_measureRow();
|
||
|
||
return DecoratedBox(
|
||
decoration: BoxDecoration(color: scheme.surfaceContainerLow),
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(4, 16, 16, 16),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
ListenableBuilder(
|
||
listenable: _tabsScrollController,
|
||
builder: (context, child) => LetterIndexBar(
|
||
availableLetters: {
|
||
for (final tab in filteredTabs)
|
||
indexLetterOf(tab.customerName),
|
||
},
|
||
activeLetter: _touchedLetter ?? _visibleLetter(filteredTabs),
|
||
isTouched: _touchedLetter != null,
|
||
onLetterSelected: (letter) =>
|
||
_jumpToLetter(letter, filteredTabs),
|
||
onReleased: () => setState(() => _touchedLetter = null),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
const dividerHeight = 28.0;
|
||
const minDetailsHeight = 240.0;
|
||
final available = constraints.maxHeight - dividerHeight;
|
||
final collapsedNames = max(available * 0.2, 64.0);
|
||
final collapsedItems = max(available * 0.1, 64.0);
|
||
final focus =
|
||
_focus == _PanelFocus.items && widget.selectedTab == null
|
||
? _PanelFocus.balanced
|
||
: _focus;
|
||
final searchHeight = widget.showSearch ? _searchHeight : 0.0;
|
||
// Without a selected tab there are no items to show, so
|
||
// the names get the whole panel
|
||
final hasSelection = widget.selectedTab != null;
|
||
final selectedIndex = filteredTabs.indexWhere(
|
||
(tab) => tab.id == widget.selectedTabId,
|
||
);
|
||
final rowExtent = _rowExtent;
|
||
final namesHeight = !hasSelection
|
||
? constraints.maxHeight
|
||
: switch (focus) {
|
||
_PanelFocus.balanced => min(246.0, available / 2),
|
||
_PanelFocus.names => available - collapsedItems,
|
||
// A tile near the top can't scroll down to its
|
||
// items, so the names shrink up to it instead
|
||
_PanelFocus.items =>
|
||
(selectedIndex == -1 || rowExtent == null
|
||
? collapsedNames
|
||
: min(
|
||
collapsedNames,
|
||
(selectedIndex + 1) * rowExtent -
|
||
_tabRowGap,
|
||
)) +
|
||
searchHeight,
|
||
};
|
||
|
||
final attachOffset = _attachOffset(
|
||
filteredTabs,
|
||
namesHeight - searchHeight,
|
||
);
|
||
final attached = attachOffset != null;
|
||
_lineUpSelectedTab(attachOffset);
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
AnimatedContainer(
|
||
duration: _panelAnimation,
|
||
curve: _panelCurve,
|
||
height: namesHeight,
|
||
onEnd: _finishLineUp,
|
||
child: Column(
|
||
children: [
|
||
if (widget.showSearch) ...[
|
||
SizedBox(
|
||
height: _searchHeight - 8,
|
||
child: TextField(
|
||
autofocus: true,
|
||
controller: _searchController,
|
||
onTap: () => _focusOn(_PanelFocus.names),
|
||
onChanged: (value) {
|
||
setState(() {
|
||
_searchQuery = value;
|
||
_focus = _PanelFocus.names;
|
||
});
|
||
},
|
||
decoration: InputDecoration(
|
||
hintText: l10n.searchByName,
|
||
prefixIcon: Icon(
|
||
Icons.search_rounded,
|
||
size: 20,
|
||
),
|
||
suffixIcon: _searchQuery.isEmpty
|
||
? null
|
||
: IconButton(
|
||
onPressed: () {
|
||
_searchController.clear();
|
||
setState(() {
|
||
_searchQuery = '';
|
||
});
|
||
},
|
||
icon: const Icon(
|
||
Icons.clear_rounded,
|
||
),
|
||
tooltip: l10n.clearSearch,
|
||
),
|
||
isDense: true,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
Expanded(
|
||
child:
|
||
NotificationListener<ScrollStartNotification>(
|
||
onNotification: (notification) {
|
||
if (notification.dragDetails != null) {
|
||
_focusOn(_PanelFocus.names);
|
||
}
|
||
return false;
|
||
},
|
||
child: OpenTabsList(
|
||
controller: _tabsScrollController,
|
||
firstRowKey: _firstRowKey,
|
||
tabs: filteredTabs,
|
||
selectedTabId: widget.selectedTabId,
|
||
attachedTabId: attached
|
||
? widget.selectedTabId
|
||
: null,
|
||
onTabSelected: (id) {
|
||
// Tapping the open tab again closes it
|
||
if (id == widget.selectedTabId) {
|
||
_focusOn(_PanelFocus.names);
|
||
widget.onTabDeselected();
|
||
return;
|
||
}
|
||
_focusOn(_PanelFocus.items);
|
||
widget.onTabSelected(id);
|
||
},
|
||
onTabEdited: widget.onTabEdited,
|
||
onTabDeleted: widget.onTabDeleted,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (!attached && hasSelection)
|
||
const Divider(height: dividerHeight),
|
||
Expanded(
|
||
child: Listener(
|
||
onPointerDown: (_) => _focusOn(_PanelFocus.items),
|
||
child: AnimatedContainer(
|
||
duration: _panelAnimation,
|
||
curve: _panelCurve,
|
||
padding: EdgeInsets.all(attached ? 12 : 0),
|
||
decoration: BoxDecoration(
|
||
color: attached
|
||
? scheme.primary.withValues(alpha: 0.06)
|
||
: Colors.transparent,
|
||
borderRadius: _attachedDetailsRadius,
|
||
border: Border.all(
|
||
color: attached
|
||
? scheme.primary.withValues(alpha: 0.3)
|
||
: Colors.transparent,
|
||
),
|
||
),
|
||
// Collapsed, the details keep their layout and are
|
||
// clipped to a peek of the first items
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final height = max(
|
||
constraints.maxHeight,
|
||
minDetailsHeight,
|
||
);
|
||
|
||
return ClipRect(
|
||
child: OverflowBox(
|
||
alignment: Alignment.topCenter,
|
||
minHeight: height,
|
||
maxHeight: height,
|
||
child: widget.selectedTab == null
|
||
? Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.receipt_long_outlined,
|
||
size: 36,
|
||
color: scheme.onSurface
|
||
.withValues(alpha: 0.25),
|
||
),
|
||
const SizedBox(height: 10),
|
||
Text(
|
||
l10n.selectOrOpenTab,
|
||
style: Theme.of(
|
||
context,
|
||
).textTheme.bodyMedium,
|
||
),
|
||
],
|
||
),
|
||
)
|
||
: SelectedTabDetails(
|
||
tab: widget.selectedTab!,
|
||
onItemQuantityChanged:
|
||
widget.onItemQuantityChanged,
|
||
onCloseTabPressed:
|
||
widget.onCloseTabPressed,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class OpenTabsList extends StatelessWidget {
|
||
final ScrollController? controller;
|
||
|
||
/// Put on the first row, so its height can be measured.
|
||
final Key? firstRowKey;
|
||
final List<BarTab> tabs;
|
||
final String? selectedTabId;
|
||
|
||
/// The tab whose tile is lined up on top of its items.
|
||
final String? attachedTabId;
|
||
final ValueChanged<String> onTabSelected;
|
||
final Future<void> Function(BarTab) onTabEdited;
|
||
final Future<void> Function(BarTab) onTabDeleted;
|
||
|
||
const OpenTabsList({
|
||
super.key,
|
||
this.controller,
|
||
this.firstRowKey,
|
||
required this.tabs,
|
||
required this.selectedTabId,
|
||
this.attachedTabId,
|
||
required this.onTabSelected,
|
||
required this.onTabEdited,
|
||
required this.onTabDeleted,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l10n = AppLocalizations.of(context);
|
||
if (tabs.isEmpty) {
|
||
return Center(
|
||
child: Text(
|
||
l10n.noOpenTabs,
|
||
style: Theme.of(context).textTheme.bodyMedium,
|
||
),
|
||
);
|
||
}
|
||
|
||
// A fixed row height lets the letter index work out where each tab is
|
||
return ListView.builder(
|
||
controller: controller,
|
||
itemCount: tabs.length,
|
||
// The selected card's border is thickest, so it sets the height
|
||
prototypeItem: _TabRow(child: _TabCard(tab: tabs.first, selected: true)),
|
||
itemBuilder: (context, index) {
|
||
final tab = tabs[index];
|
||
final attached = tab.id == attachedTabId;
|
||
|
||
return _TabRow(
|
||
key: index == 0 ? firstRowKey : null,
|
||
child: ClipRRect(
|
||
borderRadius: attached
|
||
? _attachedTileRadius
|
||
: BorderRadius.circular(14),
|
||
child: Slidable(
|
||
key: ValueKey(tab.id),
|
||
endActionPane: ActionPane(
|
||
motion: const DrawerMotion(),
|
||
extentRatio: 0.6,
|
||
children: [
|
||
SlidableAction(
|
||
onPressed: (_) => onTabEdited(tab),
|
||
icon: Icons.edit_outlined,
|
||
label: l10n.edit,
|
||
backgroundColor: Theme.of(context).colorScheme.secondary,
|
||
foregroundColor: Theme.of(context).colorScheme.onSecondary,
|
||
),
|
||
SlidableAction(
|
||
onPressed: (_) => onTabDeleted(tab),
|
||
icon: Icons.delete_outline_rounded,
|
||
label: l10n.delete,
|
||
backgroundColor: Theme.of(context).colorScheme.error,
|
||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||
),
|
||
],
|
||
),
|
||
child: _TabCard(
|
||
tab: tab,
|
||
selected: tab.id == selectedTabId,
|
||
attached: attached,
|
||
onTap: () => onTabSelected(tab.id),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _TabRow extends StatelessWidget {
|
||
final Widget child;
|
||
|
||
const _TabRow({super.key, required this.child});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: _tabRowGap),
|
||
child: child,
|
||
);
|
||
}
|
||
}
|
||
|
||
class _TabCard extends StatelessWidget {
|
||
final BarTab tab;
|
||
final bool selected;
|
||
final bool attached;
|
||
final VoidCallback? onTap;
|
||
|
||
const _TabCard({
|
||
required this.tab,
|
||
this.selected = false,
|
||
this.attached = false,
|
||
this.onTap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final l10n = AppLocalizations.of(context);
|
||
final scheme = Theme.of(context).colorScheme;
|
||
final primary = scheme.primary;
|
||
final radius = attached ? _attachedTileRadius : BorderRadius.circular(14);
|
||
|
||
return AnimatedContainer(
|
||
duration: const Duration(milliseconds: 150),
|
||
decoration: BoxDecoration(
|
||
color: selected
|
||
? primary.withValues(alpha: 0.14)
|
||
: scheme.onSurface.withValues(alpha: 0.04),
|
||
borderRadius: radius,
|
||
border: Border.all(
|
||
color: selected
|
||
? primary.withValues(alpha: 0.6)
|
||
: scheme.onSurface.withValues(alpha: 0.06),
|
||
width: selected ? 1.4 : 1,
|
||
),
|
||
),
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
borderRadius: radius,
|
||
onTap: onTap,
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||
child: SizedBox(
|
||
width: double.infinity,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
customerDisplayName(tab.customerName),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w800,
|
||
color: scheme.onSurface,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
l10n.tabItemSummary(tab.itemCount, tab.totalInCents.asEuro),
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The A–Z strip beside a list of names: tap or slide to jump to a letter.
|
||
class LetterIndexBar extends StatefulWidget {
|
||
final Set<String> availableLetters;
|
||
final String? activeLetter;
|
||
final bool isTouched;
|
||
final ValueChanged<String> onLetterSelected;
|
||
final VoidCallback onReleased;
|
||
|
||
const LetterIndexBar({
|
||
super.key,
|
||
required this.availableLetters,
|
||
required this.activeLetter,
|
||
required this.isTouched,
|
||
required this.onLetterSelected,
|
||
required this.onReleased,
|
||
});
|
||
|
||
@override
|
||
State<LetterIndexBar> createState() => _LetterIndexBarState();
|
||
}
|
||
|
||
class _LetterIndexBarState extends State<LetterIndexBar>
|
||
with SingleTickerProviderStateMixin {
|
||
static final _zIndex = indexLetters.indexOf('Z');
|
||
|
||
/// Sliding (rather than tapping) leaves a fading trail of rainbow dots.
|
||
bool _isSliding = false;
|
||
|
||
/// Progress of an A → Z → A slide: null once broken, else whether Z was hit.
|
||
bool? _reachedZ;
|
||
|
||
/// Sweeps a rainbow down the bar after a full A → Z → A slide.
|
||
late final AnimationController _wave = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 1400),
|
||
);
|
||
|
||
@override
|
||
void dispose() {
|
||
_wave.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _setSliding(bool value) {
|
||
if (_isSliding != value) setState(() => _isSliding = value);
|
||
}
|
||
|
||
void _trackRainbowRoad(int index, {bool isStart = false}) {
|
||
if (isStart) {
|
||
_reachedZ = index == 0 ? false : null;
|
||
} else if (_reachedZ == false && index >= _zIndex) {
|
||
_reachedZ = true;
|
||
} else if (_reachedZ == true && index == 0) {
|
||
_reachedZ = null;
|
||
_wave.forward(from: 0);
|
||
ScaffoldMessenger.maybeOf(context)
|
||
?..hideCurrentSnackBar()
|
||
..showSnackBar(
|
||
SnackBar(content: Text(AppLocalizations.of(context).rainbowRoad)),
|
||
);
|
||
}
|
||
}
|
||
|
||
static Color _rainbowAt(int index) => HSVColor.fromAHSV(
|
||
1,
|
||
360 * index / indexLetters.length,
|
||
0.75,
|
||
0.95,
|
||
).toColor();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final scheme = theme.colorScheme;
|
||
final onLetterSelected = widget.onLetterSelected;
|
||
|
||
void onReleased() {
|
||
_setSliding(false);
|
||
widget.onReleased();
|
||
}
|
||
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final letterHeight = constraints.maxHeight / indexLetters.length;
|
||
final fontSize = min(13.0, letterHeight * 0.65);
|
||
|
||
int indexAt(Offset position) => (position.dy / letterHeight)
|
||
.floor()
|
||
.clamp(0, indexLetters.length - 1);
|
||
|
||
String letterAt(Offset position) => indexLetters[indexAt(position)];
|
||
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTapDown: (details) =>
|
||
onLetterSelected(letterAt(details.localPosition)),
|
||
onTapUp: (_) => onReleased(),
|
||
onTapCancel: onReleased,
|
||
onVerticalDragStart: (details) {
|
||
_setSliding(true);
|
||
_trackRainbowRoad(indexAt(details.localPosition), isStart: true);
|
||
onLetterSelected(letterAt(details.localPosition));
|
||
},
|
||
onVerticalDragUpdate: (details) {
|
||
_trackRainbowRoad(indexAt(details.localPosition));
|
||
onLetterSelected(letterAt(details.localPosition));
|
||
},
|
||
onVerticalDragEnd: (_) => onReleased(),
|
||
onVerticalDragCancel: onReleased,
|
||
child: SizedBox(
|
||
width: 28,
|
||
child: AnimatedBuilder(
|
||
animation: _wave,
|
||
builder: (context, _) {
|
||
final isWaving = _wave.isAnimating;
|
||
// Runs past both ends so the first and last dots fade too.
|
||
final crest = _wave.value * (indexLetters.length + 8) - 2;
|
||
|
||
return Column(
|
||
children: [
|
||
for (final (index, letter) in indexLetters.indexed)
|
||
SizedBox(
|
||
height: letterHeight,
|
||
child: Center(
|
||
child: _IndexLetter(
|
||
letter: letter,
|
||
fontSize: fontSize,
|
||
isActive: letter == widget.activeLetter,
|
||
isTouched: widget.isTouched,
|
||
isLit: isWaving && (crest - index).abs() < 1.5,
|
||
hasNames: widget.availableLetters.contains(letter),
|
||
rainbowColor: _isSliding || isWaving
|
||
? _rainbowAt(index)
|
||
: null,
|
||
scheme: scheme,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _IndexLetter extends StatelessWidget {
|
||
final String letter;
|
||
final double fontSize;
|
||
final bool isActive;
|
||
final bool isTouched;
|
||
|
||
/// Lit by the rainbow road wave, regardless of touch.
|
||
final bool isLit;
|
||
final bool hasNames;
|
||
|
||
/// Set while sliding: the dot takes this color and fades out slowly.
|
||
final Color? rainbowColor;
|
||
final ColorScheme scheme;
|
||
|
||
const _IndexLetter({
|
||
required this.letter,
|
||
required this.fontSize,
|
||
required this.isActive,
|
||
required this.isTouched,
|
||
this.isLit = false,
|
||
required this.hasNames,
|
||
this.rainbowColor,
|
||
required this.scheme,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final rainbowColor = this.rainbowColor;
|
||
final showDot = (isActive && isTouched) || isLit;
|
||
final color = switch ((isActive, isTouched, hasNames)) {
|
||
_ when isLit && rainbowColor != null => Colors.white,
|
||
(true, true, _) when rainbowColor != null => Colors.white,
|
||
(true, true, _) => scheme.onPrimary,
|
||
(true, false, _) => scheme.primary,
|
||
(false, _, true) => scheme.onSurface,
|
||
(false, _, false) => scheme.onSurface.withValues(alpha: 0.25),
|
||
};
|
||
|
||
return AnimatedContainer(
|
||
// Applies to the transition being started, so a sliding dot appears
|
||
// instantly but lingers on its way out.
|
||
duration: Duration(
|
||
milliseconds: rainbowColor != null && !showDot ? 600 : 120,
|
||
),
|
||
width: fontSize * 1.8,
|
||
height: fontSize * 1.8,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: switch ((showDot, rainbowColor)) {
|
||
(true, final Color rainbow?) => rainbow,
|
||
(true, null) => scheme.primary,
|
||
(false, final Color rainbow?) => rainbow.withValues(alpha: 0),
|
||
(false, null) => Colors.transparent,
|
||
},
|
||
),
|
||
child: Text(
|
||
letter,
|
||
style: TextStyle(
|
||
fontSize: fontSize,
|
||
height: 1,
|
||
fontWeight: isActive || hasNames ? FontWeight.w800 : FontWeight.w500,
|
||
color: color,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|