feat: add alot
This commit is contained in:
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/remote_command.dart';
|
||||
import '../utils/navigation.dart';
|
||||
import '../viewmodels/admin_pin_view_model.dart';
|
||||
import '../viewmodels/remote_sync_view_model.dart';
|
||||
@@ -41,7 +42,7 @@ class AdminScreenView extends StatelessWidget {
|
||||
);
|
||||
|
||||
if (confirmed == true && context.mounted) {
|
||||
context.read<RemoteSyncViewModel>().unlink();
|
||||
await context.read<RemoteSyncViewModel>().unlink();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,8 +136,13 @@ class AdminScreenView extends StatelessWidget {
|
||||
subtitle: remoteSync.isSyncing
|
||||
? l10n.syncing
|
||||
: remoteSync.lastSyncAt != null
|
||||
? l10n.lastSync(remoteSync.lastSyncAt!.toLocal().toString().substring(0, 19))
|
||||
: l10n.notSyncedYet,
|
||||
? l10n.lastSync(
|
||||
remoteSync.lastSyncAt!
|
||||
.toLocal()
|
||||
.toString()
|
||||
.substring(0, 19),
|
||||
)
|
||||
: l10n.notSyncedYet,
|
||||
trailing: remoteSync.isSyncing
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
@@ -154,20 +160,18 @@ class AdminScreenView extends StatelessWidget {
|
||||
children: remoteSync.activity.map((entry) {
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: Icon(
|
||||
entry.result == 'completed'
|
||||
? Icons.check_circle_outline
|
||||
: entry.result == 'conflict'
|
||||
? Icons.warning_amber_outlined
|
||||
: Icons.error_outline,
|
||||
size: 20,
|
||||
),
|
||||
leading: Icon(switch (entry.result) {
|
||||
CommandResult.completed => Icons.check_circle_outline,
|
||||
CommandResult.conflict => Icons.warning_amber_outlined,
|
||||
CommandResult.failed ||
|
||||
CommandResult.expired => Icons.error_outline,
|
||||
}, size: 20),
|
||||
title: Text(
|
||||
'${entry.commandType} (${entry.commandId.substring(0, 8)}...)',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${entry.processedAt.toLocal()} - ${entry.result}',
|
||||
'${entry.processedAt.toLocal()} - ${entry.result.name}',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
);
|
||||
@@ -277,10 +281,7 @@ class _AdminTile extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
trailing!,
|
||||
],
|
||||
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
|
||||
if (onTap != null)
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
@@ -292,4 +293,4 @@ class _AdminTile extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../l10n/app_localizations_helpers.dart';
|
||||
import '../../models/product.dart';
|
||||
import '../../models/remote_command.dart';
|
||||
import '../../services/product_service.dart';
|
||||
import '../../utils/app_updater.dart';
|
||||
import '../../viewmodels/admin_pin_view_model.dart';
|
||||
import '../../viewmodels/bar_screen_view_model.dart';
|
||||
import '../../viewmodels/pin_lock_view_model.dart';
|
||||
import '../../viewmodels/product_list_view_model.dart';
|
||||
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 '../dialogs/new_tab_dialog.dart';
|
||||
import 'product_grid.dart';
|
||||
import 'tab_panel.dart';
|
||||
|
||||
class BarScreenView extends StatefulWidget {
|
||||
const BarScreenView({super.key});
|
||||
|
||||
@override
|
||||
State<BarScreenView> createState() => _BarScreenViewState();
|
||||
}
|
||||
|
||||
class _BarScreenViewState extends State<BarScreenView> {
|
||||
late final StreamSubscription<RemoteSyncEvent> _remoteCloseSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_remoteCloseSubscription = context
|
||||
.read<RemoteSyncViewModel>()
|
||||
.tabsClosedRemotely
|
||||
.listen(_showRemoteClose);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
UpdateChecker.check(context);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_remoteCloseSubscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showRemoteClose(RemoteSyncEvent event) {
|
||||
if (!mounted) return;
|
||||
|
||||
final l10n = AppLocalizations.of(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
l10n.tabClosedRemotely(
|
||||
event.tabCustomerName ?? '',
|
||||
event.requestedBy ?? '',
|
||||
),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addProduct(Product product) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final viewModel = context.read<BarScreenViewModel>();
|
||||
|
||||
if (viewModel.selectedTab == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.openOrSelectTab)));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await viewModel.addProductToSelectedTab(product);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
e is InsufficientStockException
|
||||
? l10n.productOutOfStock
|
||||
: l10n.couldNotAddProductToTab,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _logout() {
|
||||
context.read<PinLockViewModel>().lock();
|
||||
context.read<AdminPinViewModel>().lock();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final viewModel = context.watch<BarScreenViewModel>();
|
||||
final productsViewModel = context.watch<ProductListViewModel>();
|
||||
final settings = context.watch<SettingsViewModel>().settings;
|
||||
final stockByProductId = {
|
||||
for (final product in productsViewModel.products)
|
||||
product.id: product.stockQuantity,
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.barTabs),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: l10n.admin,
|
||||
onPressed: () => context.push('/admin'),
|
||||
icon: const Icon(Icons.admin_panel_settings_outlined),
|
||||
),
|
||||
if (kDebugMode) ...[
|
||||
const SizedBox(width: 6),
|
||||
_DebugShortcut(
|
||||
tooltip: l10n.manageProducts,
|
||||
icon: Icons.inventory_2_outlined,
|
||||
location: '/products',
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_DebugShortcut(
|
||||
tooltip: l10n.tabHistory,
|
||||
icon: Icons.history_rounded,
|
||||
location: '/history',
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: l10n.refresh,
|
||||
onPressed: () => context.read<RemoteSyncViewModel>().syncNow(),
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: l10n.settings,
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: const Icon(Icons.settings),
|
||||
),
|
||||
if (context.watch<PinLockViewModel>().isPinSet) ...[
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: l10n.logout,
|
||||
onPressed: _logout,
|
||||
icon: const Icon(Icons.logout),
|
||||
),
|
||||
],
|
||||
],
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
if (viewModel.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
);
|
||||
}
|
||||
|
||||
if (viewModel.loadError != null) {
|
||||
return _LoadError(message: l10n.errorText(viewModel.loadError));
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: ProductGrid(
|
||||
products: productsViewModel.products,
|
||||
rows: settings.barGridRows,
|
||||
hasSelectedTab: viewModel.selectedTab != null,
|
||||
onProductTap: _addProduct,
|
||||
),
|
||||
),
|
||||
Container(width: 1, color: Theme.of(context).dividerColor),
|
||||
Expanded(
|
||||
child: TabPanel(
|
||||
tabs: viewModel.tabs,
|
||||
selectedTab: viewModel.selectedTab,
|
||||
selectedTabId: viewModel.selectedTabId,
|
||||
stockByProductId: stockByProductId,
|
||||
onNewTabPressed: () => showNewTabDialog(context),
|
||||
onTabSelected: viewModel.selectTab,
|
||||
onItemQuantityChanged: viewModel.changeItemQuantity,
|
||||
onCloseTabPressed: () => confirmCloseTab(context),
|
||||
onTabEdited: (tab) => showEditTabDialog(context, tab),
|
||||
onTabDeleted: (tab) => confirmDeleteTab(context, tab),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DebugShortcut extends StatelessWidget {
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final String location;
|
||||
|
||||
const _DebugShortcut({
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.location,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
tooltip: tooltip,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Colors.yellow,
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
onPressed: () => context.push(location),
|
||||
icon: Icon(icon),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadError extends StatelessWidget {
|
||||
final String message;
|
||||
|
||||
const _LoadError({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline_rounded,
|
||||
size: 40,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/product.dart';
|
||||
import '../widgets/product_tile.dart';
|
||||
|
||||
class ProductGrid extends StatelessWidget {
|
||||
final List<Product> products;
|
||||
final int rows;
|
||||
final bool hasSelectedTab;
|
||||
final ValueChanged<Product> onProductTap;
|
||||
|
||||
const ProductGrid({
|
||||
super.key,
|
||||
required this.products,
|
||||
required this.rows,
|
||||
required this.hasSelectedTab,
|
||||
required this.onProductTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
if (products.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: scheme.onSurface.withValues(alpha: 0.05),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 40,
|
||||
color: scheme.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.noProductsYet,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.addFirstProduct,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.push('/admin'),
|
||||
icon: const Icon(Icons.admin_panel_settings_outlined),
|
||||
label: Text(l10n.admin),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.products,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
if (!hasSelectedTab)
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.onSurface.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline_rounded,
|
||||
size: 14,
|
||||
color: scheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.selectTabToAddItems,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const horizontalPadding = 32.0;
|
||||
const verticalPadding = 28.0;
|
||||
const spacing = 12.0;
|
||||
final rowCount = rows.clamp(2, 6).toInt();
|
||||
final rowHeight =
|
||||
((constraints.maxHeight -
|
||||
verticalPadding -
|
||||
(rowCount - 1) * spacing) /
|
||||
rowCount)
|
||||
.clamp(96.0, 320.0)
|
||||
.toDouble();
|
||||
final columns =
|
||||
(((constraints.maxWidth - horizontalPadding + spacing) /
|
||||
(rowHeight + spacing))
|
||||
.ceil())
|
||||
.clamp(2, 8)
|
||||
.toInt();
|
||||
|
||||
return GridView.builder(
|
||||
key: ValueKey('product-grid-$rows'),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
itemCount: products.length,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columns,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final product = products[index];
|
||||
|
||||
return ProductTile(
|
||||
product: product,
|
||||
enabled: hasSelectedTab,
|
||||
onTap: () => onProductTap(product),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/bar_tab.dart';
|
||||
import '../../models/tab_item.dart';
|
||||
import '../../services/product_service.dart';
|
||||
import '../../utils/money.dart';
|
||||
|
||||
class SelectedTabDetails extends StatelessWidget {
|
||||
final BarTab tab;
|
||||
final Map<String, int> stockByProductId;
|
||||
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
|
||||
final VoidCallback onCloseTabPressed;
|
||||
|
||||
const SelectedTabDetails({
|
||||
super.key,
|
||||
required this.tab,
|
||||
required this.stockByProductId,
|
||||
required this.onItemQuantityChanged,
|
||||
required this.onCloseTabPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
tab.customerName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: tab.items.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.local_bar_outlined,
|
||||
size: 32,
|
||||
color: scheme.onSurface.withValues(alpha: 0.25),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.tapProductsToAdd,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: tab.items.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final item = tab.items[index];
|
||||
|
||||
return TabItemRow(
|
||||
item: item,
|
||||
stockByProductId: stockByProductId,
|
||||
onQuantityChanged: onItemQuantityChanged,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.total,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
tab.totalInCents.asEuro,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineMedium?.copyWith(fontSize: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: tab.items.isEmpty ? null : onCloseTabPressed,
|
||||
child: Text(l10n.closeTab),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TabItemRow extends StatelessWidget {
|
||||
final TabItem item;
|
||||
final Map<String, int> stockByProductId;
|
||||
final Future<void> Function(TabItem item, int delta) onQuantityChanged;
|
||||
|
||||
const TabItemRow({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.stockByProductId,
|
||||
required this.onQuantityChanged,
|
||||
});
|
||||
|
||||
Future<void> _changeQuantity(BuildContext context, int delta) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
try {
|
||||
await onQuantityChanged(item, delta);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
e is InsufficientStockException
|
||||
? l10n.productOutOfStock
|
||||
: l10n.couldNotUpdateQuantity,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final availableStock = stockByProductId[item.productId];
|
||||
final canIncrease = availableStock == null || availableStock > 0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.productName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${item.quantity} × ${item.unitPriceInCents.asEuro}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.onSurface.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => _changeQuantity(context, -1),
|
||||
icon: const Icon(Icons.remove_rounded, size: 18),
|
||||
),
|
||||
SizedBox(
|
||||
width: 22,
|
||||
child: Text(
|
||||
'${item.quantity}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: canIncrease
|
||||
? () => _changeQuantity(context, 1)
|
||||
: null,
|
||||
icon: const Icon(Icons.add_rounded, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(
|
||||
item.lineTotalInCents.asEuro,
|
||||
textAlign: TextAlign.end,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/bar_tab.dart';
|
||||
import '../../models/tab_item.dart';
|
||||
import '../../utils/money.dart';
|
||||
import 'selected_tab_details.dart';
|
||||
|
||||
class TabPanel extends StatefulWidget {
|
||||
final List<BarTab> tabs;
|
||||
final BarTab? selectedTab;
|
||||
final String? selectedTabId;
|
||||
final Map<String, int> stockByProductId;
|
||||
final VoidCallback onNewTabPressed;
|
||||
final ValueChanged<String> onTabSelected;
|
||||
final Future<void> Function(TabItem item, int delta) 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.stockByProductId,
|
||||
required this.onNewTabPressed,
|
||||
required this.onTabSelected,
|
||||
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();
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@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();
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(color: scheme.surfaceContainerLow),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
},
|
||||
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(width: 10),
|
||||
|
||||
IconButton.filled(
|
||||
onPressed: widget.onNewTabPressed,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
tooltip: l10n.openNewTab,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
SizedBox(
|
||||
height: 190,
|
||||
child: OpenTabsList(
|
||||
tabs: filteredTabs,
|
||||
selectedTabId: widget.selectedTabId,
|
||||
onTabSelected: widget.onTabSelected,
|
||||
onTabEdited: widget.onTabEdited,
|
||||
onTabDeleted: widget.onTabDeleted,
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 28),
|
||||
|
||||
Expanded(
|
||||
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!,
|
||||
stockByProductId: widget.stockByProductId,
|
||||
onItemQuantityChanged: widget.onItemQuantityChanged,
|
||||
onCloseTabPressed: widget.onCloseTabPressed,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OpenTabsList extends StatelessWidget {
|
||||
final List<BarTab> tabs;
|
||||
final String? selectedTabId;
|
||||
final ValueChanged<String> onTabSelected;
|
||||
final Future<void> Function(BarTab) onTabEdited;
|
||||
final Future<void> Function(BarTab) onTabDeleted;
|
||||
|
||||
const OpenTabsList({
|
||||
super.key,
|
||||
required this.tabs,
|
||||
required this.selectedTabId,
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: tabs.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final tab = tabs[index];
|
||||
final selected = tab.id == selectedTabId;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final primary = scheme.primary;
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: 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: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? primary.withValues(alpha: 0.14)
|
||||
: scheme.onSurface.withValues(alpha: 0.04),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
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: BorderRadius.circular(14),
|
||||
onTap: () => onTabSelected(tab.id),
|
||||
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(
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,884 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:kooltab2/viewmodels/admin_pin_view_model.dart';
|
||||
import 'package:kooltab2/viewmodels/pin_lock_view_model.dart';
|
||||
import 'package:kooltab2/viewmodels/product_list_view_model.dart';
|
||||
import 'package:kooltab2/views/dialogs/close_tab_dialog.dart';
|
||||
import 'package:kooltab2/views/dialogs/edit_tab_dialog.dart';
|
||||
import 'package:kooltab2/views/dialogs/new_tab_dialog.dart';
|
||||
import 'package:kooltab2/views/widgets/product_tile.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
|
||||
import '../models/bar_tab.dart';
|
||||
import '../models/product.dart';
|
||||
import '../models/tab_item.dart';
|
||||
import '../models/remote_command.dart';
|
||||
import '../services/product_service.dart';
|
||||
import '../utils/app_updater.dart';
|
||||
import '../viewmodels/bar_screen_view_model.dart';
|
||||
import '../viewmodels/settings_view_model.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/app_localizations_helpers.dart';
|
||||
|
||||
class BarScreenView extends StatefulWidget {
|
||||
const BarScreenView({super.key});
|
||||
|
||||
@override
|
||||
State<BarScreenView> createState() => _BarScreenViewState();
|
||||
}
|
||||
|
||||
class _BarScreenViewState extends State<BarScreenView> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
UpdateChecker.check(context);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_checkRemoteEvent();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant BarScreenView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_checkRemoteEvent();
|
||||
}
|
||||
|
||||
void _checkRemoteEvent() {
|
||||
final viewModel = context.read<BarScreenViewModel>();
|
||||
final event = viewModel.lastRemoteEvent;
|
||||
if (event == null) return;
|
||||
|
||||
viewModel.clearRemoteEvent();
|
||||
|
||||
if (event.type == RemoteSyncEventType.tabClosedRemotely) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final message = l10n.tabClosedRemotely(
|
||||
event.tabCustomerName ?? '',
|
||||
event.requestedBy ?? '',
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final viewModel = context.watch<BarScreenViewModel>();
|
||||
final productsViewModel = context.watch<ProductListViewModel>();
|
||||
final settings = context.watch<SettingsViewModel>().settings;
|
||||
final stockByProductId = {
|
||||
for (final product in productsViewModel.products)
|
||||
product.id: product.stockQuantity,
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.barTabs),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: l10n.admin,
|
||||
onPressed: () => context.push('/admin'),
|
||||
icon: const Icon(Icons.admin_panel_settings_outlined),
|
||||
),
|
||||
if (kDebugMode) ...[
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: l10n.manageProducts,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Colors.yellow,
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
onPressed: () => context.push('/products'),
|
||||
icon: const Icon(Icons.inventory_2_outlined),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: l10n.tabHistory,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Colors.yellow,
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
onPressed: () => context.push('/history'),
|
||||
icon: const Icon(Icons.history_rounded),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: l10n.refresh,
|
||||
onPressed: () => viewModel.loadAndSync(),
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: l10n.settings,
|
||||
onPressed: () => context.push('/settings'),
|
||||
icon: const Icon(Icons.settings),
|
||||
),
|
||||
if (context.watch<PinLockViewModel>().isPinSet) ...[
|
||||
const SizedBox(width: 6),
|
||||
IconButton(
|
||||
tooltip: l10n.logout,
|
||||
onPressed: () {
|
||||
Provider.of<PinLockViewModel>(context, listen: false).lock();
|
||||
Provider.of<AdminPinViewModel>(context, listen: false).lock();
|
||||
},
|
||||
icon: const Icon(Icons.logout),
|
||||
),
|
||||
],
|
||||
],
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
if (viewModel.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
);
|
||||
}
|
||||
|
||||
if (viewModel.errorMessage != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline_rounded,
|
||||
size: 40,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.localizedError(viewModel.errorMessage),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _ProductGrid(
|
||||
products: productsViewModel.products,
|
||||
rows: settings.barGridRows,
|
||||
hasSelectedTab: viewModel.selectedTab != null,
|
||||
onProductTap: (product) async {
|
||||
if (viewModel.selectedTab == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.openOrSelectTab)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await viewModel.addProductToSelectedTab(product);
|
||||
} catch (e, stack) {
|
||||
if (e is! InsufficientStockException) {
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
e is InsufficientStockException
|
||||
? l10n.productOutOfStock
|
||||
: l10n.couldNotAddProductToTab,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
Container(width: 1, color: Theme.of(context).dividerColor),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: _TabPanel(
|
||||
tabs: viewModel.tabs,
|
||||
selectedTab: viewModel.selectedTab,
|
||||
selectedTabId: viewModel.selectedTabId,
|
||||
stockByProductId: stockByProductId,
|
||||
onNewTabPressed: () => showNewTabDialog(context),
|
||||
onTabSelected: viewModel.selectTab,
|
||||
onItemQuantityChanged: viewModel.changeItemQuantity,
|
||||
onCloseTabPressed: () => confirmCloseTab(context),
|
||||
onTabEdited: (tab) => showEditTabDialog(context, tab),
|
||||
onTabDeleted: (tab) => confirmDeleteTab(context, tab),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProductGrid extends StatelessWidget {
|
||||
final List<Product> products;
|
||||
final int rows;
|
||||
final bool hasSelectedTab;
|
||||
final ValueChanged<Product> onProductTap;
|
||||
|
||||
const _ProductGrid({
|
||||
required this.products,
|
||||
required this.rows,
|
||||
required this.hasSelectedTab,
|
||||
required this.onProductTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
if (products.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: scheme.onSurface.withValues(alpha: 0.05),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 40,
|
||||
color: scheme.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.noProductsYet,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.addFirstProduct,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.push('/admin'),
|
||||
icon: const Icon(Icons.admin_panel_settings_outlined),
|
||||
label: Text(l10n.admin),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.products,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
if (!hasSelectedTab)
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.onSurface.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline_rounded,
|
||||
size: 14,
|
||||
color: scheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.selectTabToAddItems,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const horizontalPadding = 32.0;
|
||||
const verticalPadding = 28.0;
|
||||
const spacing = 12.0;
|
||||
final rowCount = rows.clamp(2, 6).toInt();
|
||||
final rowHeight =
|
||||
((constraints.maxHeight -
|
||||
verticalPadding -
|
||||
(rowCount - 1) * spacing) /
|
||||
rowCount)
|
||||
.clamp(96.0, 320.0)
|
||||
.toDouble();
|
||||
final columns =
|
||||
(((constraints.maxWidth - horizontalPadding + spacing) /
|
||||
(rowHeight + spacing))
|
||||
.ceil())
|
||||
.clamp(2, 8)
|
||||
.toInt();
|
||||
|
||||
return GridView.builder(
|
||||
key: ValueKey('product-grid-$rows'),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
itemCount: products.length,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columns,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final product = products[index];
|
||||
|
||||
return ProductTile(
|
||||
product: product,
|
||||
enabled: hasSelectedTab,
|
||||
onTap: () => onProductTap(product),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TabPanel extends StatefulWidget {
|
||||
final List<BarTab> tabs;
|
||||
final BarTab? selectedTab;
|
||||
final String? selectedTabId;
|
||||
final Map<String, int> stockByProductId;
|
||||
final VoidCallback onNewTabPressed;
|
||||
final ValueChanged<String> onTabSelected;
|
||||
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
|
||||
final VoidCallback onCloseTabPressed;
|
||||
final Future<void> Function(BarTab) onTabEdited;
|
||||
final Future<void> Function(BarTab) onTabDeleted;
|
||||
|
||||
const _TabPanel({
|
||||
required this.tabs,
|
||||
required this.selectedTab,
|
||||
required this.selectedTabId,
|
||||
required this.stockByProductId,
|
||||
required this.onNewTabPressed,
|
||||
required this.onTabSelected,
|
||||
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();
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@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();
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(color: scheme.surfaceContainerLow),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
},
|
||||
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(width: 10),
|
||||
|
||||
IconButton.filled(
|
||||
onPressed: widget.onNewTabPressed,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
tooltip: l10n.openNewTab,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
SizedBox(
|
||||
height: 190,
|
||||
child: _OpenTabsList(
|
||||
tabs: filteredTabs,
|
||||
selectedTabId: widget.selectedTabId,
|
||||
onTabSelected: widget.onTabSelected,
|
||||
onTabEdited: widget.onTabEdited,
|
||||
onTabDeleted: widget.onTabDeleted,
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 28),
|
||||
|
||||
Expanded(
|
||||
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!,
|
||||
stockByProductId: widget.stockByProductId,
|
||||
onItemQuantityChanged: widget.onItemQuantityChanged,
|
||||
onCloseTabPressed: widget.onCloseTabPressed,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OpenTabsList extends StatelessWidget {
|
||||
final List<BarTab> tabs;
|
||||
final String? selectedTabId;
|
||||
final ValueChanged<String> onTabSelected;
|
||||
final Future<void> Function(BarTab) onTabEdited;
|
||||
final Future<void> Function(BarTab) onTabDeleted;
|
||||
|
||||
const _OpenTabsList({
|
||||
required this.tabs,
|
||||
required this.selectedTabId,
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: tabs.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final tab = tabs[index];
|
||||
final selected = tab.id == selectedTabId;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final primary = scheme.primary;
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: 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: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? primary.withValues(alpha: 0.14)
|
||||
: scheme.onSurface.withValues(alpha: 0.04),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
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: BorderRadius.circular(14),
|
||||
onTap: () => onTabSelected(tab.id),
|
||||
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(
|
||||
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.formattedTotal,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectedTabDetails extends StatelessWidget {
|
||||
final BarTab tab;
|
||||
final Map<String, int> stockByProductId;
|
||||
final Future<void> Function(TabItem item, int delta) onItemQuantityChanged;
|
||||
final VoidCallback onCloseTabPressed;
|
||||
|
||||
const _SelectedTabDetails({
|
||||
required this.tab,
|
||||
required this.stockByProductId,
|
||||
required this.onItemQuantityChanged,
|
||||
required this.onCloseTabPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
tab.customerName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Divider(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: tab.items.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.local_bar_outlined,
|
||||
size: 32,
|
||||
color: scheme.onSurface.withValues(alpha: 0.25),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.tapProductsToAdd,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: tab.items.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final item = tab.items[index];
|
||||
|
||||
return _TabItemRow(
|
||||
item: item,
|
||||
stockByProductId: stockByProductId,
|
||||
onQuantityChanged: onItemQuantityChanged,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.total,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
tab.formattedTotal,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineMedium?.copyWith(fontSize: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: tab.items.isEmpty ? null : onCloseTabPressed,
|
||||
child: Text(l10n.closeTab),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TabItemRow extends StatelessWidget {
|
||||
final TabItem item;
|
||||
final Map<String, int> stockByProductId;
|
||||
final Future<void> Function(TabItem item, int delta) onQuantityChanged;
|
||||
|
||||
const _TabItemRow({
|
||||
required this.item,
|
||||
required this.stockByProductId,
|
||||
required this.onQuantityChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final availableStock = stockByProductId[item.productId];
|
||||
final canIncrease = availableStock == null || availableStock > 0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.productName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${item.quantity} × ${item.formattedUnitPrice}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.onSurface.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () async {
|
||||
try {
|
||||
await onQuantityChanged(item, -1);
|
||||
} catch (e, stack) {
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.couldNotUpdateQuantity)),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.remove_rounded, size: 18),
|
||||
),
|
||||
SizedBox(
|
||||
width: 22,
|
||||
child: Text(
|
||||
'${item.quantity}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: canIncrease
|
||||
? () async {
|
||||
try {
|
||||
await onQuantityChanged(item, 1);
|
||||
} catch (e, stack) {
|
||||
if (e is! InsufficientStockException) {
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
}
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
e is InsufficientStockException
|
||||
? l10n.productOutOfStock
|
||||
: l10n.couldNotUpdateQuantity,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.add_rounded, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(
|
||||
item.formattedLineTotal,
|
||||
textAlign: TextAlign.end,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/app_localizations_helpers.dart';
|
||||
import '../utils/app_update_util.dart';
|
||||
import '../utils/navigation.dart';
|
||||
import '../viewmodels/dev_menu_view_model.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/app_localizations_helpers.dart';
|
||||
|
||||
class DevMenuView extends StatefulWidget {
|
||||
const DevMenuView({super.key});
|
||||
@@ -68,7 +68,10 @@ class _DevMenuViewState extends State<DevMenuView> {
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
children: [
|
||||
const Text("Hallo Matteo", style: TextStyle(color: Colors.pinkAccent),),
|
||||
const Text(
|
||||
'Hallo Matteo',
|
||||
style: TextStyle(color: Colors.pinkAccent),
|
||||
),
|
||||
_Section(
|
||||
title: l10n.dataManagement,
|
||||
children: [
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/payment_method.dart';
|
||||
import '../../utils/money.dart';
|
||||
import '../../viewmodels/bar_screen_view_model.dart';
|
||||
import '../widgets/slide_confirm.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
Future<void> confirmCloseTab(BuildContext context) async {
|
||||
final viewModel = context.read<BarScreenViewModel>();
|
||||
@@ -29,7 +29,7 @@ Future<void> confirmCloseTab(BuildContext context) async {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(l10n.currentTotal(tab.formattedTotal)),
|
||||
Text(l10n.currentTotal(tab.totalInCents.asEuro)),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
l10n.paymentMethod,
|
||||
@@ -68,8 +68,7 @@ Future<void> confirmCloseTab(BuildContext context) async {
|
||||
|
||||
try {
|
||||
await viewModel.closeSelectedTab(paymentMethod: paymentMethod);
|
||||
} catch (e, stack) {
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
} catch (_) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/bar_tab.dart';
|
||||
@@ -18,8 +17,7 @@ Future<void> showEditTabDialog(BuildContext context, BarTab tab) async {
|
||||
|
||||
try {
|
||||
await context.read<BarScreenViewModel>().renameTab(tab.id, name.trim());
|
||||
} catch (e, stack) {
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
} catch (_) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
@@ -107,8 +105,7 @@ Future<void> confirmDeleteTab(BuildContext context, BarTab tab) async {
|
||||
|
||||
try {
|
||||
await context.read<BarScreenViewModel>().deleteTab(tab.id);
|
||||
} catch (e, stack) {
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
} catch (_) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:kooltab2/viewmodels/bar_screen_view_model.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../viewmodels/bar_screen_view_model.dart';
|
||||
|
||||
Future<void> showNewTabDialog(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
@@ -44,8 +44,7 @@ Future<void> showNewTabDialog(BuildContext context) async {
|
||||
|
||||
try {
|
||||
await context.read<BarScreenViewModel>().createTab(name.trim());
|
||||
} catch (e, stack) {
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
} catch (_) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:kooltab2/views/widgets/closed_tab_card.dart';
|
||||
import 'package:kooltab2/views/widgets/open_tab_history_card.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app/router.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/app_localizations_helpers.dart';
|
||||
import '../models/bar_tab.dart';
|
||||
import '../models/closed_tab.dart';
|
||||
import '../utils/navigation.dart';
|
||||
import '../viewmodels/history_view_model.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/app_localizations_helpers.dart';
|
||||
import 'widgets/closed_tab_card.dart';
|
||||
import 'widgets/open_tab_history_card.dart';
|
||||
|
||||
class HistoryScreenView extends StatefulWidget {
|
||||
const HistoryScreenView({super.key});
|
||||
@@ -87,7 +87,7 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
|
||||
);
|
||||
}
|
||||
|
||||
if (viewModel.errorMessage != null) {
|
||||
if (viewModel.error != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
@@ -101,7 +101,7 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.localizedError(viewModel.errorMessage),
|
||||
l10n.errorText(viewModel.error),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../viewmodels/pin_lock_view_model.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/app_localizations_helpers.dart';
|
||||
import '../models/app_error.dart';
|
||||
import '../viewmodels/pin_lock_view_model.dart';
|
||||
|
||||
enum PinEntryMode { unlock, create }
|
||||
|
||||
@@ -18,7 +19,7 @@ class PinEntryView extends StatefulWidget {
|
||||
final bool isAdmin;
|
||||
final Future<bool> Function(String)? onVerify;
|
||||
final Future<bool> Function(String)? onSet;
|
||||
final String? Function()? errorMessage;
|
||||
final AppError? Function()? error;
|
||||
final bool showBack;
|
||||
|
||||
const PinEntryView({
|
||||
@@ -28,7 +29,7 @@ class PinEntryView extends StatefulWidget {
|
||||
this.isAdmin = false,
|
||||
this.onVerify,
|
||||
this.onSet,
|
||||
this.errorMessage,
|
||||
this.error,
|
||||
this.showBack = false,
|
||||
});
|
||||
|
||||
@@ -123,7 +124,7 @@ class _PinEntryViewState extends State<PinEntryView> {
|
||||
_firstEntry = null;
|
||||
_isConfirmStep = false;
|
||||
});
|
||||
_fail(l10n.localizedError(_errorMessage()));
|
||||
_fail(l10n.errorText(_error()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -136,7 +137,7 @@ class _PinEntryViewState extends State<PinEntryView> {
|
||||
if (ok) {
|
||||
widget.onSuccess?.call();
|
||||
} else {
|
||||
_fail(l10n.localizedError(_errorMessage()));
|
||||
_fail(l10n.errorText(_error()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,11 +155,11 @@ class _PinEntryViewState extends State<PinEntryView> {
|
||||
return context.read<PinLockViewModel>().verify(pin);
|
||||
}
|
||||
|
||||
String? _errorMessage() {
|
||||
final callback = widget.errorMessage;
|
||||
AppError? _error() {
|
||||
final callback = widget.error;
|
||||
if (callback != null) return callback();
|
||||
|
||||
return context.read<PinLockViewModel>().errorMessage;
|
||||
return context.read<PinLockViewModel>().error;
|
||||
}
|
||||
|
||||
void _fail(String message) {
|
||||
|
||||
@@ -5,13 +5,12 @@ import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../models/product.dart';
|
||||
import '../viewmodels/product_list_view_model.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/app_localizations_helpers.dart';
|
||||
import '../models/product.dart';
|
||||
import '../utils/navigation.dart';
|
||||
import '../viewmodels/product_list_view_model.dart';
|
||||
|
||||
class ProductFormView extends StatefulWidget {
|
||||
final String? productId;
|
||||
@@ -84,8 +83,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
_hasImage = product.imagePath != null && product.imagePath!.isNotEmpty;
|
||||
|
||||
setState(() => _isLoading = false);
|
||||
} catch (e, stack) {
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
@@ -205,8 +203,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
imagePath: _imagePath,
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSaving = false);
|
||||
ScaffoldMessenger.of(
|
||||
@@ -251,8 +248,7 @@ class _ProductFormViewState extends State<ProductFormView> {
|
||||
|
||||
try {
|
||||
await viewModel.deleteProduct(widget.productId!);
|
||||
} catch (e, stack) {
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
|
||||
@@ -4,10 +4,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../viewmodels/product_list_view_model.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/app_localizations_helpers.dart';
|
||||
import '../utils/money.dart';
|
||||
import '../utils/navigation.dart';
|
||||
import '../viewmodels/product_list_view_model.dart';
|
||||
|
||||
class ProductListView extends StatefulWidget {
|
||||
const ProductListView({super.key});
|
||||
@@ -22,7 +23,7 @@ class _ProductListViewState extends State<ProductListView> {
|
||||
super.initState();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<ProductListViewModel>().loadProducts();
|
||||
context.read<ProductListViewModel>().ensureLoaded();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,10 +52,8 @@ class _ProductListViewState extends State<ProductListView> {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (viewModel.errorMessage != null) {
|
||||
return Center(
|
||||
child: Text(l10n.localizedError(viewModel.errorMessage)),
|
||||
);
|
||||
if (viewModel.loadError != null) {
|
||||
return Center(child: Text(l10n.errorText(viewModel.loadError)));
|
||||
}
|
||||
|
||||
if (viewModel.products.isEmpty) {
|
||||
@@ -93,7 +92,7 @@ class _ProductListViewState extends State<ProductListView> {
|
||||
subtitle: Text(
|
||||
l10n.productStockSummary(
|
||||
l10n.categoryLabel(product.category),
|
||||
product.formattedPrice,
|
||||
product.priceInCents.asEuro,
|
||||
product.stockQuantity,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../viewmodels/registration_view_model.dart';
|
||||
import '../viewmodels/remote_sync_view_model.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class RegistrationView extends StatefulWidget {
|
||||
const RegistrationView({super.key});
|
||||
@@ -115,8 +114,10 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
vm.errorMessage ?? 'Unknown error',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
@@ -150,21 +151,27 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
'Select your venue to link this device',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Select venue',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
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),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.15),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
@@ -176,7 +183,10 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
child: InkWell(
|
||||
onTap: () => vm.selectVenue(venue),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
@@ -192,7 +202,9 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -235,8 +247,10 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
'Enter device details to complete pairing',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextFormField(
|
||||
@@ -246,7 +260,9 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
hintText: 'e.g. Bar terminal 1',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (v) => v == null || v.trim().isEmpty ? 'Device name is required' : null,
|
||||
validator: (v) => v == null || v.trim().isEmpty
|
||||
? 'Device name is required'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
@@ -259,7 +275,9 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
),
|
||||
maxLength: 6,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
validator: (v) => v == null || v.trim().length < 6 ? 'Enter the 6-character code' : null,
|
||||
validator: (v) => v == null || v.trim().length < 6
|
||||
? 'Enter the 6-character code'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (vm.errorMessage != null)
|
||||
@@ -289,7 +307,9 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
await vm.registerDevice(
|
||||
deviceName: _deviceNameController.text.trim(),
|
||||
registrationCode: _regCodeController.text.trim().toUpperCase(),
|
||||
registrationCode: _regCodeController.text
|
||||
.trim()
|
||||
.toUpperCase(),
|
||||
);
|
||||
},
|
||||
child: const Text('Register'),
|
||||
@@ -338,27 +358,29 @@ class _RegistrationViewState extends State<RegistrationView> {
|
||||
'${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),
|
||||
),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Open tabs will sync to the dashboard.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
context.read<RemoteSyncViewModel>().startSync();
|
||||
context.go('/bar');
|
||||
},
|
||||
// Once configured, the router redirects to /bar by itself.
|
||||
onPressed: () => context.read<RemoteSyncViewModel>().activate(),
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
label: const Text('Start using KoolTab'),
|
||||
label: const Text('Start using KoolTap'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,14 @@ import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../models/settings.dart';
|
||||
import '../viewmodels/admin_pin_view_model.dart';
|
||||
import '../utils/app_update_util.dart';
|
||||
import '../utils/navigation.dart';
|
||||
import '../viewmodels/pin_lock_view_model.dart';
|
||||
import '../viewmodels/settings_view_model.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/app_localizations_helpers.dart';
|
||||
import '../models/settings.dart';
|
||||
import '../utils/app_update_util.dart';
|
||||
import '../utils/navigation.dart';
|
||||
import '../viewmodels/admin_pin_view_model.dart';
|
||||
import '../viewmodels/pin_lock_view_model.dart';
|
||||
import '../viewmodels/settings_view_model.dart';
|
||||
|
||||
class SettingsScreenView extends StatefulWidget {
|
||||
const SettingsScreenView({super.key});
|
||||
@@ -41,7 +41,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
||||
|
||||
Future<String> currentVersion() async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
debugPrint("Current app version: ${info.version}");
|
||||
debugPrint('Current app version: ${info.version}');
|
||||
return info.version;
|
||||
}
|
||||
|
||||
@@ -110,6 +110,30 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<T?> _pickOption<T>(
|
||||
BuildContext context, {
|
||||
required List<T> options,
|
||||
required T selected,
|
||||
required String Function(T) label,
|
||||
}) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: RadioGroup<T>(
|
||||
groupValue: selected,
|
||||
onChanged: (value) => Navigator.pop(sheetContext, value),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final option in options)
|
||||
RadioListTile<T>(value: option, title: Text(label(option))),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
@@ -119,6 +143,8 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
||||
|
||||
Future<void> _enablePin() async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final pinLockViewModel = context.read<PinLockViewModel>();
|
||||
final settingsViewModel = context.read<SettingsViewModel>();
|
||||
final pin = await _promptPin(l10n.setPinCode, hint: l10n.fourDigits);
|
||||
if (pin == null) return;
|
||||
|
||||
@@ -127,26 +153,20 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
||||
return;
|
||||
}
|
||||
|
||||
final pinLockViewModel = context.read<PinLockViewModel>();
|
||||
final success = await pinLockViewModel.setPin(pin);
|
||||
|
||||
if (!success) {
|
||||
_showError(l10n.localizedError(pinLockViewModel.errorMessage));
|
||||
_showError(l10n.errorText(pinLockViewModel.error));
|
||||
return;
|
||||
}
|
||||
|
||||
pinLockViewModel.setPinRequired(true);
|
||||
|
||||
try {
|
||||
await context.read<SettingsViewModel>().updatePinRequired(true);
|
||||
} catch (e, stack) {
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
_showError(l10n.couldNotSavePin);
|
||||
}
|
||||
await settingsViewModel.updatePinRequired(true);
|
||||
}
|
||||
|
||||
Future<void> _changePin() async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final pinLockViewModel = context.read<PinLockViewModel>();
|
||||
final current = await _promptPin(l10n.enterCurrentPin);
|
||||
if (current == null) return;
|
||||
|
||||
@@ -158,38 +178,32 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
||||
return;
|
||||
}
|
||||
|
||||
final pinLockViewModel = context.read<PinLockViewModel>();
|
||||
final success = await pinLockViewModel.changePin(
|
||||
currentPin: current,
|
||||
newPin: newPin,
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
_showError(l10n.localizedError(pinLockViewModel.errorMessage));
|
||||
_showError(l10n.errorText(pinLockViewModel.error));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disablePin() async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final pinLockViewModel = context.read<PinLockViewModel>();
|
||||
final settingsViewModel = context.read<SettingsViewModel>();
|
||||
final current = await _promptPin(l10n.enterCurrentPin);
|
||||
if (current == null) return;
|
||||
|
||||
final pinLockViewModel = context.read<PinLockViewModel>();
|
||||
final success = await pinLockViewModel.disablePin(current);
|
||||
|
||||
if (!success) {
|
||||
_showError(l10n.localizedError(pinLockViewModel.errorMessage));
|
||||
_showError(l10n.errorText(pinLockViewModel.error));
|
||||
return;
|
||||
}
|
||||
|
||||
pinLockViewModel.setPinRequired(false);
|
||||
|
||||
try {
|
||||
await context.read<SettingsViewModel>().updatePinRequired(false);
|
||||
} catch (e, stack) {
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
_showError(l10n.couldNotSavePin);
|
||||
}
|
||||
await settingsViewModel.updatePinRequired(false);
|
||||
}
|
||||
|
||||
Future<void> _configureAdminPin() async {
|
||||
@@ -207,7 +221,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
||||
|
||||
final success = await adminPinViewModel.setPin(pin);
|
||||
if (!success) {
|
||||
_showError(l10n.localizedError(adminPinViewModel.errorMessage));
|
||||
_showError(l10n.errorText(adminPinViewModel.error));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -235,7 +249,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
_showError(l10n.localizedError(adminPinViewModel.errorMessage));
|
||||
_showError(l10n.errorText(adminPinViewModel.error));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -330,26 +344,15 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
||||
AppLanguage.dutch => l10n.languageDutch,
|
||||
},
|
||||
onTap: () async {
|
||||
final selected = await showModalBottomSheet<AppLanguage>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: AppLanguage.values.map((language) {
|
||||
return RadioListTile<AppLanguage>(
|
||||
value: language,
|
||||
groupValue: settings.language,
|
||||
title: Text(switch (language) {
|
||||
AppLanguage.system => l10n.languageSystem,
|
||||
AppLanguage.english => l10n.languageEnglish,
|
||||
AppLanguage.dutch => l10n.languageDutch,
|
||||
}),
|
||||
onChanged: (value) =>
|
||||
Navigator.pop(context, value),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
final selected = await _pickOption(
|
||||
context,
|
||||
options: AppLanguage.values,
|
||||
selected: settings.language,
|
||||
label: (language) => switch (language) {
|
||||
AppLanguage.system => l10n.languageSystem,
|
||||
AppLanguage.english => l10n.languageEnglish,
|
||||
AppLanguage.dutch => l10n.languageDutch,
|
||||
},
|
||||
);
|
||||
|
||||
if (selected != null) {
|
||||
@@ -362,23 +365,11 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
||||
title: l10n.productGridRows,
|
||||
subtitle: l10n.rowsCount(settings.barGridRows),
|
||||
onTap: () async {
|
||||
final selected = await showModalBottomSheet<int>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final rows in [2, 3, 4, 5, 6])
|
||||
RadioListTile<int>(
|
||||
value: rows,
|
||||
groupValue: settings.barGridRows,
|
||||
title: Text(l10n.rowsCount(rows)),
|
||||
onChanged: (value) =>
|
||||
Navigator.pop(context, value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
final selected = await _pickOption(
|
||||
context,
|
||||
options: const [2, 3, 4, 5, 6],
|
||||
selected: settings.barGridRows,
|
||||
label: l10n.rowsCount,
|
||||
);
|
||||
|
||||
if (selected != null) {
|
||||
@@ -396,27 +387,16 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
||||
AppThemeMode.ugly => l10n.ugly,
|
||||
},
|
||||
onTap: () async {
|
||||
final selected = await showModalBottomSheet<AppThemeMode>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: AppThemeMode.values.map((mode) {
|
||||
return RadioListTile<AppThemeMode>(
|
||||
value: mode,
|
||||
groupValue: settings.themeMode,
|
||||
title: Text(switch (mode) {
|
||||
AppThemeMode.system => l10n.system,
|
||||
AppThemeMode.light => l10n.light,
|
||||
AppThemeMode.dark => l10n.dark,
|
||||
AppThemeMode.ugly => l10n.ugly,
|
||||
}),
|
||||
onChanged: (value) =>
|
||||
Navigator.pop(context, value),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
final selected = await _pickOption(
|
||||
context,
|
||||
options: AppThemeMode.values,
|
||||
selected: settings.themeMode,
|
||||
label: (mode) => switch (mode) {
|
||||
AppThemeMode.system => l10n.system,
|
||||
AppThemeMode.light => l10n.light,
|
||||
AppThemeMode.dark => l10n.dark,
|
||||
AppThemeMode.ugly => l10n.ugly,
|
||||
},
|
||||
);
|
||||
|
||||
if (selected != null) {
|
||||
@@ -484,7 +464,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
||||
|
||||
_showUpdateDialog(update);
|
||||
} catch (e, stack) {
|
||||
Sentry.captureException(e, stackTrace: stack);
|
||||
unawaited(Sentry.captureException(e, stackTrace: stack));
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/app_localizations_helpers.dart';
|
||||
import '../utils/app_update_util.dart';
|
||||
import '../utils/navigation.dart';
|
||||
import '../viewmodels/update_progress_view_model.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/app_localizations_helpers.dart';
|
||||
|
||||
class UpdateProgressView extends StatefulWidget {
|
||||
final UpdateInfo update;
|
||||
@@ -166,7 +166,7 @@ class _UpdateProgressViewState extends State<UpdateProgressView> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('KoolTab', style: Theme.of(context).textTheme.headlineMedium),
|
||||
Text('KoolTap', style: Theme.of(context).textTheme.headlineMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.version(widget.update.version),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:kooltab2/models/closed_tab.dart';
|
||||
import 'package:kooltab2/models/closed_tab_item.dart';
|
||||
import 'package:kooltab2/models/payment_method.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/closed_tab.dart';
|
||||
import '../../models/closed_tab_item.dart';
|
||||
import '../../models/payment_method.dart';
|
||||
import '../../utils/history_grouping.dart';
|
||||
import '../../utils/money.dart';
|
||||
|
||||
class ClosedTabCard extends StatefulWidget {
|
||||
final ClosedTab closedTab;
|
||||
@@ -103,7 +104,8 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
|
||||
switch (closedTab.paymentMethod) {
|
||||
PaymentMethod.cash => l10n.cash,
|
||||
PaymentMethod.payconiq => l10n.payconiq,
|
||||
PaymentMethod.paymentPending => l10n.paymentPending,
|
||||
PaymentMethod.paymentPending =>
|
||||
l10n.paymentPending,
|
||||
},
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
@@ -126,7 +128,7 @@ class _ClosedTabCardState extends State<ClosedTabCard> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
closedTab.formattedTotal,
|
||||
closedTab.totalInCents.asEuro,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
|
||||
@@ -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/money.dart';
|
||||
|
||||
class OpenTabHistoryCard extends StatefulWidget {
|
||||
final BarTab tab;
|
||||
@@ -133,7 +134,7 @@ class _OpenTabHistoryCardState extends State<OpenTabHistoryCard> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
tab.formattedTotal,
|
||||
tab.totalInCents.asEuro,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:kooltab2/models/product.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/product.dart';
|
||||
|
||||
class ProductTile extends StatelessWidget {
|
||||
final Product product;
|
||||
|
||||
Reference in New Issue
Block a user