fix
This commit is contained in:
3
devtools_options.yaml
Normal file
3
devtools_options.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
|
extensions:
|
||||||
@@ -36,8 +36,8 @@ class _RetailAppState extends ConsumerState<RetailApp> {
|
|||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Retail POS',
|
title: 'Retail POS',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.lightTheme(),
|
theme: AppTheme.lightTheme,
|
||||||
darkTheme: AppTheme.darkTheme(),
|
darkTheme: AppTheme.darkTheme,
|
||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
// Wrap the home with AuthWrapper to require login
|
// Wrap the home with AuthWrapper to require login
|
||||||
home: const AuthWrapper(
|
home: const AuthWrapper(
|
||||||
|
|||||||
@@ -23,4 +23,17 @@ class AppConstants {
|
|||||||
static const int minStockThreshold = 5;
|
static const int minStockThreshold = 5;
|
||||||
static const int maxCartItemQuantity = 999;
|
static const int maxCartItemQuantity = 999;
|
||||||
static const double minTransactionAmount = 0.01;
|
static const double minTransactionAmount = 0.01;
|
||||||
|
|
||||||
|
// Spacing and Sizes
|
||||||
|
static const double defaultPadding = 16.0;
|
||||||
|
static const double smallPadding = 8.0;
|
||||||
|
static const double largePadding = 24.0;
|
||||||
|
static const double borderRadius = 12.0;
|
||||||
|
static const double buttonHeight = 48.0;
|
||||||
|
static const double textFieldHeight = 56.0;
|
||||||
|
|
||||||
|
// Animation Durations
|
||||||
|
static const Duration shortAnimationDuration = Duration(milliseconds: 200);
|
||||||
|
static const Duration mediumAnimationDuration = Duration(milliseconds: 400);
|
||||||
|
static const Duration longAnimationDuration = Duration(milliseconds: 600);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,124 +1,297 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'colors.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import '../constants/app_constants.dart';
|
||||||
|
|
||||||
/// Material 3 theme configuration for the app
|
/// Application theme configuration using Material Design 3
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
AppTheme._();
|
AppTheme._();
|
||||||
|
|
||||||
/// Light theme
|
// Color scheme for light theme
|
||||||
static ThemeData lightTheme() {
|
static const ColorScheme _lightColorScheme = ColorScheme(
|
||||||
|
brightness: Brightness.light,
|
||||||
|
primary: Color(0xFF1976D2), // Blue
|
||||||
|
onPrimary: Color(0xFFFFFFFF),
|
||||||
|
primaryContainer: Color(0xFFE3F2FD),
|
||||||
|
onPrimaryContainer: Color(0xFF0D47A1),
|
||||||
|
secondary: Color(0xFF757575), // Grey
|
||||||
|
onSecondary: Color(0xFFFFFFFF),
|
||||||
|
secondaryContainer: Color(0xFFE0E0E0),
|
||||||
|
onSecondaryContainer: Color(0xFF424242),
|
||||||
|
tertiary: Color(0xFF4CAF50), // Green
|
||||||
|
onTertiary: Color(0xFFFFFFFF),
|
||||||
|
tertiaryContainer: Color(0xFFE8F5E8),
|
||||||
|
onTertiaryContainer: Color(0xFF2E7D32),
|
||||||
|
error: Color(0xFFD32F2F),
|
||||||
|
onError: Color(0xFFFFFFFF),
|
||||||
|
errorContainer: Color(0xFFFFEBEE),
|
||||||
|
onErrorContainer: Color(0xFFB71C1C),
|
||||||
|
surface: Color(0xFFFFFFFF),
|
||||||
|
onSurface: Color(0xFF212121),
|
||||||
|
surfaceContainerHighest: Color(0xFFF5F5F5),
|
||||||
|
onSurfaceVariant: Color(0xFF616161),
|
||||||
|
outline: Color(0xFFBDBDBD),
|
||||||
|
outlineVariant: Color(0xFFE0E0E0),
|
||||||
|
shadow: Color(0xFF000000),
|
||||||
|
scrim: Color(0xFF000000),
|
||||||
|
inverseSurface: Color(0xFF303030),
|
||||||
|
onInverseSurface: Color(0xFFF5F5F5),
|
||||||
|
inversePrimary: Color(0xFF90CAF9),
|
||||||
|
surfaceTint: Color(0xFF1976D2),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Color scheme for dark theme
|
||||||
|
static const ColorScheme _darkColorScheme = ColorScheme(
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
primary: Color(0xFF90CAF9), // Light Blue
|
||||||
|
onPrimary: Color(0xFF0D47A1),
|
||||||
|
primaryContainer: Color(0xFF1565C0),
|
||||||
|
onPrimaryContainer: Color(0xFFE3F2FD),
|
||||||
|
secondary: Color(0xFFBDBDBD), // Light Grey
|
||||||
|
onSecondary: Color(0xFF424242),
|
||||||
|
secondaryContainer: Color(0xFF616161),
|
||||||
|
onSecondaryContainer: Color(0xFFE0E0E0),
|
||||||
|
tertiary: Color(0xFF81C784), // Light Green
|
||||||
|
onTertiary: Color(0xFF2E7D32),
|
||||||
|
tertiaryContainer: Color(0xFF388E3C),
|
||||||
|
onTertiaryContainer: Color(0xFFE8F5E8),
|
||||||
|
error: Color(0xFFEF5350),
|
||||||
|
onError: Color(0xFFB71C1C),
|
||||||
|
errorContainer: Color(0xFFD32F2F),
|
||||||
|
onErrorContainer: Color(0xFFFFEBEE),
|
||||||
|
surface: Color(0xFF121212),
|
||||||
|
onSurface: Color(0xFFE0E0E0),
|
||||||
|
surfaceContainerHighest: Color(0xFF2C2C2C),
|
||||||
|
onSurfaceVariant: Color(0xFFBDBDBD),
|
||||||
|
outline: Color(0xFF757575),
|
||||||
|
outlineVariant: Color(0xFF424242),
|
||||||
|
shadow: Color(0xFF000000),
|
||||||
|
scrim: Color(0xFF000000),
|
||||||
|
inverseSurface: Color(0xFFE0E0E0),
|
||||||
|
onInverseSurface: Color(0xFF303030),
|
||||||
|
inversePrimary: Color(0xFF1976D2),
|
||||||
|
surfaceTint: Color(0xFF90CAF9),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Light theme configuration
|
||||||
|
static ThemeData get lightTheme {
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: Brightness.light,
|
colorScheme: _lightColorScheme,
|
||||||
colorScheme: ColorScheme.light(
|
scaffoldBackgroundColor: _lightColorScheme.surface,
|
||||||
primary: AppColors.primaryLight,
|
|
||||||
secondary: AppColors.secondaryLight,
|
// App Bar Theme
|
||||||
tertiary: AppColors.tertiaryLight,
|
appBarTheme: AppBarTheme(
|
||||||
error: AppColors.errorLight,
|
|
||||||
surface: AppColors.surfaceLight,
|
|
||||||
onPrimary: AppColors.white,
|
|
||||||
onSecondary: AppColors.white,
|
|
||||||
onSurface: AppColors.black,
|
|
||||||
onError: AppColors.white,
|
|
||||||
primaryContainer: AppColors.primaryContainer,
|
|
||||||
secondaryContainer: AppColors.secondaryContainer,
|
|
||||||
),
|
|
||||||
scaffoldBackgroundColor: AppColors.backgroundLight,
|
|
||||||
appBarTheme: const AppBarTheme(
|
|
||||||
centerTitle: true,
|
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
backgroundColor: AppColors.primaryLight,
|
scrolledUnderElevation: 1,
|
||||||
foregroundColor: AppColors.white,
|
backgroundColor: _lightColorScheme.surface,
|
||||||
),
|
foregroundColor: _lightColorScheme.onSurface,
|
||||||
cardTheme: CardThemeData(
|
titleTextStyle: TextStyle(
|
||||||
elevation: 2,
|
fontSize: 20,
|
||||||
shape: RoundedRectangleBorder(
|
fontWeight: FontWeight.w600,
|
||||||
borderRadius: BorderRadius.circular(12),
|
color: _lightColorScheme.onSurface,
|
||||||
),
|
),
|
||||||
|
systemOverlayStyle: SystemUiOverlayStyle.dark,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Elevated Button Theme
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
minimumSize: Size(double.infinity, AppConstants.buttonHeight),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Text Button Theme
|
||||||
|
textButtonTheme: TextButtonThemeData(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
minimumSize: Size(0, AppConstants.buttonHeight),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Input Decoration Theme
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: AppColors.grey100,
|
fillColor: _lightColorScheme.surfaceContainerHighest,
|
||||||
|
contentPadding: EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide(color: _lightColorScheme.outline),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide(color: _lightColorScheme.outline),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
borderSide: const BorderSide(color: AppColors.primaryLight, width: 2),
|
borderSide: BorderSide(color: _lightColorScheme.primary, width: 2),
|
||||||
),
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
borderSide: BorderSide(color: _lightColorScheme.error),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
borderSide: BorderSide(color: _lightColorScheme.error, width: 2),
|
||||||
|
),
|
||||||
|
labelStyle: TextStyle(color: _lightColorScheme.onSurfaceVariant),
|
||||||
|
hintStyle: TextStyle(color: _lightColorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
|
||||||
|
|
||||||
|
// List Tile Theme
|
||||||
|
listTileTheme: ListTileThemeData(
|
||||||
|
contentPadding: EdgeInsets.symmetric(
|
||||||
|
horizontal: AppConstants.defaultPadding,
|
||||||
|
vertical: AppConstants.smallPadding,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Divider Theme
|
||||||
|
dividerTheme: DividerThemeData(
|
||||||
|
color: _lightColorScheme.outline,
|
||||||
|
thickness: 0.5,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Progress Indicator Theme
|
||||||
|
progressIndicatorTheme: ProgressIndicatorThemeData(
|
||||||
|
color: _lightColorScheme.primary,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Snack Bar Theme
|
||||||
|
snackBarTheme: SnackBarThemeData(
|
||||||
|
backgroundColor: _lightColorScheme.inverseSurface,
|
||||||
|
contentTextStyle: TextStyle(color: _lightColorScheme.onInverseSurface),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dark theme
|
/// Dark theme configuration
|
||||||
static ThemeData darkTheme() {
|
static ThemeData get darkTheme {
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: Brightness.dark,
|
colorScheme: _darkColorScheme,
|
||||||
colorScheme: ColorScheme.dark(
|
scaffoldBackgroundColor: _darkColorScheme.surface,
|
||||||
primary: AppColors.primaryDark,
|
|
||||||
secondary: AppColors.secondaryDark,
|
// App Bar Theme
|
||||||
tertiary: AppColors.tertiaryDark,
|
appBarTheme: AppBarTheme(
|
||||||
error: AppColors.errorDark,
|
|
||||||
surface: AppColors.surfaceDark,
|
|
||||||
onPrimary: AppColors.black,
|
|
||||||
onSecondary: AppColors.black,
|
|
||||||
onSurface: AppColors.white,
|
|
||||||
onError: AppColors.black,
|
|
||||||
primaryContainer: AppColors.primaryContainer,
|
|
||||||
secondaryContainer: AppColors.secondaryContainer,
|
|
||||||
),
|
|
||||||
scaffoldBackgroundColor: AppColors.backgroundDark,
|
|
||||||
appBarTheme: const AppBarTheme(
|
|
||||||
centerTitle: true,
|
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
backgroundColor: AppColors.backgroundDark,
|
scrolledUnderElevation: 1,
|
||||||
foregroundColor: AppColors.white,
|
backgroundColor: _darkColorScheme.surface,
|
||||||
),
|
foregroundColor: _darkColorScheme.onSurface,
|
||||||
cardTheme: CardThemeData(
|
titleTextStyle: TextStyle(
|
||||||
elevation: 2,
|
fontSize: 20,
|
||||||
shape: RoundedRectangleBorder(
|
fontWeight: FontWeight.w600,
|
||||||
borderRadius: BorderRadius.circular(12),
|
color: _darkColorScheme.onSurface,
|
||||||
),
|
),
|
||||||
|
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Elevated Button Theme
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
minimumSize: Size(double.infinity, AppConstants.buttonHeight),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Text Button Theme
|
||||||
|
textButtonTheme: TextButtonThemeData(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
minimumSize: Size(0, AppConstants.buttonHeight),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Input Decoration Theme
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: AppColors.grey800,
|
fillColor: _darkColorScheme.surfaceContainerHighest,
|
||||||
|
contentPadding: EdgeInsets.all(AppConstants.defaultPadding),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide(color: _darkColorScheme.outline),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide(color: _darkColorScheme.outline),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
borderSide: const BorderSide(color: AppColors.primaryDark, width: 2),
|
borderSide: BorderSide(color: _darkColorScheme.primary, width: 2),
|
||||||
),
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
borderSide: BorderSide(color: _darkColorScheme.error),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
borderSide: BorderSide(color: _darkColorScheme.error, width: 2),
|
||||||
|
),
|
||||||
|
labelStyle: TextStyle(color: _darkColorScheme.onSurfaceVariant),
|
||||||
|
hintStyle: TextStyle(color: _darkColorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
|
||||||
|
|
||||||
|
// List Tile Theme
|
||||||
|
listTileTheme: ListTileThemeData(
|
||||||
|
contentPadding: EdgeInsets.symmetric(
|
||||||
|
horizontal: AppConstants.defaultPadding,
|
||||||
|
vertical: AppConstants.smallPadding,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Divider Theme
|
||||||
|
dividerTheme: DividerThemeData(
|
||||||
|
color: _darkColorScheme.outline,
|
||||||
|
thickness: 0.5,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Progress Indicator Theme
|
||||||
|
progressIndicatorTheme: ProgressIndicatorThemeData(
|
||||||
|
color: _darkColorScheme.primary,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Snack Bar Theme
|
||||||
|
snackBarTheme: SnackBarThemeData(
|
||||||
|
backgroundColor: _darkColorScheme.inverseSurface,
|
||||||
|
contentTextStyle: TextStyle(color: _darkColorScheme.onInverseSurface),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppConstants.borderRadius),
|
||||||
|
),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,10 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
// Forgot password link
|
// Forgot password link
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: isLoading ? null : _handleForgotPassword,
|
onPressed: isLoading ? null : _handleForgotPassword,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
minimumSize: const Size(0, 0),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Forgot Password?',
|
'Forgot Password?',
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ part of 'categories_provider.dart';
|
|||||||
|
|
||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
// ignore_for_file: type=lint, type=warning
|
// ignore_for_file: type=lint, type=warning
|
||||||
/// Provider for categories list with API-first approach
|
/// Provider for categories list with online-first approach
|
||||||
|
|
||||||
@ProviderFor(Categories)
|
@ProviderFor(Categories)
|
||||||
const categoriesProvider = CategoriesProvider._();
|
const categoriesProvider = CategoriesProvider._();
|
||||||
|
|
||||||
/// Provider for categories list with API-first approach
|
/// Provider for categories list with online-first approach
|
||||||
final class CategoriesProvider
|
final class CategoriesProvider
|
||||||
extends $AsyncNotifierProvider<Categories, List<Category>> {
|
extends $AsyncNotifierProvider<Categories, List<Category>> {
|
||||||
/// Provider for categories list with API-first approach
|
/// Provider for categories list with online-first approach
|
||||||
const CategoriesProvider._()
|
const CategoriesProvider._()
|
||||||
: super(
|
: super(
|
||||||
from: null,
|
from: null,
|
||||||
@@ -38,7 +38,7 @@ final class CategoriesProvider
|
|||||||
|
|
||||||
String _$categoriesHash() => r'33c33b08f8926e5bbbd112285591c74a3ff0f61c';
|
String _$categoriesHash() => r'33c33b08f8926e5bbbd112285591c74a3ff0f61c';
|
||||||
|
|
||||||
/// Provider for categories list with API-first approach
|
/// Provider for categories list with online-first approach
|
||||||
|
|
||||||
abstract class _$Categories extends $AsyncNotifier<List<Category>> {
|
abstract class _$Categories extends $AsyncNotifier<List<Category>> {
|
||||||
FutureOr<List<Category>> build();
|
FutureOr<List<Category>> build();
|
||||||
|
|||||||
@@ -1,169 +1,535 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../widgets/product_selector.dart';
|
import '../../../products/presentation/providers/products_provider.dart';
|
||||||
import '../widgets/cart_summary.dart';
|
import '../../../products/presentation/providers/selected_category_provider.dart';
|
||||||
|
import '../../../categories/presentation/providers/categories_provider.dart';
|
||||||
import '../providers/cart_provider.dart';
|
import '../providers/cart_provider.dart';
|
||||||
|
import '../providers/cart_total_provider.dart';
|
||||||
import '../../domain/entities/cart_item.dart';
|
import '../../domain/entities/cart_item.dart';
|
||||||
|
import '../../../../core/widgets/loading_indicator.dart';
|
||||||
|
import '../../../../core/widgets/error_widget.dart';
|
||||||
|
import '../../../../core/widgets/empty_state.dart';
|
||||||
|
import '../../../../core/widgets/optimized_cached_image.dart';
|
||||||
|
import '../../../../shared/widgets/price_display.dart';
|
||||||
|
|
||||||
/// Home page - POS interface with product selector and cart
|
/// Home page - Quick sale POS interface
|
||||||
class HomePage extends ConsumerWidget {
|
class HomePage extends ConsumerStatefulWidget {
|
||||||
const HomePage({super.key});
|
const HomePage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<HomePage> createState() => _HomePageState();
|
||||||
final cartAsync = ref.watch(cartProvider);
|
}
|
||||||
final isWideScreen = MediaQuery.of(context).size.width > 600;
|
|
||||||
|
|
||||||
return Scaffold(
|
class _HomePageState extends ConsumerState<HomePage> {
|
||||||
appBar: AppBar(
|
String _searchQuery = '';
|
||||||
title: const Text('Point of Sale'),
|
|
||||||
actions: [
|
@override
|
||||||
// Cart item count badge
|
Widget build(BuildContext context) {
|
||||||
cartAsync.whenOrNull(
|
final productsAsync = ref.watch(productsProvider);
|
||||||
data: (items) => items.isNotEmpty
|
final categoriesAsync = ref.watch(categoriesProvider);
|
||||||
? Padding(
|
final selectedCategory = ref.watch(selectedCategoryProvider);
|
||||||
padding: const EdgeInsets.only(right: 16.0),
|
final cartAsync = ref.watch(cartProvider);
|
||||||
child: Center(
|
final totalData = ref.watch(cartTotalProvider);
|
||||||
child: Badge(
|
final theme = Theme.of(context);
|
||||||
label: Text('${items.length}'),
|
|
||||||
child: const Icon(Icons.shopping_cart),
|
final cartItems = cartAsync.value ?? [];
|
||||||
|
final itemCount = cartItems.length;
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: theme.colorScheme.surfaceContainerLowest,
|
||||||
|
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// Search bar
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
child: TextField(
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search Menu',
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 20),
|
||||||
|
suffixIcon: _searchQuery.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = '';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.tune, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Show filters
|
||||||
|
},
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: theme.colorScheme.outline.withOpacity(0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: theme.colorScheme.outline.withOpacity(0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Category filter buttons
|
||||||
|
categoriesAsync.when(
|
||||||
|
loading: () => const SizedBox.shrink(),
|
||||||
|
error: (_, __) => const SizedBox.shrink(),
|
||||||
|
data: (categories) {
|
||||||
|
if (categories.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: 75,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
child: ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
children: [
|
||||||
|
// All/Favorite category
|
||||||
|
_CategoryButton(
|
||||||
|
icon: Icons.star,
|
||||||
|
label: 'Favorite',
|
||||||
|
isSelected: selectedCategory == null,
|
||||||
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(selectedCategoryProvider.notifier)
|
||||||
|
.clearSelection();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Category buttons
|
||||||
|
...categories.map(
|
||||||
|
(category) => Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 12.0),
|
||||||
|
child: _CategoryButton(
|
||||||
|
icon: _getCategoryIcon(category.name),
|
||||||
|
label: category.name,
|
||||||
|
isSelected: selectedCategory == category.id,
|
||||||
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(selectedCategoryProvider.notifier)
|
||||||
|
.selectCategory(category.id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// Products list
|
||||||
|
Expanded(
|
||||||
|
child: productsAsync.when(
|
||||||
|
loading: () => const LoadingIndicator(
|
||||||
|
message: 'Loading products...',
|
||||||
|
),
|
||||||
|
error: (error, stack) => ErrorDisplay(
|
||||||
|
message: error.toString(),
|
||||||
|
onRetry: () => ref.refresh(productsProvider),
|
||||||
|
),
|
||||||
|
data: (products) {
|
||||||
|
// Filter available products
|
||||||
|
var availableProducts =
|
||||||
|
products.where((p) => p.isAvailable).toList();
|
||||||
|
|
||||||
|
// Apply category filter
|
||||||
|
if (selectedCategory != null) {
|
||||||
|
availableProducts = availableProducts
|
||||||
|
.where((p) => p.categoryId == selectedCategory)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (_searchQuery.isNotEmpty) {
|
||||||
|
availableProducts = availableProducts.where((p) {
|
||||||
|
final query = _searchQuery.toLowerCase();
|
||||||
|
return p.name.toLowerCase().contains(query) ||
|
||||||
|
(p.description?.toLowerCase().contains(query) ?? false);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableProducts.isEmpty) {
|
||||||
|
return EmptyState(
|
||||||
|
message: _searchQuery.isNotEmpty
|
||||||
|
? 'No products found'
|
||||||
|
: 'No products available',
|
||||||
|
subMessage: _searchQuery.isNotEmpty
|
||||||
|
? 'Try a different search term'
|
||||||
|
: 'Add products to start selling',
|
||||||
|
icon: Icons.inventory_2_outlined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: availableProducts.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final product = availableProducts[index];
|
||||||
|
|
||||||
|
// Find if product is in cart
|
||||||
|
final cartItem = cartItems.firstWhere(
|
||||||
|
(item) => item.productId == product.id,
|
||||||
|
orElse: () => CartItem(
|
||||||
|
productId: '',
|
||||||
|
productName: '',
|
||||||
|
price: 0,
|
||||||
|
quantity: 0,
|
||||||
|
addedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final isInCart = cartItem.productId.isNotEmpty;
|
||||||
|
final quantity = isInCart ? cartItem.quantity : 0;
|
||||||
|
|
||||||
|
return _ProductListItem(
|
||||||
|
product: product,
|
||||||
|
quantity: quantity,
|
||||||
|
onAdd: () => _addToCart(product),
|
||||||
|
onIncrement: isInCart
|
||||||
|
? () => ref
|
||||||
|
.read(cartProvider.notifier)
|
||||||
|
.updateQuantity(product.id, quantity + 1)
|
||||||
|
: null,
|
||||||
|
onDecrement: isInCart
|
||||||
|
? () {
|
||||||
|
if (quantity > 1) {
|
||||||
|
ref
|
||||||
|
.read(cartProvider.notifier)
|
||||||
|
.updateQuantity(product.id, quantity - 1);
|
||||||
|
} else {
|
||||||
|
ref
|
||||||
|
.read(cartProvider.notifier)
|
||||||
|
.removeItem(product.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Bottom bar
|
||||||
|
bottomNavigationBar: itemCount > 0
|
||||||
|
? Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () => _proceedToCheckout(),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: theme.colorScheme.primary,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Proceed New Order',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onPrimary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$itemCount items',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
PriceDisplay(
|
||||||
|
price: totalData.total,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onPrimary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(Icons.arrow_forward, color: Colors.white),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
) ?? const SizedBox.shrink(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: isWideScreen
|
|
||||||
? Row(
|
|
||||||
children: [
|
|
||||||
// Product selector on left
|
|
||||||
Expanded(
|
|
||||||
flex: 3,
|
|
||||||
child: ProductSelector(
|
|
||||||
onProductTap: (product) {
|
|
||||||
_showAddToCartDialog(context, ref, product);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Divider
|
|
||||||
const VerticalDivider(width: 1),
|
|
||||||
// Cart on right
|
|
||||||
const Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: CartSummary(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: Column(
|
|
||||||
children: [
|
|
||||||
// Product selector on top
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: ProductSelector(
|
|
||||||
onProductTap: (product) {
|
|
||||||
_showAddToCartDialog(context, ref, product);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Divider
|
|
||||||
const Divider(height: 1),
|
|
||||||
// Cart on bottom
|
|
||||||
const Expanded(
|
|
||||||
flex: 3,
|
|
||||||
child: CartSummary(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showAddToCartDialog(
|
void _addToCart(dynamic product) {
|
||||||
BuildContext context,
|
|
||||||
WidgetRef ref,
|
|
||||||
dynamic product,
|
|
||||||
) {
|
|
||||||
int quantity = 1;
|
|
||||||
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => StatefulBuilder(
|
|
||||||
builder: (context, setState) => AlertDialog(
|
|
||||||
title: const Text('Add to Cart'),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
product.name,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.remove_circle_outline),
|
|
||||||
onPressed: quantity > 1
|
|
||||||
? () => setState(() => quantity--)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
||||||
child: Text(
|
|
||||||
'$quantity',
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.add_circle_outline),
|
|
||||||
onPressed: quantity < product.stockQuantity
|
|
||||||
? () => setState(() => quantity++)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (product.stockQuantity < 5)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
|
||||||
child: Text(
|
|
||||||
'Only ${product.stockQuantity} in stock',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
// Create cart item from product
|
|
||||||
final cartItem = CartItem(
|
final cartItem = CartItem(
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
productName: product.name,
|
productName: product.name,
|
||||||
price: product.price,
|
price: product.price,
|
||||||
quantity: quantity,
|
quantity: 1,
|
||||||
imageUrl: product.imageUrl,
|
imageUrl: product.imageUrl,
|
||||||
addedAt: DateTime.now(),
|
addedAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add to cart
|
|
||||||
ref.read(cartProvider.notifier).addItem(cartItem);
|
ref.read(cartProvider.notifier).addItem(cartItem);
|
||||||
|
}
|
||||||
|
|
||||||
Navigator.pop(context);
|
void _proceedToCheckout() {
|
||||||
|
// TODO: Navigate to checkout/order detail screen
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
const SnackBar(
|
||||||
content: Text('Added ${product.name} to cart'),
|
content: Text('Proceeding to checkout...'),
|
||||||
duration: const Duration(seconds: 2),
|
duration: Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
icon: const Icon(Icons.add_shopping_cart),
|
|
||||||
label: const Text('Add'),
|
IconData _getCategoryIcon(String categoryName) {
|
||||||
|
final name = categoryName.toLowerCase();
|
||||||
|
if (name.contains('drink') || name.contains('beverage')) {
|
||||||
|
return Icons.local_cafe;
|
||||||
|
} else if (name.contains('food') || name.contains('meal')) {
|
||||||
|
return Icons.restaurant;
|
||||||
|
} else if (name.contains('dessert') || name.contains('sweet')) {
|
||||||
|
return Icons.cake;
|
||||||
|
} else {
|
||||||
|
return Icons.category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Category filter button
|
||||||
|
class _CategoryButton extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _CategoryButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.primaryContainer
|
||||||
|
: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: Colors.transparent,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Product list item
|
||||||
|
class _ProductListItem extends StatelessWidget {
|
||||||
|
final dynamic product;
|
||||||
|
final int quantity;
|
||||||
|
final VoidCallback onAdd;
|
||||||
|
final VoidCallback? onIncrement;
|
||||||
|
final VoidCallback? onDecrement;
|
||||||
|
|
||||||
|
const _ProductListItem({
|
||||||
|
required this.product,
|
||||||
|
required this.quantity,
|
||||||
|
required this.onAdd,
|
||||||
|
this.onIncrement,
|
||||||
|
this.onDecrement,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isInCart = quantity > 0;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: isInCart
|
||||||
|
? theme.colorScheme.primary.withOpacity(0.3)
|
||||||
|
: theme.colorScheme.outlineVariant,
|
||||||
|
width: isInCart ? 2 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Product image
|
||||||
|
RepaintBoundary(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: product.imageUrl != null
|
||||||
|
? OptimizedCachedImage(
|
||||||
|
key: ValueKey('product_img_${product.id}'),
|
||||||
|
imageUrl: product.imageUrl!,
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.inventory_2,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Product info
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
product.name,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
PriceDisplay(
|
||||||
|
price: product.price,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Add/quantity controls
|
||||||
|
if (!isInCart)
|
||||||
|
IconButton(
|
||||||
|
onPressed: onAdd,
|
||||||
|
icon: const Icon(Icons.add_circle),
|
||||||
|
iconSize: 32,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: onDecrement,
|
||||||
|
icon: const Icon(Icons.remove),
|
||||||
|
iconSize: 20,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'$quantity',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: onIncrement,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
iconSize: 20,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class CartTotal extends _$CartTotal {
|
|||||||
// Calculate subtotal
|
// Calculate subtotal
|
||||||
final subtotal = items.fold<double>(
|
final subtotal = items.fold<double>(
|
||||||
0.0,
|
0.0,
|
||||||
(sum, item) => sum + item.lineTotal,
|
(sum, item) => sum + item.total,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate tax
|
// Calculate tax
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ final class CartTotalProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$cartTotalHash() => r'044f6d4749eec49f9ef4173fc42d149a3841df21';
|
String _$cartTotalHash() => r'3e4ed08789743e7149a77047651b5d99e380a696';
|
||||||
|
|
||||||
/// Cart totals calculation provider
|
/// Cart totals calculation provider
|
||||||
|
|
||||||
|
|||||||
348
lib/features/home/presentation/widgets/cart_bottom_bar.dart
Normal file
348
lib/features/home/presentation/widgets/cart_bottom_bar.dart
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../providers/cart_provider.dart';
|
||||||
|
import '../providers/cart_total_provider.dart';
|
||||||
|
import '../../../../shared/widgets/price_display.dart';
|
||||||
|
|
||||||
|
/// Bottom bar showing cart total and checkout button
|
||||||
|
class CartBottomBar extends ConsumerWidget {
|
||||||
|
const CartBottomBar({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final cartAsync = ref.watch(cartProvider);
|
||||||
|
final totalData = ref.watch(cartTotalProvider);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
final itemCount = cartAsync.value?.length ?? 0;
|
||||||
|
final hasItems = itemCount > 0;
|
||||||
|
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
height: hasItems ? 80 : 0,
|
||||||
|
child: hasItems
|
||||||
|
? Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.1),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Cart icon with badge
|
||||||
|
Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.shopping_cart,
|
||||||
|
size: 32,
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: -8,
|
||||||
|
top: -8,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
minWidth: 20,
|
||||||
|
minHeight: 20,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'$itemCount',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onError,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
// Total info
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$itemCount item${itemCount == 1 ? '' : 's'}',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
PriceDisplay(
|
||||||
|
price: totalData.total,
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// View Cart button
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () {
|
||||||
|
_showCartBottomSheet(context, ref);
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: theme.colorScheme.onPrimaryContainer,
|
||||||
|
side: BorderSide(
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('View Cart'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// Checkout button
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Navigate to checkout
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Checkout coming soon!'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.payment),
|
||||||
|
label: const Text('Checkout'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: theme.colorScheme.primary,
|
||||||
|
foregroundColor: theme.colorScheme.onPrimary,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showCartBottomSheet(BuildContext context, WidgetRef ref) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => DraggableScrollableSheet(
|
||||||
|
initialChildSize: 0.7,
|
||||||
|
minChildSize: 0.5,
|
||||||
|
maxChildSize: 0.95,
|
||||||
|
expand: false,
|
||||||
|
builder: (context, scrollController) {
|
||||||
|
return CartBottomSheet(scrollController: scrollController);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cart bottom sheet content
|
||||||
|
class CartBottomSheet extends ConsumerWidget {
|
||||||
|
final ScrollController scrollController;
|
||||||
|
|
||||||
|
const CartBottomSheet({
|
||||||
|
super.key,
|
||||||
|
required this.scrollController,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final cartAsync = ref.watch(cartProvider);
|
||||||
|
final totalData = ref.watch(cartTotalProvider);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Handle bar
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant.withOpacity(0.4),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Header
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Shopping Cart',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (cartAsync.value?.isNotEmpty ?? false)
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(cartProvider.notifier).clearCart();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.delete_sweep),
|
||||||
|
label: const Text('Clear'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
// Cart items
|
||||||
|
Expanded(
|
||||||
|
child: cartAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, stack) => Center(child: Text('Error: $error')),
|
||||||
|
data: (items) {
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Text('Cart is empty'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
controller: scrollController,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
itemCount: items.length,
|
||||||
|
separatorBuilder: (context, index) => const Divider(),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(item.productName),
|
||||||
|
subtitle: PriceDisplay(
|
||||||
|
price: item.price,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Quantity controls
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.remove_circle_outline),
|
||||||
|
onPressed: item.quantity > 1
|
||||||
|
? () => ref
|
||||||
|
.read(cartProvider.notifier)
|
||||||
|
.updateQuantity(
|
||||||
|
item.productId,
|
||||||
|
item.quantity - 1,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
iconSize: 20,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${item.quantity}',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
|
onPressed: () => ref
|
||||||
|
.read(cartProvider.notifier)
|
||||||
|
.updateQuantity(
|
||||||
|
item.productId,
|
||||||
|
item.quantity + 1,
|
||||||
|
),
|
||||||
|
iconSize: 20,
|
||||||
|
),
|
||||||
|
// Remove button
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
onPressed: () => ref
|
||||||
|
.read(cartProvider.notifier)
|
||||||
|
.removeItem(item.productId),
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
iconSize: 20,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Summary
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(color: theme.dividerColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('Subtotal:', style: theme.textTheme.bodyLarge),
|
||||||
|
PriceDisplay(
|
||||||
|
price: totalData.subtotal,
|
||||||
|
style: theme.textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (totalData.tax > 0) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Tax (${(totalData.taxRate * 100).toStringAsFixed(0)}%):',
|
||||||
|
style: theme.textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
PriceDisplay(
|
||||||
|
price: totalData.tax,
|
||||||
|
style: theme.textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const Divider(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Total:',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PriceDisplay(
|
||||||
|
price: totalData.total,
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
lib/features/home/presentation/widgets/pos_product_card.dart
Normal file
156
lib/features/home/presentation/widgets/pos_product_card.dart
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../products/domain/entities/product.dart';
|
||||||
|
import '../../../../shared/widgets/price_display.dart';
|
||||||
|
import '../../../../core/widgets/optimized_cached_image.dart';
|
||||||
|
|
||||||
|
/// POS-specific product card with Add to Cart button
|
||||||
|
class PosProductCard extends StatelessWidget {
|
||||||
|
final Product product;
|
||||||
|
final VoidCallback onAddToCart;
|
||||||
|
|
||||||
|
const PosProductCard({
|
||||||
|
super.key,
|
||||||
|
required this.product,
|
||||||
|
required this.onAddToCart,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isLowStock = product.stockQuantity < 5;
|
||||||
|
final isOutOfStock = product.stockQuantity == 0;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
elevation: 2,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: isOutOfStock ? null : onAddToCart,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Product Image
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
product.imageUrl != null
|
||||||
|
? OptimizedCachedImage(
|
||||||
|
imageUrl: product.imageUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.inventory_2,
|
||||||
|
size: 48,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Stock badge
|
||||||
|
if (isOutOfStock)
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'OUT OF STOCK',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onError,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (isLowStock)
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.errorContainer,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${product.stockQuantity} left',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onErrorContainer,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Product Info
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
// Product Name
|
||||||
|
Text(
|
||||||
|
product.name,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
// Price and Add Button Row
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: PriceDisplay(
|
||||||
|
price: product.price,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Add to Cart Button
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: isOutOfStock ? null : onAddToCart,
|
||||||
|
icon: const Icon(Icons.add_shopping_cart, size: 18),
|
||||||
|
label: const Text('Add'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../products/presentation/providers/products_provider.dart';
|
import '../../../products/presentation/providers/products_provider.dart';
|
||||||
import '../../../products/presentation/widgets/product_card.dart';
|
|
||||||
import '../../../products/domain/entities/product.dart';
|
import '../../../products/domain/entities/product.dart';
|
||||||
import '../../../../core/widgets/loading_indicator.dart';
|
import '../../../../core/widgets/loading_indicator.dart';
|
||||||
import '../../../../core/widgets/error_widget.dart';
|
import '../../../../core/widgets/error_widget.dart';
|
||||||
import '../../../../core/widgets/empty_state.dart';
|
import '../../../../core/widgets/empty_state.dart';
|
||||||
|
import 'pos_product_card.dart';
|
||||||
|
|
||||||
/// Product selector widget for POS
|
/// Product selector widget for POS
|
||||||
class ProductSelector extends ConsumerWidget {
|
class ProductSelector extends ConsumerStatefulWidget {
|
||||||
final void Function(Product)? onProductTap;
|
final void Function(Product)? onProductTap;
|
||||||
|
|
||||||
const ProductSelector({
|
const ProductSelector({
|
||||||
@@ -17,7 +17,14 @@ class ProductSelector extends ConsumerWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<ProductSelector> createState() => _ProductSelectorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProductSelectorState extends ConsumerState<ProductSelector> {
|
||||||
|
String _searchQuery = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final productsAsync = ref.watch(productsProvider);
|
final productsAsync = ref.watch(productsProvider);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
@@ -30,6 +37,33 @@ class ProductSelector extends ConsumerWidget {
|
|||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
// Search Bar
|
||||||
|
TextField(
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search products...',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: _searchQuery.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = '';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: productsAsync.when(
|
child: productsAsync.when(
|
||||||
loading: () => const LoadingIndicator(
|
loading: () => const LoadingIndicator(
|
||||||
@@ -50,13 +84,26 @@ class ProductSelector extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filter only available products for POS
|
// Filter only available products for POS
|
||||||
final availableProducts =
|
var availableProducts =
|
||||||
products.where((p) => p.isAvailable).toList();
|
products.where((p) => p.isAvailable).toList();
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (_searchQuery.isNotEmpty) {
|
||||||
|
availableProducts = availableProducts.where((p) {
|
||||||
|
final query = _searchQuery.toLowerCase();
|
||||||
|
return p.name.toLowerCase().contains(query) ||
|
||||||
|
(p.description?.toLowerCase().contains(query) ?? false);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
if (availableProducts.isEmpty) {
|
if (availableProducts.isEmpty) {
|
||||||
return const EmptyState(
|
return EmptyState(
|
||||||
message: 'No products available',
|
message: _searchQuery.isNotEmpty
|
||||||
subMessage: 'All products are currently unavailable',
|
? 'No products found'
|
||||||
|
: 'No products available',
|
||||||
|
subMessage: _searchQuery.isNotEmpty
|
||||||
|
? 'Try a different search term'
|
||||||
|
: 'All products are currently unavailable',
|
||||||
icon: Icons.inventory_2_outlined,
|
icon: Icons.inventory_2_outlined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -81,9 +128,9 @@ class ProductSelector extends ConsumerWidget {
|
|||||||
itemCount: availableProducts.length,
|
itemCount: availableProducts.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final product = availableProducts[index];
|
final product = availableProducts[index];
|
||||||
return GestureDetector(
|
return PosProductCard(
|
||||||
onTap: () => onProductTap?.call(product),
|
product: product,
|
||||||
child: ProductCard(product: product),
|
onAddToCart: () => widget.onProductTap?.call(product),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ part of 'products_provider.dart';
|
|||||||
|
|
||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
// ignore_for_file: type=lint, type=warning
|
// ignore_for_file: type=lint, type=warning
|
||||||
/// Provider for products list with API-first approach
|
/// Provider for products list with online-first approach
|
||||||
|
|
||||||
@ProviderFor(Products)
|
@ProviderFor(Products)
|
||||||
const productsProvider = ProductsProvider._();
|
const productsProvider = ProductsProvider._();
|
||||||
|
|
||||||
/// Provider for products list with API-first approach
|
/// Provider for products list with online-first approach
|
||||||
final class ProductsProvider
|
final class ProductsProvider
|
||||||
extends $AsyncNotifierProvider<Products, List<Product>> {
|
extends $AsyncNotifierProvider<Products, List<Product>> {
|
||||||
/// Provider for products list with API-first approach
|
/// Provider for products list with online-first approach
|
||||||
const ProductsProvider._()
|
const ProductsProvider._()
|
||||||
: super(
|
: super(
|
||||||
from: null,
|
from: null,
|
||||||
@@ -38,7 +38,7 @@ final class ProductsProvider
|
|||||||
|
|
||||||
String _$productsHash() => r'0ff8c2de46bb4b1e29678cc811ec121c9fb4c8eb';
|
String _$productsHash() => r'0ff8c2de46bb4b1e29678cc811ec121c9fb4c8eb';
|
||||||
|
|
||||||
/// Provider for products list with API-first approach
|
/// Provider for products list with online-first approach
|
||||||
|
|
||||||
abstract class _$Products extends $AsyncNotifier<List<Product>> {
|
abstract class _$Products extends $AsyncNotifier<List<Product>> {
|
||||||
FutureOr<List<Product>> build();
|
FutureOr<List<Product>> build();
|
||||||
|
|||||||
Reference in New Issue
Block a user