diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1480a1b..262f715 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -319,6 +319,15 @@ "deviceRegistered": "Device registered!", "openTabsWillSync": "Open tabs will sync to the dashboard.", "done": "Done", + "stepVenue": "Venue", + "stepDevice": "Device", + "stepLinked": "Linked", + "noVenuesFound": "No venues found. Create one on the dashboard first.", + "changeVenue": "Change", + "deviceNameHint": "e.g. Bar terminal 1", + "registrationCodeHelper": "Find this code on the dashboard under devices.", + "venueNowLinked": "This device is now linked to {venue}.", + "@venueNowLinked": {"placeholders": {"venue": {"type": "String"}}}, "scanToPay": "Let the customer scan this code to pay {amount}", "@scanToPay": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7b17d04..bd6ad75 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1880,6 +1880,54 @@ abstract class AppLocalizations { /// **'Done'** String get done; + /// No description provided for @stepVenue. + /// + /// In en, this message translates to: + /// **'Venue'** + String get stepVenue; + + /// No description provided for @stepDevice. + /// + /// In en, this message translates to: + /// **'Device'** + String get stepDevice; + + /// No description provided for @stepLinked. + /// + /// In en, this message translates to: + /// **'Linked'** + String get stepLinked; + + /// No description provided for @noVenuesFound. + /// + /// In en, this message translates to: + /// **'No venues found. Create one on the dashboard first.'** + String get noVenuesFound; + + /// No description provided for @changeVenue. + /// + /// In en, this message translates to: + /// **'Change'** + String get changeVenue; + + /// No description provided for @deviceNameHint. + /// + /// In en, this message translates to: + /// **'e.g. Bar terminal 1'** + String get deviceNameHint; + + /// No description provided for @registrationCodeHelper. + /// + /// In en, this message translates to: + /// **'Find this code on the dashboard under devices.'** + String get registrationCodeHelper; + + /// No description provided for @venueNowLinked. + /// + /// In en, this message translates to: + /// **'This device is now linked to {venue}.'** + String venueNowLinked(String venue); + /// No description provided for @scanToPay. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 48d35e1..1150cdd 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -977,6 +977,34 @@ class AppLocalizationsEn extends AppLocalizations { @override String get done => 'Done'; + @override + String get stepVenue => 'Venue'; + + @override + String get stepDevice => 'Device'; + + @override + String get stepLinked => 'Linked'; + + @override + String get noVenuesFound => + 'No venues found. Create one on the dashboard first.'; + + @override + String get changeVenue => 'Change'; + + @override + String get deviceNameHint => 'e.g. Bar terminal 1'; + + @override + String get registrationCodeHelper => + 'Find this code on the dashboard under devices.'; + + @override + String venueNowLinked(String venue) { + return 'This device is now linked to $venue.'; + } + @override String scanToPay(String amount) { return 'Let the customer scan this code to pay $amount'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 1896988..393d437 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -990,6 +990,34 @@ class AppLocalizationsNl extends AppLocalizations { @override String get done => 'Klaar'; + @override + String get stepVenue => 'Locatie'; + + @override + String get stepDevice => 'Toestel'; + + @override + String get stepLinked => 'Gekoppeld'; + + @override + String get noVenuesFound => + 'Geen locaties gevonden. Maak er eerst een aan op het dashboard.'; + + @override + String get changeVenue => 'Wijzigen'; + + @override + String get deviceNameHint => 'bv. Bartoestel 1'; + + @override + String get registrationCodeHelper => + 'Je vindt deze code op het dashboard bij toestellen.'; + + @override + String venueNowLinked(String venue) { + return 'Dit toestel is nu gekoppeld aan $venue.'; + } + @override String scanToPay(String amount) { return 'Laat de klant deze code scannen om $amount te betalen'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 1d8b6b2..a0d6b16 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -318,5 +318,13 @@ "deviceRegistered": "Toestel geregistreerd!", "openTabsWillSync": "Open poefs worden gesynchroniseerd met het dashboard.", "done": "Klaar", + "stepVenue": "Locatie", + "stepDevice": "Toestel", + "stepLinked": "Gekoppeld", + "noVenuesFound": "Geen locaties gevonden. Maak er eerst een aan op het dashboard.", + "changeVenue": "Wijzigen", + "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" } diff --git a/lib/viewmodels/registration_view_model.dart b/lib/viewmodels/registration_view_model.dart index 5c84856..2715ef1 100644 --- a/lib/viewmodels/registration_view_model.dart +++ b/lib/viewmodels/registration_view_model.dart @@ -34,11 +34,19 @@ class RegistrationViewModel extends ChangeNotifier { void selectVenue(Venue venue) { _selectedVenue = venue; + _errorMessage = null; + notifyListeners(); + } + + void deselectVenue() { + _selectedVenue = null; + _errorMessage = null; notifyListeners(); } Future fetchVenues() async { _step = RegistrationStep.loadingVenues; + _selectedVenue = null; _errorMessage = null; notifyListeners(); @@ -96,12 +104,4 @@ class RegistrationViewModel extends ChangeNotifier { } Future retry() => fetchVenues(); - - void reset() { - _step = RegistrationStep.initial; - _venues = []; - _selectedVenue = null; - _errorMessage = null; - notifyListeners(); - } } diff --git a/lib/views/pin_lock_view.dart b/lib/views/pin_lock_view.dart index abd198d..92182c9 100644 --- a/lib/views/pin_lock_view.dart +++ b/lib/views/pin_lock_view.dart @@ -183,7 +183,7 @@ class _PinEntryViewState extends State { return Scaffold( body: SafeArea( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 0), child: Column( children: [ Align( diff --git a/lib/views/registration_view.dart b/lib/views/registration_view.dart index dd0f1ee..3be1d99 100644 --- a/lib/views/registration_view.dart +++ b/lib/views/registration_view.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../l10n/app_localizations.dart'; @@ -6,6 +9,8 @@ import '../utils/navigation.dart'; import '../viewmodels/registration_view_model.dart'; import '../viewmodels/remote_sync_view_model.dart'; +const _codeLength = 6; + class RegistrationView extends StatefulWidget { const RegistrationView({super.key}); @@ -26,8 +31,6 @@ class _RegistrationViewState extends State { }); } - AppLocalizations get _l10n => AppLocalizations.of(context); - @override void dispose() { _deviceNameController.dispose(); @@ -35,6 +38,23 @@ class _RegistrationViewState extends State { super.dispose(); } + Future _register(RegistrationViewModel vm) async { + if (!_formKey.currentState!.validate()) return; + FocusScope.of(context).unfocus(); + + await vm.registerDevice( + deviceName: _deviceNameController.text.trim(), + registrationCode: _regCodeController.text.trim().toUpperCase(), + ); + } + + void _finish() { + // The redirect away from /register only sees the base location of a + // pushed route, so leave the screen ourselves. + unawaited(context.read().activate()); + context.popOrGo('/admin'); + } + @override Widget build(BuildContext context) { final vm = context.watch(); @@ -44,11 +64,17 @@ class _RegistrationViewState extends State { backgroundColor: Theme.of(context).colorScheme.surface, appBar: AppBar( backgroundColor: Colors.transparent, - leading: IconButton( - tooltip: l10n.cancel, - onPressed: () => context.popOrGo('/admin'), - icon: const Icon(Icons.close_rounded), - ), + title: Text(l10n.connectToRemoteServer), + leading: vm.step == RegistrationStep.registered + ? null + : IconButton( + tooltip: l10n.cancel, + onPressed: vm.step == RegistrationStep.registering + ? null + : () => context.popOrGo('/admin'), + icon: const Icon(Icons.close_rounded), + ), + automaticallyImplyLeading: false, ), body: SafeArea( child: Center( @@ -56,7 +82,15 @@ class _RegistrationViewState extends State { padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), - child: _buildStep(vm, l10n), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _StepIndicator(current: _stepIndex(vm)), + const SizedBox(height: 32), + _buildStep(vm, l10n), + ], + ), ), ), ), @@ -64,333 +98,422 @@ class _RegistrationViewState extends State { ); } + int _stepIndex(RegistrationViewModel vm) { + if (vm.step == RegistrationStep.registered) return 2; + return vm.selectedVenue == null ? 0 : 1; + } + Widget _buildStep(RegistrationViewModel vm, AppLocalizations l10n) { switch (vm.step) { + case RegistrationStep.initial: case RegistrationStep.loadingVenues: - return _buildLoading(); + return _buildLoading(l10n); case RegistrationStep.error: - return _buildError(vm); + return _buildError(vm, l10n); case RegistrationStep.venuesLoaded: + case RegistrationStep.registering: if (vm.selectedVenue == null) { return _buildVenueSelection(vm, l10n); } return _buildDeviceDetails(vm, l10n); - case RegistrationStep.registering: - return _buildRegistering(); case RegistrationStep.registered: return _buildSuccess(vm, l10n); - case RegistrationStep.initial: - return _buildLoading(); } } - Widget _buildLoading() { + Widget _buildLoading(AppLocalizations l10n) { return Column( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(height: 60), - Icon( - Icons.cloud_outlined, - size: 64, - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), - ), - const SizedBox(height: 24), + const SizedBox(height: 40), const CircularProgressIndicator(strokeWidth: 3), const SizedBox(height: 16), Text( - _l10n.connectingToServer, + l10n.connectingToServer, style: Theme.of(context).textTheme.titleMedium, ), ], ); } - Widget _buildError(RegistrationViewModel vm) { + Widget _buildError(RegistrationViewModel vm, AppLocalizations l10n) { + final scheme = Theme.of(context).colorScheme; + return Column( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(height: 60), - Icon( - Icons.cloud_off_rounded, - size: 64, - color: Theme.of(context).colorScheme.error, - ), - const SizedBox(height: 24), + Icon(Icons.cloud_off_rounded, size: 56, color: scheme.error), + const SizedBox(height: 16), Text( - _l10n.failedToConnect, + l10n.failedToConnect, style: Theme.of(context).textTheme.titleLarge, ), - const SizedBox(height: 12), + const SizedBox(height: 8), Text( - vm.errorMessage ?? _l10n.unknownError, + vm.errorMessage ?? l10n.unknownError, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), + color: scheme.onSurface.withValues(alpha: 0.6), ), ), const SizedBox(height: 24), FilledButton.icon( - onPressed: () => vm.retry(), + onPressed: vm.retry, icon: const Icon(Icons.refresh), - label: Text(_l10n.retry), + label: Text(l10n.retry), ), ], ); } Widget _buildVenueSelection(RegistrationViewModel vm, AppLocalizations l10n) { + final scheme = Theme.of(context).colorScheme; + return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: 40), - Icon( - Icons.cloud_outlined, - size: 48, - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), - ), - const SizedBox(height: 16), Text( - _l10n.connectToRemoteServer, - textAlign: TextAlign.center, + l10n.selectVenue, style: Theme.of(context).textTheme.headlineSmall, ), - const SizedBox(height: 8), + const SizedBox(height: 4), Text( - _l10n.selectVenueToLink, - textAlign: TextAlign.center, + l10n.selectVenueToLink, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - const SizedBox(height: 32), - Text( - _l10n.selectVenue, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.15), - ), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - children: [ - for (final venue in vm.venues) - Material( - color: Colors.transparent, - child: InkWell( - onTap: () => vm.selectVenue(venue), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - child: Row( - children: [ - Icon( - Icons.store_outlined, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - venue.name, - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - Icon( - Icons.chevron_right_rounded, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.3), - ), - ], - ), - ), - ), - ), - ], + color: scheme.onSurface.withValues(alpha: 0.6), ), ), const SizedBox(height: 24), - TextButton( - onPressed: () => vm.retry(), - child: Text(_l10n.refreshVenues), + if (vm.venues.isEmpty) + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Icon( + Icons.storefront_outlined, + size: 40, + color: scheme.onSurface.withValues(alpha: 0.4), + ), + const SizedBox(height: 12), + Text( + l10n.noVenuesFound, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ) + else + for (final venue in vm.venues) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Material( + color: scheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => vm.selectVenue(venue), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), + child: Row( + children: [ + CircleAvatar( + backgroundColor: scheme.primaryContainer, + foregroundColor: scheme.onPrimaryContainer, + child: const Icon(Icons.storefront_outlined), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + venue.name, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Icon( + Icons.chevron_right_rounded, + color: scheme.onSurface.withValues(alpha: 0.4), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 16), + Center( + child: TextButton.icon( + onPressed: vm.retry, + icon: const Icon(Icons.refresh, size: 18), + label: Text(l10n.refreshVenues), + ), ), ], ); } Widget _buildDeviceDetails(RegistrationViewModel vm, AppLocalizations l10n) { + final scheme = Theme.of(context).colorScheme; + final isBusy = vm.step == RegistrationStep.registering; + return Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: 40), - Icon( - Icons.store_outlined, - size: 48, - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + Container( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + decoration: BoxDecoration( + color: scheme.primaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Icon(Icons.storefront_outlined, color: scheme.primary), + const SizedBox(width: 12), + Expanded( + child: Text( + vm.selectedVenue!.name, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + TextButton( + onPressed: isBusy ? null : vm.deselectVenue, + child: Text(l10n.changeVenue), + ), + ], + ), + ), + const SizedBox(height: 24), + Text( + l10n.enterDeviceDetails, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: scheme.onSurface.withValues(alpha: 0.6), + ), ), const SizedBox(height: 16), - Text( - vm.selectedVenue!.name, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 8), - Text( - _l10n.enterDeviceDetails, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - const SizedBox(height: 32), TextFormField( controller: _deviceNameController, + enabled: !isBusy, + textInputAction: TextInputAction.next, decoration: InputDecoration( - labelText: _l10n.deviceName, - hintText: 'e.g. Bar terminal 1', - border: OutlineInputBorder(), + labelText: l10n.deviceName, + hintText: l10n.deviceNameHint, + prefixIcon: const Icon(Icons.tablet_android_outlined), + border: const OutlineInputBorder(), ), validator: (v) => - v == null || v.trim().isEmpty ? _l10n.deviceNameRequired : null, + v == null || v.trim().isEmpty ? l10n.deviceNameRequired : null, ), const SizedBox(height: 16), TextFormField( controller: _regCodeController, - decoration: InputDecoration( - labelText: _l10n.registrationCode, - hintText: '6-character code from dashboard', - border: OutlineInputBorder(), - counterText: '', - ), - maxLength: 6, + enabled: !isBusy, + autofocus: true, + maxLength: _codeLength, + textAlign: TextAlign.center, textCapitalization: TextCapitalization.characters, - validator: (v) => v == null || v.trim().length < 6 - ? _l10n.enterRegistrationCode + textInputAction: TextInputAction.done, + autocorrect: false, + enableSuggestions: false, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp('[a-zA-Z0-9]')), + _UpperCaseFormatter(), + ], + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + letterSpacing: 8, + fontFamily: 'monospace', + ), + decoration: InputDecoration( + labelText: l10n.registrationCode, + hintText: '•' * _codeLength, + helperText: l10n.registrationCodeHelper, + border: const OutlineInputBorder(), + ), + onFieldSubmitted: (_) => _register(vm), + validator: (v) => v == null || v.trim().length < _codeLength + ? l10n.enterRegistrationCode : null, ), - const SizedBox(height: 8), - if (vm.errorMessage != null) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - vm.errorMessage!, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - fontSize: 13, - ), + if (vm.errorMessage != null) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: scheme.onErrorContainer), + const SizedBox(width: 12), + Expanded( + child: Text( + vm.errorMessage!, + style: TextStyle(color: scheme.onErrorContainer), + ), + ), + ], ), ), - const SizedBox(height: 16), - Row( - children: [ - TextButton( - onPressed: () { - vm.reset(); - }, - child: Text(_l10n.back), - ), - const SizedBox(width: 12), - Expanded( - child: FilledButton( - onPressed: () async { - if (!_formKey.currentState!.validate()) return; - await vm.registerDevice( - deviceName: _deviceNameController.text.trim(), - registrationCode: _regCodeController.text - .trim() - .toUpperCase(), - ); - }, - child: Text(_l10n.register), - ), - ), - ], + ], + const SizedBox(height: 24), + FilledButton( + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + ), + onPressed: isBusy ? null : () => _register(vm), + child: isBusy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Text(l10n.registeringDevice), + ], + ) + : Text(l10n.register), ), ], ), ); } - Widget _buildRegistering() { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 60), - const CircularProgressIndicator(strokeWidth: 3), - const SizedBox(height: 24), - Text( - _l10n.registeringDevice, - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ); - } - Widget _buildSuccess(RegistrationViewModel vm, AppLocalizations l10n) { + final scheme = Theme.of(context).colorScheme; + return Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: 40), - Icon( - Icons.check_circle_rounded, - size: 80, - color: Theme.of(context).colorScheme.primary, + Center( + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: scheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check_rounded, + size: 56, + color: scheme.onPrimaryContainer, + ), + ), ), const SizedBox(height: 24), Text( - _l10n.deviceRegistered, + l10n.deviceRegistered, textAlign: TextAlign.center, style: Theme.of(context).textTheme.headlineSmall, ), - const SizedBox(height: 12), - Text( - '${vm.selectedVenue?.name ?? "Venue"} is now linked.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), const SizedBox(height: 8), Text( - _l10n.openTabsWillSync, + l10n.venueNowLinked(vm.selectedVenue?.name ?? ''), textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 4), + Text( + l10n.openTabsWillSync, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: scheme.onSurface.withValues(alpha: 0.6), ), ), const SizedBox(height: 32), FilledButton.icon( - // Once configured, the router redirects to /bar by itself. - onPressed: () => context.read().activate(), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + ), + onPressed: _finish, icon: const Icon(Icons.arrow_forward), - label: Text(_l10n.done), + label: Text(l10n.done), ), ], ); } } + +class _StepIndicator extends StatelessWidget { + final int current; + + const _StepIndicator({required this.current}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final scheme = Theme.of(context).colorScheme; + final labels = [l10n.stepVenue, l10n.stepDevice, l10n.stepLinked]; + + return Row( + children: [ + for (var i = 0; i < labels.length; i++) ...[ + if (i > 0) + Expanded( + child: Container( + height: 2, + margin: const EdgeInsets.only(bottom: 20), + color: i <= current + ? scheme.primary + : scheme.onSurface.withValues(alpha: 0.15), + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 14, + backgroundColor: i <= current + ? scheme.primary + : scheme.onSurface.withValues(alpha: 0.1), + foregroundColor: i <= current + ? scheme.onPrimary + : scheme.onSurface.withValues(alpha: 0.6), + child: i < current + ? const Icon(Icons.check_rounded, size: 16) + : Text( + '${i + 1}', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 4), + Text( + labels[i], + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: i <= current + ? scheme.onSurface + : scheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), + ], + ], + ); + } +} + +class _UpperCaseFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + return newValue.copyWith(text: newValue.text.toUpperCase()); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 3fc6717..2fd4b06 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.8+0 +version: 1.3.9+0 environment: sdk: ^3.12.2