feat: add equality operators, make history screen paginated, random performance updates

This commit is contained in:
2026-07-29 18:10:46 +02:00
parent da5588cd1c
commit 031a618220
22 changed files with 702 additions and 79 deletions
+14
View File
@@ -107,6 +107,12 @@ class _DevMenuViewState extends State<DevMenuView> {
subtitle: 'Show FPS, memory, widget count',
onTap: () => _showDebugOverlayInfo(),
),
_Tile(
icon: Icons.error_outline_rounded,
title: 'Error screen',
subtitle: 'View the error screen UI',
onTap: () => context.push('/error'),
),
],
),
_Section(
@@ -141,6 +147,14 @@ class _DevMenuViewState extends State<DevMenuView> {
subtitle: 'Set all products below threshold',
onTap: vm.isLoading ? null : () => vm.simulateLowStock(),
),
_Tile(
icon: Icons.history_rounded,
title: 'Generate 100 mock orders',
subtitle: 'Random customers, items, and amounts',
onTap: vm.isLoading
? null
: () => vm.generateMockOrders(count: 100),
),
],
),
_Section(
+65
View File
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class ErrorScreenView extends StatelessWidget {
const ErrorScreenView({super.key});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => context.pop(),
icon: const Icon(Icons.arrow_back),
),
title: const Text('Error Screen'),
centerTitle: true,
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: scheme.error.withValues(alpha: 0.12),
),
child: Icon(
Icons.error_outline_rounded,
size: 64,
color: scheme.error,
),
),
const SizedBox(height: 24),
Text(
'Something went wrong',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: scheme.error,
),
),
const SizedBox(height: 12),
Text(
'An unexpected error occurred.\nPlease try restarting the app.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: scheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: () => context.go('/bar'),
icon: const Icon(Icons.home_rounded),
label: const Text('Go to bar screen'),
),
],
),
),
),
);
}
}
+171 -17
View File
@@ -1,12 +1,9 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:kooltab2/views/widgets/closed_tab_card.dart';
import 'package:provider/provider.dart';
import '../app/router.dart';
import '../models/closed_tab.dart';
import '../models/closed_tab_item.dart';
import '../viewmodels/history_view_model.dart';
class HistoryScreenView extends StatefulWidget {
@@ -17,10 +14,14 @@ class HistoryScreenView extends StatefulWidget {
}
class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<HistoryViewModel>().load();
});
@@ -34,16 +35,23 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
@override
void didPopNext() {
// Called when you come back to this screen
context.read<HistoryViewModel>().load();
}
@override
void dispose() {
routeObserver.unsubscribe(this);
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.extentAfter < 300) {
context.read<HistoryViewModel>().loadMore();
}
}
@override
Widget build(BuildContext context) {
final viewModel = context.watch<HistoryViewModel>();
@@ -53,9 +61,7 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
title: Row(
children: [
IconButton(
onPressed: () {
context.go('/bar');
},
onPressed: () => context.go('/bar'),
icon: const Icon(Icons.arrow_back),
),
const SizedBox(width: 5),
@@ -108,23 +114,51 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: TextField(
onChanged: viewModel.search,
decoration: const InputDecoration(
hintText: 'Search by name…',
prefixIcon: Icon(Icons.search_rounded, size: 20),
isDense: true,
),
child: Row(
children: [
Expanded(
child: TextField(
onChanged: viewModel.search,
decoration: const InputDecoration(
hintText: 'Search by name…',
prefixIcon: Icon(Icons.search_rounded, size: 20),
isDense: true,
),
),
),
const SizedBox(width: 12),
_CustomerDropdown(
customerNames: viewModel.customerNames,
selectedCustomer: viewModel.selectedCustomer,
onSelected: viewModel.filterByCustomer,
),
],
),
),
Expanded(
child: viewModel.closedTabs.isEmpty
? _EmptyState()
: ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
itemCount: viewModel.closedTabs.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemCount: viewModel.closedTabs.length +
(viewModel.hasMore || viewModel.isLoadingMore
? 1
: 0),
separatorBuilder: (_, _) =>
const SizedBox(height: 10),
itemBuilder: (context, index) {
if (index == viewModel.closedTabs.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(
child: CircularProgressIndicator(
strokeWidth: 2.5,
),
),
);
}
final closedTab = viewModel.closedTabs[index];
return ClosedTabCard(closedTab: closedTab);
@@ -139,6 +173,126 @@ class _HistoryScreenViewState extends State<HistoryScreenView> with RouteAware {
}
}
class _CustomerDropdown extends StatelessWidget {
static const _allSentinel = r'$__all__$';
final List<String> customerNames;
final String? selectedCustomer;
final ValueChanged<String?> onSelected;
const _CustomerDropdown({
required this.customerNames,
required this.selectedCustomer,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final isFiltered = selectedCustomer != null;
return PopupMenuButton<String>(
onSelected: (value) {
onSelected(value == _allSentinel ? null : value);
},
offset: const Offset(0, 44),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
color: Theme.of(context).cardTheme.color ?? scheme.surface,
itemBuilder: (context) => [
PopupMenuItem<String>(
value: _allSentinel,
child: Row(
children: [
Icon(
isFiltered
? Icons.people_outline
: Icons.people_rounded,
size: 18,
color: isFiltered
? null
: scheme.primary,
),
const SizedBox(width: 10),
Text(
'All customers',
style: TextStyle(
fontWeight: isFiltered ? FontWeight.w400 : FontWeight.w700,
color: isFiltered ? null : scheme.primary,
),
),
],
),
),
if (customerNames.isNotEmpty)
const PopupMenuDivider(height: 1),
...customerNames.map(
(name) => PopupMenuItem<String>(
value: name,
child: Row(
children: [
Icon(
name == selectedCustomer
? Icons.person_rounded
: Icons.person_outline_rounded,
size: 18,
),
const SizedBox(width: 10),
Expanded(
child: Text(name, overflow: TextOverflow.ellipsis),
),
],
),
),
),
],
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: scheme.onSurface.withValues(alpha: 0.12)),
color: isFiltered
? scheme.primary.withValues(alpha: 0.08)
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.person_rounded,
size: 18,
color: isFiltered
? scheme.primary
: scheme.onSurface.withValues(alpha: 0.5),
),
const SizedBox(width: 6),
Flexible(
child: Text(
selectedCustomer ?? 'Customer',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: isFiltered
? scheme.primary
: scheme.onSurface.withValues(alpha: 0.5),
),
),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_drop_down_rounded,
size: 18,
color: isFiltered
? scheme.primary
: scheme.onSurface.withValues(alpha: 0.5),
),
],
),
),
);
}
}
class _EmptyState extends StatelessWidget {
@override
Widget build(BuildContext context) {
@@ -174,4 +328,4 @@ class _EmptyState extends StatelessWidget {
),
);
}
}
}
+21 -7
View File
@@ -36,6 +36,7 @@ class _ProductFormViewState extends State<ProductFormView> {
Product? _existingProduct;
String? _imagePath;
bool _hasImage = false;
bool _isLoading = true;
bool _isSaving = false;
@@ -72,6 +73,7 @@ class _ProductFormViewState extends State<ProductFormView> {
_stockController.text = product.stockQuantity.toString();
_lowStockController.text = product.lowStockThreshold.toString();
_imagePath = product.imagePath;
_hasImage = product.imagePath != null && product.imagePath!.isNotEmpty;
setState(() => _isLoading = false);
}
@@ -108,10 +110,11 @@ class _ProductFormViewState extends State<ProductFormView> {
Future<void> _pickImage() async {
final pickedFile = await _imagePicker.pickImage(
source: ImageSource.gallery,
imageQuality: 85,
maxWidth: 1000,
);
source: ImageSource.gallery,
imageQuality: 80,
maxWidth: 1000,
maxHeight: 1000,
);
if (pickedFile == null) return;
@@ -121,6 +124,7 @@ class _ProductFormViewState extends State<ProductFormView> {
setState(() {
_imagePath = copiedImagePath;
_hasImage = true;
});
}
@@ -222,7 +226,6 @@ class _ProductFormViewState extends State<ProductFormView> {
}
Widget _buildImagePicker(BuildContext context) {
final hasImage = _imagePath != null && File(_imagePath!).existsSync();
return InkWell(
onTap: _pickImage,
@@ -234,11 +237,22 @@ class _ProductFormViewState extends State<ProductFormView> {
border: Border.all(color: Theme.of(context).colorScheme.outline),
),
clipBehavior: Clip.antiAlias,
child: hasImage
child: _hasImage
? Stack(
fit: StackFit.expand,
children: [
Image.file(File(_imagePath!), fit: BoxFit.fitHeight),
Image.file(
File(_imagePath!),
fit: BoxFit.fitHeight,
cacheWidth: 1000,
errorBuilder: (context, error, stackTrace) => Center(
child: Icon(
Icons.broken_image_outlined,
size: 48,
color: Theme.of(context).colorScheme.outline,
),
),
),
Positioned(
right: 12,
bottom: 12,
+11 -4
View File
@@ -73,10 +73,17 @@ class _ProductListViewState extends State<ProductListView> {
width: 56,
height: 56,
child: Center(
child: Image.file(
File(product.imagePath!),
fit: BoxFit.contain,
),
child: product.imagePath != null &&
product.imagePath!.isNotEmpty
? Image.file(
File(product.imagePath!),
fit: BoxFit.contain,
cacheWidth: 112,
errorBuilder:
(context, error, stackTrace) =>
const Icon(Icons.image_not_supported_outlined),
)
: const Icon(Icons.image_not_supported_outlined),
),
),
title: Text(product.name),
+22 -22
View File
@@ -7,6 +7,7 @@ class ProductTile extends StatelessWidget {
final Product product;
final bool enabled;
final VoidCallback onTap;
static const _tileImageSize = 280.0;
const ProductTile({
required this.product,
@@ -14,16 +15,6 @@ class ProductTile extends StatelessWidget {
required this.onTap,
});
bool get _hasImage {
final imagePath = product.imagePath;
if (imagePath == null || imagePath.isEmpty) {
return false;
}
return File(imagePath).existsSync();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -32,7 +23,10 @@ class ProductTile extends StatelessWidget {
final outOfStock = product.stockQuantity <= 0;
final lowStock =
product.stockQuantity > 0 &&
product.stockQuantity <= product.lowStockThreshold; // adjust name
product.stockQuantity <= product.lowStockThreshold;
final hasImage = product.imagePath != null &&
product.imagePath!.isNotEmpty;
final borderColor = outOfStock
? scheme.error.withValues(alpha: 0.7)
@@ -59,21 +53,18 @@ class ProductTile extends StatelessWidget {
children: [
Opacity(
opacity: outOfStock ? 0.35 : (enabled ? 1 : 0.4),
child: _hasImage
child: hasImage
? Image.file(
File(product.imagePath!),
fit: BoxFit.scaleDown,
cacheWidth: _tileImageSize.toInt(),
errorBuilder:
(context, error, stackTrace) => _noImage(scheme),
)
: Center(
child: Icon(
Icons.image_not_supported_outlined,
size: 34,
color: scheme.onSurface.withValues(alpha: 0.3),
),
),
: _noImage(scheme),
),
if (_hasImage)
if (hasImage)
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
@@ -126,6 +117,16 @@ class ProductTile extends StatelessWidget {
),
);
}
Widget _noImage(ColorScheme scheme) {
return Center(
child: Icon(
Icons.image_not_supported_outlined,
size: 34,
color: scheme.onSurface.withValues(alpha: 0.3),
),
);
}
}
class _StockBadge extends StatelessWidget {
@@ -141,8 +142,7 @@ class _StockBadge extends StatelessWidget {
return const SizedBox.shrink();
}
final low =
product.stockQuantity <= product.lowStockThreshold; // adjust name
final low = product.stockQuantity <= product.lowStockThreshold;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),