diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6b4250e..602b8a9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -356,5 +356,12 @@ "placeholders": { "amount": {"type": "String"} } - } + }, + "creditsDirectedBy": "Directed by", + "creditsStarring": "Starring", + "creditsSpecialThanks": "Special thanks", + "creditsEveryoneAtTheBar": "Everyone at the bar", + "creditsDrinkResponsibly": "Drink responsibly. Mostly.", + "lazyPinRemark": "๐Ÿคจ Really?", + "rainbowRoad": "Rainbow road ๐ŸŒˆ" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e7d9bf9..8f00774 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2035,6 +2035,48 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Let the customer scan this code to pay {amount}'** String scanToPay(String amount); + + /// No description provided for @creditsDirectedBy. + /// + /// In en, this message translates to: + /// **'Directed by'** + String get creditsDirectedBy; + + /// No description provided for @creditsStarring. + /// + /// In en, this message translates to: + /// **'Starring'** + String get creditsStarring; + + /// No description provided for @creditsSpecialThanks. + /// + /// In en, this message translates to: + /// **'Special thanks'** + String get creditsSpecialThanks; + + /// No description provided for @creditsEveryoneAtTheBar. + /// + /// In en, this message translates to: + /// **'Everyone at the bar'** + String get creditsEveryoneAtTheBar; + + /// No description provided for @creditsDrinkResponsibly. + /// + /// In en, this message translates to: + /// **'Drink responsibly. Mostly.'** + String get creditsDrinkResponsibly; + + /// No description provided for @lazyPinRemark. + /// + /// In en, this message translates to: + /// **'๐Ÿคจ Really?'** + String get lazyPinRemark; + + /// No description provided for @rainbowRoad. + /// + /// In en, this message translates to: + /// **'Rainbow road ๐ŸŒˆ'** + String get rainbowRoad; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 209b5bf..d969885 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1082,4 +1082,25 @@ class AppLocalizationsEn extends AppLocalizations { String scanToPay(String amount) { return 'Let the customer scan this code to pay $amount'; } + + @override + String get creditsDirectedBy => 'Directed by'; + + @override + String get creditsStarring => 'Starring'; + + @override + String get creditsSpecialThanks => 'Special thanks'; + + @override + String get creditsEveryoneAtTheBar => 'Everyone at the bar'; + + @override + String get creditsDrinkResponsibly => 'Drink responsibly. Mostly.'; + + @override + String get lazyPinRemark => '๐Ÿคจ Really?'; + + @override + String get rainbowRoad => 'Rainbow road ๐ŸŒˆ'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 5486f1e..bb386c5 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1096,4 +1096,25 @@ class AppLocalizationsNl extends AppLocalizations { String scanToPay(String amount) { return 'Laat de klant deze code scannen om $amount te betalen'; } + + @override + String get creditsDirectedBy => 'Geregisseerd door'; + + @override + String get creditsStarring => 'Met in de hoofdrol'; + + @override + String get creditsSpecialThanks => 'Speciale dank aan'; + + @override + String get creditsEveryoneAtTheBar => 'Iedereen aan de toog'; + + @override + String get creditsDrinkResponsibly => 'Drink met mate. Meestal.'; + + @override + String get lazyPinRemark => '๐Ÿคจ Echt?'; + + @override + String get rainbowRoad => 'Regenboogweg ๐ŸŒˆ'; } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 27d120d..d488638 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -344,5 +344,12 @@ "deviceNameHint": "bv. Bartoestel 1", "registrationCodeHelper": "Je vindt deze code op het dashboard bij toestellen.", "venueNowLinked": "Dit toestel is nu gekoppeld aan {venue}.", - "scanToPay": "Laat de klant deze code scannen om {amount} te betalen" + "scanToPay": "Laat de klant deze code scannen om {amount} te betalen", + "creditsDirectedBy": "Geregisseerd door", + "creditsStarring": "Met in de hoofdrol", + "creditsSpecialThanks": "Speciale dank aan", + "creditsEveryoneAtTheBar": "Iedereen aan de toog", + "creditsDrinkResponsibly": "Drink met mate. Meestal.", + "lazyPinRemark": "๐Ÿคจ Echt?", + "rainbowRoad": "Regenboogweg ๐ŸŒˆ" } diff --git a/lib/services/remote_sync_service.dart b/lib/services/remote_sync_service.dart index 4da2312..4faf03e 100644 --- a/lib/services/remote_sync_service.dart +++ b/lib/services/remote_sync_service.dart @@ -104,6 +104,28 @@ class RemoteSyncService { /// Null for devices linked before the name was kept. Future get venueName => secureStorage.read(key: _venueNameKey); + /// Looks up and keeps the venue's name for a device linked before the + /// name was kept. Null when it can't be found, e.g. while offline. + Future fetchVenueName() async { + final venueId = await this.venueId; + if (venueId == null) return null; + + try { + final venues = await apiClient.getVenues(); + final name = venues + .where((venue) => venue.id == venueId) + .firstOrNull + ?.name; + if (name != null) { + await secureStorage.write(key: _venueNameKey, value: name); + } + return name; + } on Exception catch (e) { + debugPrint('RemoteSyncService: fetchVenueName error: $e'); + return null; + } + } + Future get isConfigured async => await _credentials() != null; Future get authToken async => (await _credentials())?.token; diff --git a/lib/utils/easter_eggs.dart b/lib/utils/easter_eggs.dart new file mode 100644 index 0000000..e7b8eae --- /dev/null +++ b/lib/utils/easter_eggs.dart @@ -0,0 +1,13 @@ +/// Names that get a crown wherever a tab's customer name is shown. +const _royalNames = {'bram verhulst'}; + +bool isRoyalCustomer(String name) => _royalNames.contains( + name.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '), +); + +/// Display-only: never use the result as the stored customer name. +String customerDisplayName(String name) => + isRoyalCustomer(name) ? '$name ๐Ÿ‘‘' : name; + +/// PINs that earn a raised eyebrow on the PIN pad. +bool isLazyPin(String pin) => pin == '1234' || pin == '0000'; diff --git a/lib/viewmodels/remote_sync_view_model.dart b/lib/viewmodels/remote_sync_view_model.dart index 7e9d2a5..1939c4e 100644 --- a/lib/viewmodels/remote_sync_view_model.dart +++ b/lib/viewmodels/remote_sync_view_model.dart @@ -62,6 +62,16 @@ class RemoteSyncViewModel extends ChangeNotifier { _hasLoaded = true; notifyListeners(); + + if (_isConfigured && _venueName == null) unawaited(_fetchVenueName()); + } + + Future _fetchVenueName() async { + final name = await syncService.fetchVenueName(); + if (name == null) return; + + _venueName = name; + notifyListeners(); } /// Called once registration stored new credentials. diff --git a/lib/views/bar/bar_screen_view.dart b/lib/views/bar/bar_screen_view.dart index d9ff3b9..1744044 100644 --- a/lib/views/bar/bar_screen_view.dart +++ b/lib/views/bar/bar_screen_view.dart @@ -18,6 +18,7 @@ import '../../viewmodels/remote_sync_view_model.dart'; import '../../viewmodels/settings_view_model.dart'; import '../dialogs/close_tab_dialog.dart'; import '../dialogs/edit_tab_dialog.dart'; +import '../widgets/venue_title.dart'; import 'product_grid.dart'; import 'tab_panel.dart'; @@ -118,10 +119,17 @@ class _BarScreenViewState extends State { final viewModel = context.watch(); final productsViewModel = context.watch(); final settings = context.watch().settings; + final venueName = context.select( + (viewModel) => viewModel.venueName, + ); return Scaffold( appBar: AppBar( - title: Text(l10n.barTabs), + title: VenueTitle( + text: venueName == null || venueName.trim().isEmpty + ? l10n.barTabs + : venueName, + ), actions: [ IconButton( tooltip: _showSearch ? l10n.closeSearch : l10n.searchTabs, diff --git a/lib/views/bar/product_grid.dart b/lib/views/bar/product_grid.dart index d0a5568..c280e02 100644 --- a/lib/views/bar/product_grid.dart +++ b/lib/views/bar/product_grid.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; @@ -147,10 +148,15 @@ class ProductGrid extends StatelessWidget { itemBuilder: (context, index) { final product = products[index]; - return ProductTile( - product: product, + return _FireTile( + key: ValueKey(product.id), enabled: hasSelectedTab, onTap: () => onProductTap(product), + builder: (onTap) => ProductTile( + product: product, + enabled: hasSelectedTab, + onTap: onTap, + ), ); }, ); @@ -161,3 +167,128 @@ class ProductGrid extends StatelessWidget { ); } } + +/// Five quick taps on the same product set its tile on fire for a moment. +class _FireTile extends StatefulWidget { + final bool enabled; + final VoidCallback onTap; + final Widget Function(VoidCallback onTap) builder; + + const _FireTile({ + super.key, + required this.enabled, + required this.onTap, + required this.builder, + }); + + @override + State<_FireTile> createState() => _FireTileState(); +} + +class _FireTileState extends State<_FireTile> + with SingleTickerProviderStateMixin { + static const _tapsNeeded = 5; + static const _tapWindow = Duration(milliseconds: 700); + static const _burnTime = Duration(milliseconds: 2500); + + late final AnimationController _flicker = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 180), + ); + + int _tapCount = 0; + DateTime? _lastTap; + Timer? _burnTimer; + + bool get _isOnFire => _burnTimer != null; + + @override + void dispose() { + _burnTimer?.cancel(); + _flicker.dispose(); + super.dispose(); + } + + void _onTap() { + widget.onTap(); + + final now = DateTime.now(); + final last = _lastTap; + _tapCount = last != null && now.difference(last) < _tapWindow + ? _tapCount + 1 + : 1; + _lastTap = now; + if (_tapCount < _tapsNeeded && !_isOnFire) return; + + // Each extra quick tap keeps it burning. + _burnTimer?.cancel(); + _burnTimer = Timer(_burnTime, () { + if (!mounted) return; + _flicker.stop(); + setState(() => _burnTimer = null); + }); + if (!_flicker.isAnimating) _flicker.repeat(reverse: true); + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + widget.builder(_onTap), + IgnorePointer( + child: AnimatedOpacity( + opacity: _isOnFire ? 1 : 0, + duration: const Duration(milliseconds: 300), + child: AnimatedBuilder( + animation: _flicker, + builder: (context, _) { + final heat = _flicker.value; + + return Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Color.lerp( + Colors.deepOrange, + Colors.amber, + heat, + )!, + width: 2.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.deepOrange.withValues( + alpha: 0.45 + 0.3 * heat, + ), + blurRadius: 14 + 10 * heat, + spreadRadius: 1 + 2 * heat, + ), + ], + ), + ), + Positioned( + left: 8, + bottom: 6, + child: Transform.scale( + scale: 1 + 0.15 * heat, + alignment: Alignment.bottomCenter, + child: const Text('๐Ÿ”ฅ', style: TextStyle(fontSize: 26)), + ), + ), + ], + ); + }, + ), + ), + ), + ], + ); + } +} diff --git a/lib/views/bar/tab_panel.dart b/lib/views/bar/tab_panel.dart index 1f57392..4c7fcc7 100644 --- a/lib/views/bar/tab_panel.dart +++ b/lib/views/bar/tab_panel.dart @@ -8,6 +8,7 @@ import 'package:flutter_slidable/flutter_slidable.dart'; import '../../l10n/app_localizations.dart'; import '../../models/bar_tab.dart'; import '../../models/tab_item.dart'; +import '../../utils/easter_eggs.dart'; import '../../utils/money.dart'; import '../../utils/name_index.dart'; import 'selected_tab_details.dart'; @@ -578,7 +579,7 @@ class _TabCard extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - tab.customerName, + customerDisplayName(tab.customerName), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( @@ -602,7 +603,7 @@ class _TabCard extends StatelessWidget { } } -class _LetterIndexBar extends StatelessWidget { +class _LetterIndexBar extends StatefulWidget { final Set availableLetters; final String? activeLetter; final bool isTouched; @@ -617,20 +618,80 @@ class _LetterIndexBar extends StatelessWidget { 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); - String letterAt(Offset position) { - final index = (position.dy / letterHeight).floor(); - return indexLetters[index.clamp(0, indexLetters.length - 1)]; - } + 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, @@ -638,31 +699,49 @@ class _LetterIndexBar extends StatelessWidget { onLetterSelected(letterAt(details.localPosition)), onTapUp: (_) => onReleased(), onTapCancel: onReleased, - onVerticalDragStart: (details) => - onLetterSelected(letterAt(details.localPosition)), - onVerticalDragUpdate: (details) => - onLetterSelected(letterAt(details.localPosition)), + 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: Column( - children: [ - for (final letter in indexLetters) - SizedBox( - height: letterHeight, - child: Center( - child: _IndexLetter( - letter: letter, - fontSize: fontSize, - isActive: letter == activeLetter, - isTouched: isTouched, - hasNames: availableLetters.contains(letter), - scheme: scheme, + 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, + ), + ), ), - ), - ), - ], + ], + ); + }, ), ), ); @@ -676,7 +755,13 @@ class _IndexLetter extends StatelessWidget { 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({ @@ -684,13 +769,19 @@ class _IndexLetter extends StatelessWidget { 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, @@ -698,13 +789,22 @@ class _IndexLetter extends StatelessWidget { }; return AnimatedContainer( - duration: const Duration(milliseconds: 120), + // 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: isActive && isTouched ? scheme.primary : Colors.transparent, + 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, diff --git a/lib/views/credits_view.dart b/lib/views/credits_view.dart new file mode 100644 index 0000000..63bbec0 --- /dev/null +++ b/lib/views/credits_view.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../l10n/app_localizations.dart'; +import '../viewmodels/product_list_view_model.dart'; + +/// Movie-style end credits, opened by tapping the version number in settings. +class CreditsView extends StatefulWidget { + final String appVersion; + + const CreditsView({super.key, required this.appVersion}); + + @override + State createState() => _CreditsViewState(); +} + +class _CreditsViewState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 25), + )..forward(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final products = context.select>( + (viewModel) => [ + for (final product in viewModel.products) + if (product.active) product.name, + ], + ); + + const titleStyle = TextStyle( + color: Colors.white, + fontSize: 34, + fontWeight: FontWeight.w900, + letterSpacing: 4, + ); + const headingStyle = TextStyle( + color: Colors.white54, + fontSize: 14, + fontWeight: FontWeight.w600, + letterSpacing: 3, + ); + const nameStyle = TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w700, + ); + + final credits = Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('KOOLTAP', style: titleStyle), + const SizedBox(height: 8), + Text(l10n.version(widget.appVersion), style: headingStyle), + const SizedBox(height: 64), + Text(l10n.creditsDirectedBy.toUpperCase(), style: headingStyle), + const SizedBox(height: 8), + const Text('Bram Verhulst ๐Ÿ‘‘', style: nameStyle), + const SizedBox(height: 48), + Text(l10n.creditsStarring.toUpperCase(), style: headingStyle), + const SizedBox(height: 8), + for (final name in products) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Text(name, style: nameStyle, textAlign: TextAlign.center), + ), + const SizedBox(height: 48), + Text(l10n.creditsSpecialThanks.toUpperCase(), style: headingStyle), + const SizedBox(height: 8), + Text(l10n.creditsEveryoneAtTheBar, style: nameStyle), + const SizedBox(height: 96), + Text( + l10n.creditsDrinkResponsibly, + style: headingStyle, + textAlign: TextAlign.center, + ), + ], + ); + + return Scaffold( + backgroundColor: Colors.black, + body: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.of(context).pop(), + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return ClipRect( + child: AnimatedBuilder( + animation: _controller, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: credits, + ), + builder: (context, child) { + // Rolls from just below the screen to fully above it. + return OverflowBox( + alignment: Alignment.topCenter, + maxHeight: double.infinity, + child: FractionalTranslation( + translation: Offset(0, -_controller.value), + child: Transform.translate( + offset: Offset( + 0, + constraints.maxHeight * (1 - _controller.value), + ), + child: child, + ), + ), + ); + }, + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/views/pin_lock_view.dart b/lib/views/pin_lock_view.dart index e7aeafe..7e9a91c 100644 --- a/lib/views/pin_lock_view.dart +++ b/lib/views/pin_lock_view.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../l10n/app_localizations.dart'; import '../l10n/app_localizations_helpers.dart'; import '../models/app_error.dart'; +import '../utils/easter_eggs.dart'; import '../viewmodels/pin_lock_view_model.dart'; enum PinEntryMode { unlock, create, change } @@ -53,6 +54,9 @@ class _PinEntryViewState extends State { bool _isConfirmStep = false; bool _isSubmitting = false; String? _localError; + + /// A cheeky remark for a lazy PIN, shown instead of an error. + bool _showLazyPinRemark = false; bool _shake = false; bool get _isChangeFlow => widget.mode == PinEntryMode.change; @@ -94,6 +98,7 @@ class _PinEntryViewState extends State { setState(() { _digits += digit; _localError = null; + if (_digits.length == 1) _showLazyPinRemark = false; }); if (_digits.length == _pinLength) { @@ -119,6 +124,7 @@ class _PinEntryViewState extends State { _firstEntry = entered; _isConfirmStep = true; _digits = ''; + _showLazyPinRemark = isLazyPin(entered); }); return; } @@ -164,6 +170,16 @@ class _PinEntryViewState extends State { _currentPin = _digits; _digits = ''; }); + } else if (isLazyPin(_digits)) { + // Correct, but let them see the raised eyebrow before moving on. + setState(() { + _showLazyPinRemark = true; + _isSubmitting = true; + }); + await Future.delayed(const Duration(milliseconds: 900)); + if (!mounted) return; + setState(() => _isSubmitting = false); + widget.onSuccess?.call(); } else { widget.onSuccess?.call(); } @@ -203,6 +219,7 @@ class _PinEntryViewState extends State { void _fail(String message) { setState(() { _localError = message; + _showLazyPinRemark = false; _digits = ''; _shake = true; }); @@ -262,6 +279,15 @@ class _PinEntryViewState extends State { _localError!, style: TextStyle(color: scheme.error, fontSize: 13), ) + : _showLazyPinRemark + ? Text( + l10n.lazyPinRemark, + style: TextStyle( + color: scheme.onSurface.withValues(alpha: 0.7), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ) : const SizedBox.shrink(), ), const Spacer(flex: 2), diff --git a/lib/views/settings_view.dart b/lib/views/settings_view.dart index 21e06cd..59018ed 100644 --- a/lib/views/settings_view.dart +++ b/lib/views/settings_view.dart @@ -15,6 +15,7 @@ import '../utils/navigation.dart'; import '../viewmodels/admin_pin_view_model.dart'; import '../viewmodels/pin_lock_view_model.dart'; import '../viewmodels/settings_view_model.dart'; +import 'credits_view.dart'; import 'pin_lock_view.dart'; class SettingsScreenView extends StatefulWidget { @@ -27,6 +28,9 @@ class SettingsScreenView extends StatefulWidget { class _SettingsScreenViewState extends State { String _appVersion = ''; Timer? _versionHoldTimer; + static const _versionTapWindow = Duration(milliseconds: 600); + int _versionTapCount = 0; + DateTime? _lastVersionTap; @override void initState() { @@ -65,6 +69,25 @@ class _SettingsScreenViewState extends State { _versionHoldTimer = null; } + /// Five quick taps on the version number roll the credits. + void _onVersionTap() { + final now = DateTime.now(); + final last = _lastVersionTap; + _versionTapCount = last != null && now.difference(last) < _versionTapWindow + ? _versionTapCount + 1 + : 1; + _lastVersionTap = now; + + if (_versionTapCount < 5) return; + _versionTapCount = 0; + Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => CreditsView(appVersion: _appVersion), + ), + ); + } + @override void dispose() { _cancelVersionHold(); @@ -377,6 +400,7 @@ class _SettingsScreenViewState extends State { onTapDown: (_) => _startVersionHold(), onTapUp: (_) => _cancelVersionHold(), onTapCancel: _cancelVersionHold, + onTap: _onVersionTap, child: Center( child: Text( l10n.version(_appVersion), diff --git a/lib/views/widgets/closed_tab_card.dart b/lib/views/widgets/closed_tab_card.dart index 39f9c78..9fc09d8 100644 --- a/lib/views/widgets/closed_tab_card.dart +++ b/lib/views/widgets/closed_tab_card.dart @@ -5,6 +5,7 @@ import '../../l10n/app_localizations.dart'; import '../../models/closed_tab.dart'; import '../../models/closed_tab_item.dart'; import '../../models/payment_method.dart'; +import '../../utils/easter_eggs.dart'; import '../../utils/history_grouping.dart'; import '../../utils/money.dart'; @@ -71,7 +72,7 @@ class _ClosedTabCardState extends State { children: [ Expanded( child: Text( - closedTab.customerName, + customerDisplayName(closedTab.customerName), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( diff --git a/lib/views/widgets/open_tab_history_card.dart b/lib/views/widgets/open_tab_history_card.dart index 73ec789..cfb9bd7 100644 --- a/lib/views/widgets/open_tab_history_card.dart +++ b/lib/views/widgets/open_tab_history_card.dart @@ -4,6 +4,7 @@ import 'package:intl/intl.dart'; import '../../l10n/app_localizations.dart'; import '../../models/bar_tab.dart'; import '../../models/tab_item_purchase.dart'; +import '../../utils/easter_eggs.dart'; import '../../utils/money.dart'; class OpenTabHistoryCard extends StatefulWidget { @@ -93,7 +94,7 @@ class _OpenTabHistoryCardState extends State { children: [ Expanded( child: Text( - tab.customerName, + customerDisplayName(tab.customerName), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( diff --git a/lib/views/widgets/venue_title.dart b/lib/views/widgets/venue_title.dart new file mode 100644 index 0000000..5845b1e --- /dev/null +++ b/lib/views/widgets/venue_title.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; + +/// The bar screen title. Ten quick taps light it up as a flickering neon +/// cafรฉ sign for a few seconds. +class VenueTitle extends StatefulWidget { + final String text; + + const VenueTitle({super.key, required this.text}); + + @override + State createState() => _VenueTitleState(); +} + +class _VenueTitleState extends State + with SingleTickerProviderStateMixin { + static const _tapsNeeded = 10; + static const _tapWindow = Duration(milliseconds: 600); + static const _neonPink = Color(0xFFFF2BD6); + + /// Stretches of the animation (as fractions) where the tube is dark, + /// like a sign that sputters on and then dies out. + static const _darkSpans = [ + (0.02, 0.05), + (0.07, 0.08), + (0.10, 0.14), + (0.55, 0.56), + (0.88, 0.89), + (0.91, 0.93), + ]; + + late final AnimationController _neon = AnimationController( + vsync: this, + duration: const Duration(seconds: 5), + ); + + int _tapCount = 0; + DateTime? _lastTap; + + @override + void dispose() { + _neon.dispose(); + super.dispose(); + } + + void _onTap() { + final now = DateTime.now(); + final last = _lastTap; + _tapCount = last != null && now.difference(last) < _tapWindow + ? _tapCount + 1 + : 1; + _lastTap = now; + + if (_tapCount < _tapsNeeded) return; + _tapCount = 0; + _neon.forward(from: 0); + } + + @override + Widget build(BuildContext context) { + final baseStyle = DefaultTextStyle.of(context).style; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _onTap, + child: AnimatedBuilder( + animation: _neon, + builder: (context, _) { + final t = _neon.value; + final isLit = + _neon.isAnimating && + !_darkSpans.any((span) => t >= span.$1 && t < span.$2); + + return Text( + widget.text, + style: isLit + ? baseStyle.copyWith( + color: const Color(0xFFFFD6F6), + fontWeight: FontWeight.w800, + shadows: const [ + Shadow(color: _neonPink, blurRadius: 4), + Shadow(color: _neonPink, blurRadius: 12), + Shadow(color: _neonPink, blurRadius: 24), + ], + ) + : _neon.isAnimating + ? baseStyle.copyWith( + color: baseStyle.color?.withValues(alpha: 0.35), + fontWeight: FontWeight.w800, + ) + : baseStyle, + ); + }, + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index dfcd939..00ca35e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: kooltap description: "KoolTap: a tablet point-of-sale app for running bar tabs." publish_to: 'none' -version: 1.3.10+0 +version: 1.3.15+0 environment: sdk: ^3.12.2