Compare commits

...

6 Commits

Author SHA1 Message Date
Phuoc Nguyen
9189b65ebf fix 2025-10-23 17:03:58 +07:00
Phuoc Nguyen
30c245b401 fix 2025-10-21 16:45:52 +07:00
Phuoc Nguyen
9c20a44a04 add refresh token 2025-10-21 16:30:11 +07:00
b94a19dd3f jj 2025-10-21 15:48:26 +07:00
1cda00c0bf fix 2025-10-16 18:06:31 +07:00
7dc66d80fc fix 2025-10-16 17:22:27 +07:00
60 changed files with 2617 additions and 735 deletions

11
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true,
"**/.hg/store/**": true,
"**/.dart_tool": true,
"**/.git/**": true,
"**/node_modules/**": true,
"**/.vscode/**": true
}
}

View File

@@ -64,8 +64,8 @@ You have access to these expert subagents - USE THEM PROACTIVELY:
## Flutter Best Practices ## Flutter Best Practices
- Use Flutter 3.x features and Material 3 design - Use Flutter 3.x features and Material 3 design
- Implement clean architecture with Riverpod for state management - Implement clean architecture with Riverpod for state management
- Use Hive CE for local database and offline-first functionality - Use Hive CE for local database with **online-first** strategy (API first, cache fallback)
- Follow proper dependency injection with GetIt - Follow proper dependency injection with Riverpod providers
- Implement proper error handling and user feedback - Implement proper error handling and user feedback
- Follow platform-specific design guidelines - Follow platform-specific design guidelines
- Use proper localization for multi-language support - Use proper localization for multi-language support
@@ -453,7 +453,7 @@ A comprehensive Flutter-based Point of Sale (POS) application designed for retai
- Supplier state - Supplier state
**Data Requirements**: **Data Requirements**:
- Product list from Hive (offline-first) - Product list from API (online-first with Hive cache fallback)
- Product images (cached with variants) - Product images (cached with variants)
- Product search indexing - Product search indexing
- Category relationships - Category relationships
@@ -969,45 +969,63 @@ GridView.builder(
- Debounce search queries - Debounce search queries
- Optimize cart calculations - Optimize cart calculations
## Offline-First Strategy ## Online-First Strategy
### Data Flow ### Data Flow
1. **Read**: Always read from Hive first (instant UI) 1. **Check Connection**: Check if device is online
2. **Sync**: Background sync with API when online 2. **Try API First**: If online, fetch fresh data from API
3. **Update**: Update Hive and UI when sync completes 3. **Update Cache**: Save API response to Hive for offline access
4. **Conflict**: Handle conflicts with last-write-wins strategy 4. **Fallback to Cache**: If API fails or offline, load from Hive
5. **Show Data**: Display data to user (from API or cache)
### Sync Logic ### Implementation Pattern
```dart ```dart
@riverpod @riverpod
class DataSync extends _$DataSync { class Products extends _$Products {
@override @override
Future<SyncStatus> build() async { Future<List<Product>> build() async {
return await _performSync(); // Online-first: Try to load from API first
final repository = ref.watch(productRepositoryProvider);
final networkInfo = ref.watch(networkInfoProvider);
// Check if online
final isConnected = await networkInfo.isConnected;
if (isConnected) {
// Try API first
try {
final syncResult = await repository.syncProducts();
return syncResult.fold(
(failure) {
// API failed, fallback to cache
print('API failed, falling back to cache: ${failure.message}');
return _loadFromCache();
},
(products) => products,
);
} catch (e) {
// API error, fallback to cache
print('API error, falling back to cache: $e');
return _loadFromCache();
}
} else {
// Offline, load from cache
print('Offline, loading from cache');
return _loadFromCache();
}
} }
Future<SyncStatus> _performSync() async { Future<List<Product>> _loadFromCache() async {
if (!await ref.read(networkInfoProvider).isConnected) { final repository = ref.read(productRepositoryProvider);
return SyncStatus.offline; final result = await repository.getAllProducts();
}
try { return result.fold(
// Sync categories first (failure) {
await ref.read(categoriesProvider.notifier).syncCategories(); print('Cache load failed: ${failure.message}');
return <Product>[];
// Then sync products and variants },
await ref.read(productsProvider.notifier).syncProducts(); (products) => products,
);
// Sync suppliers
await ref.read(suppliersProvider.notifier).syncSuppliers();
// Update last sync time
await ref.read(settingsProvider.notifier).updateLastSync();
return SyncStatus.success;
} catch (e) {
return SyncStatus.failed;
}
} }
} }
``` ```
@@ -1134,7 +1152,7 @@ class DataSync extends _$DataSync {
### Code Review Checklist ### Code Review Checklist
- [ ] Follows clean architecture principles - [ ] Follows clean architecture principles
- [ ] Proper error handling implemented - [ ] Proper error handling implemented
- [ ] Offline-first approach maintained - [ ] **Online-first approach maintained** (API first, cache fallback)
- [ ] Performance optimizations applied - [ ] Performance optimizations applied
- [ ] Proper state management with Riverpod - [ ] Proper state management with Riverpod
- [ ] Hive models and adapters properly defined - [ ] Hive models and adapters properly defined

3
devtools_options.yaml Normal file
View 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:

File diff suppressed because one or more lines are too long

View File

@@ -31,11 +31,11 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/sqflite_darwin/darwin" :path: ".symlinks/plugins/sqflite_darwin/darwin"
SPEC CHECKSUMS: SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e

View File

@@ -1,15 +1,11 @@
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 'core/router/app_router.dart';
import 'core/theme/app_theme.dart'; import 'core/theme/app_theme.dart';
import 'features/auth/presentation/presentation.dart'; import 'features/auth/presentation/providers/auth_provider.dart';
import 'features/home/presentation/pages/home_page.dart';
import 'features/products/presentation/pages/products_page.dart';
import 'features/categories/presentation/pages/categories_page.dart';
import 'features/settings/presentation/pages/settings_page.dart';
import 'features/settings/presentation/providers/theme_provider.dart'; import 'features/settings/presentation/providers/theme_provider.dart';
import 'shared/widgets/app_bottom_nav.dart';
/// Root application widget with authentication wrapper /// Root application widget with go_router integration
class RetailApp extends ConsumerStatefulWidget { class RetailApp extends ConsumerStatefulWidget {
const RetailApp({super.key}); const RetailApp({super.key});
@@ -32,54 +28,15 @@ class _RetailAppState extends ConsumerState<RetailApp> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final themeMode = ref.watch(themeModeFromThemeProvider); final themeMode = ref.watch(themeModeFromThemeProvider);
final router = ref.watch(routerProvider);
return MaterialApp( return MaterialApp.router(
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 routerConfig: router,
home: const AuthWrapper(
child: MainScreen(),
),
);
}
}
/// Main screen with bottom navigation (only accessible after login)
class MainScreen extends ConsumerStatefulWidget {
const MainScreen({super.key});
@override
ConsumerState<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends ConsumerState<MainScreen> {
int _currentIndex = 0;
final List<Widget> _pages = const [
HomePage(),
ProductsPage(),
CategoriesPage(),
SettingsPage(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _currentIndex,
children: _pages,
),
bottomNavigationBar: AppBottomNav(
currentIndex: _currentIndex,
onTap: (index) {
setState(() {
_currentIndex = index;
});
},
),
); );
} }
} }

View File

@@ -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);
} }

View File

@@ -1,13 +1,16 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../constants/api_constants.dart'; import '../constants/api_constants.dart';
import '../storage/secure_storage.dart';
import 'api_interceptor.dart'; import 'api_interceptor.dart';
import 'refresh_token_interceptor.dart';
/// Dio HTTP client configuration /// Dio HTTP client configuration
class DioClient { class DioClient {
late final Dio _dio; late final Dio _dio;
String? _authToken; String? _authToken;
final SecureStorage? secureStorage;
DioClient() { DioClient({this.secureStorage}) {
_dio = Dio( _dio = Dio(
BaseOptions( BaseOptions(
baseUrl: ApiConstants.fullBaseUrl, baseUrl: ApiConstants.fullBaseUrl,
@@ -34,6 +37,17 @@ class DioClient {
}, },
), ),
); );
// Add refresh token interceptor (if secureStorage is provided)
if (secureStorage != null) {
_dio.interceptors.add(
RefreshTokenInterceptor(
dio: _dio,
secureStorage: secureStorage!,
),
);
print('🔧 DioClient: Refresh token interceptor added');
}
} }
Dio get dio => _dio; Dio get dio => _dio;

View File

@@ -0,0 +1,104 @@
import 'package:dio/dio.dart';
import '../constants/api_constants.dart';
import '../storage/secure_storage.dart';
/// Interceptor to handle automatic token refresh on 401 errors
class RefreshTokenInterceptor extends Interceptor {
final Dio dio;
final SecureStorage secureStorage;
// To prevent infinite loop of refresh attempts
bool _isRefreshing = false;
RefreshTokenInterceptor({
required this.dio,
required this.secureStorage,
});
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
// Check if error is 401 Unauthorized
if (err.response?.statusCode == 401) {
print('🔄 Interceptor: Got 401 error, attempting token refresh...');
// Avoid infinite refresh loop
if (_isRefreshing) {
print('❌ Interceptor: Already refreshing, skip');
return handler.next(err);
}
// Check if this is NOT the refresh token endpoint itself
final requestPath = err.requestOptions.path;
if (requestPath.contains('refresh')) {
print('❌ Interceptor: 401 on refresh endpoint, cannot retry');
// Clear tokens as refresh token is invalid
await secureStorage.deleteAllTokens();
return handler.next(err);
}
try {
_isRefreshing = true;
// Get refresh token from storage
final refreshToken = await secureStorage.getRefreshToken();
if (refreshToken == null) {
print('❌ Interceptor: No refresh token available');
await secureStorage.deleteAllTokens();
return handler.next(err);
}
print('🔄 Interceptor: Calling refresh token API...');
// Call refresh token API
final response = await dio.post(
ApiConstants.refreshToken,
data: {'refreshToken': refreshToken},
options: Options(
headers: {
// Don't include auth header for refresh request
ApiConstants.authorization: null,
},
),
);
if (response.statusCode == 200) {
// Extract new tokens from response
final responseData = response.data['data'] as Map<String, dynamic>;
final newAccessToken = responseData['access_token'] as String;
final newRefreshToken = responseData['refresh_token'] as String;
print('✅ Interceptor: Got new tokens, saving...');
// Save new tokens
await secureStorage.saveAccessToken(newAccessToken);
await secureStorage.saveRefreshToken(newRefreshToken);
// Update the failed request with new token
err.requestOptions.headers[ApiConstants.authorization] = 'Bearer $newAccessToken';
print('🔄 Interceptor: Retrying original request...');
// Retry the original request
final retryResponse = await dio.fetch(err.requestOptions);
print('✅ Interceptor: Original request succeeded after refresh');
_isRefreshing = false;
return handler.resolve(retryResponse);
} else {
print('❌ Interceptor: Refresh token API returned ${response.statusCode}');
await secureStorage.deleteAllTokens();
_isRefreshing = false;
return handler.next(err);
}
} catch (e) {
print('❌ Interceptor: Error during token refresh: $e');
await secureStorage.deleteAllTokens();
_isRefreshing = false;
return handler.next(err);
}
}
// Not a 401 error, pass through
return handler.next(err);
}
}

View File

@@ -7,10 +7,12 @@ part 'core_providers.g.dart';
/// Provider for DioClient (singleton) /// Provider for DioClient (singleton)
/// ///
/// This is the global HTTP client used across the entire app. /// This is the global HTTP client used across the entire app.
/// It's configured with interceptors, timeout settings, and auth token injection. /// It's configured with interceptors, timeout settings, auth token injection,
/// and automatic token refresh on 401 errors.
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
DioClient dioClient(Ref ref) { DioClient dioClient(Ref ref) {
return DioClient(); final storage = ref.watch(secureStorageProvider);
return DioClient(secureStorage: storage);
} }
/// Provider for SecureStorage (singleton) /// Provider for SecureStorage (singleton)

View File

@@ -11,7 +11,8 @@ part of 'core_providers.dart';
/// Provider for DioClient (singleton) /// Provider for DioClient (singleton)
/// ///
/// This is the global HTTP client used across the entire app. /// This is the global HTTP client used across the entire app.
/// It's configured with interceptors, timeout settings, and auth token injection. /// It's configured with interceptors, timeout settings, auth token injection,
/// and automatic token refresh on 401 errors.
@ProviderFor(dioClient) @ProviderFor(dioClient)
const dioClientProvider = DioClientProvider._(); const dioClientProvider = DioClientProvider._();
@@ -19,7 +20,8 @@ const dioClientProvider = DioClientProvider._();
/// Provider for DioClient (singleton) /// Provider for DioClient (singleton)
/// ///
/// This is the global HTTP client used across the entire app. /// This is the global HTTP client used across the entire app.
/// It's configured with interceptors, timeout settings, and auth token injection. /// It's configured with interceptors, timeout settings, auth token injection,
/// and automatic token refresh on 401 errors.
final class DioClientProvider final class DioClientProvider
extends $FunctionalProvider<DioClient, DioClient, DioClient> extends $FunctionalProvider<DioClient, DioClient, DioClient>
@@ -27,7 +29,8 @@ final class DioClientProvider
/// Provider for DioClient (singleton) /// Provider for DioClient (singleton)
/// ///
/// This is the global HTTP client used across the entire app. /// This is the global HTTP client used across the entire app.
/// It's configured with interceptors, timeout settings, and auth token injection. /// It's configured with interceptors, timeout settings, auth token injection,
/// and automatic token refresh on 401 errors.
const DioClientProvider._() const DioClientProvider._()
: super( : super(
from: null, from: null,
@@ -61,7 +64,7 @@ final class DioClientProvider
} }
} }
String _$dioClientHash() => r'895f0dc2f8d5eab562ad65390e5c6d4a1f722b0d'; String _$dioClientHash() => r'a9edc35e0e918bfa8e6c4e3ecd72412fba383cb2';
/// Provider for SecureStorage (singleton) /// Provider for SecureStorage (singleton)
/// ///

View File

@@ -1,10 +0,0 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../network/dio_client.dart';
part 'dio_client_provider.g.dart';
/// Provider for DioClient singleton
@Riverpod(keepAlive: true)
DioClient dioClient(Ref ref) {
return DioClient();
}

View File

@@ -1,55 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'dio_client_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider for DioClient singleton
@ProviderFor(dioClient)
const dioClientProvider = DioClientProvider._();
/// Provider for DioClient singleton
final class DioClientProvider
extends $FunctionalProvider<DioClient, DioClient, DioClient>
with $Provider<DioClient> {
/// Provider for DioClient singleton
const DioClientProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'dioClientProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$dioClientHash();
@$internal
@override
$ProviderElement<DioClient> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
DioClient create(Ref ref) {
return dioClient(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(DioClient value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<DioClient>(value),
);
}
}
String _$dioClientHash() => r'895f0dc2f8d5eab562ad65390e5c6d4a1f722b0d';

View File

@@ -2,4 +2,3 @@
export 'core_providers.dart'; export 'core_providers.dart';
export 'network_info_provider.dart'; export 'network_info_provider.dart';
export 'sync_status_provider.dart'; export 'sync_status_provider.dart';
export 'dio_client_provider.dart';

View File

@@ -0,0 +1,156 @@
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../features/auth/presentation/pages/login_page.dart';
import '../../features/auth/presentation/pages/register_page.dart';
import '../../features/auth/presentation/providers/auth_provider.dart';
import '../../features/auth/presentation/widgets/splash_screen.dart';
import '../../features/categories/presentation/pages/categories_page.dart';
import '../../features/categories/presentation/pages/category_detail_page.dart';
import '../../features/home/presentation/pages/home_page.dart';
import '../../features/products/presentation/pages/batch_update_page.dart';
import '../../features/products/presentation/pages/product_detail_page.dart';
import '../../features/products/presentation/pages/products_page.dart';
import '../../features/settings/presentation/pages/settings_page.dart';
import '../../shared/widgets/app_bottom_nav_shell.dart';
part 'app_router.g.dart';
/// Router configuration provider
@Riverpod(keepAlive: true)
GoRouter router(Ref ref) {
final authState = ref.watch(authProvider);
return GoRouter(
initialLocation: '/',
debugLogDiagnostics: true,
redirect: (context, state) {
final isAuthenticated = authState.isAuthenticated;
final isLoading = authState.isLoading && authState.user == null;
final isGoingToAuth = state.matchedLocation == '/login' ||
state.matchedLocation == '/register';
// Show splash screen while loading
if (isLoading) {
return '/splash';
}
// Redirect to login if not authenticated and not already going to auth pages
if (!isAuthenticated && !isGoingToAuth && state.matchedLocation != '/splash') {
return '/login';
}
// Redirect to home if authenticated and going to auth pages
if (isAuthenticated && isGoingToAuth) {
return '/';
}
return null;
},
routes: [
// Splash screen
GoRoute(
path: '/splash',
name: 'splash',
builder: (context, state) => const SplashScreen(),
),
// Auth routes
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: '/register',
name: 'register',
builder: (context, state) => const RegisterPage(),
),
// Main shell with bottom navigation
ShellRoute(
builder: (context, state, child) {
return AppBottomNavShell(child: child);
},
routes: [
// Home tab
GoRoute(
path: '/',
name: 'home',
pageBuilder: (context, state) => NoTransitionPage(
key: state.pageKey,
child: const HomePage(),
),
),
// Products tab
GoRoute(
path: '/products',
name: 'products',
pageBuilder: (context, state) => NoTransitionPage(
key: state.pageKey,
child: const ProductsPage(),
),
routes: [
// Product detail
GoRoute(
path: ':productId',
name: 'product-detail',
builder: (context, state) {
final productId = state.pathParameters['productId']!;
return ProductDetailPage(productId: productId);
},
),
// Batch update
GoRoute(
path: 'batch-update',
name: 'batch-update',
builder: (context, state) {
// Get selected products from extra parameter
final selectedProducts = state.extra as List<dynamic>?;
if (selectedProducts == null) {
// If no products provided, return to products page
return const ProductsPage();
}
return BatchUpdatePage(
selectedProducts: selectedProducts.cast(),
);
},
),
],
),
// Categories tab
GoRoute(
path: '/categories',
name: 'categories',
pageBuilder: (context, state) => NoTransitionPage(
key: state.pageKey,
child: const CategoriesPage(),
),
routes: [
// Category detail
GoRoute(
path: ':categoryId',
name: 'category-detail',
builder: (context, state) {
final categoryId = state.pathParameters['categoryId']!;
return CategoryDetailPage(categoryId: categoryId);
},
),
],
),
// Settings tab
GoRoute(
path: '/settings',
name: 'settings',
pageBuilder: (context, state) => NoTransitionPage(
key: state.pageKey,
child: const SettingsPage(),
),
),
],
),
],
);
}

View File

@@ -0,0 +1,55 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_router.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Router configuration provider
@ProviderFor(router)
const routerProvider = RouterProvider._();
/// Router configuration provider
final class RouterProvider
extends $FunctionalProvider<GoRouter, GoRouter, GoRouter>
with $Provider<GoRouter> {
/// Router configuration provider
const RouterProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'routerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$routerHash();
@$internal
@override
$ProviderElement<GoRouter> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
GoRouter create(Ref ref) {
return router(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(GoRouter value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<GoRouter>(value),
);
}
}
String _$routerHash() => r'3c7108371f8529a70e1e479728e8da132246bab4';

View File

@@ -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,
), ),
); );
} }

View File

@@ -27,7 +27,7 @@ class EmptyState extends StatelessWidget {
children: [ children: [
Icon( Icon(
icon ?? Icons.inbox_outlined, icon ?? Icons.inbox_outlined,
size: 50, size: 48,
color: Theme.of(context).colorScheme.outline, color: Theme.of(context).colorScheme.outline,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),

View File

@@ -18,8 +18,8 @@ abstract class AuthRemoteDataSource {
/// Get current user profile /// Get current user profile
Future<UserModel> getProfile(); Future<UserModel> getProfile();
/// Refresh access token /// Refresh access token using refresh token
Future<AuthResponseModel> refreshToken(); Future<AuthResponseModel> refreshToken(String refreshToken);
} }
/// Implementation of AuthRemoteDataSource /// Implementation of AuthRemoteDataSource
@@ -119,21 +119,28 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
} }
@override @override
Future<AuthResponseModel> refreshToken() async { Future<AuthResponseModel> refreshToken(String refreshToken) async {
try { try {
final response = await dioClient.post(ApiConstants.refreshToken); print('📡 DataSource: Calling refresh token API...');
final response = await dioClient.post(
ApiConstants.refreshToken,
data: {'refreshToken': refreshToken},
);
if (response.statusCode == ApiConstants.statusOk) { if (response.statusCode == ApiConstants.statusOk) {
// API returns nested structure: {success, data: {access_token, user}, message} // API returns nested structure: {success, data: {access_token, refresh_token, user}, message}
// Extract the 'data' object // Extract the 'data' object
final responseData = response.data['data'] as Map<String, dynamic>; final responseData = response.data['data'] as Map<String, dynamic>;
print('📡 DataSource: Token refreshed successfully');
return AuthResponseModel.fromJson(responseData); return AuthResponseModel.fromJson(responseData);
} else { } else {
throw ServerException('Token refresh failed with status: ${response.statusCode}'); throw ServerException('Token refresh failed with status: ${response.statusCode}');
} }
} on DioException catch (e) { } on DioException catch (e) {
print('❌ DataSource: Refresh token failed - ${e.message}');
throw _handleDioError(e); throw _handleDioError(e);
} catch (e) { } catch (e) {
print('❌ DataSource: Unexpected error refreshing token: $e');
throw ServerException('Unexpected error refreshing token: $e'); throw ServerException('Unexpected error refreshing token: $e');
} }
} }

View File

@@ -5,6 +5,7 @@ import 'user_model.dart';
class AuthResponseModel extends AuthResponse { class AuthResponseModel extends AuthResponse {
const AuthResponseModel({ const AuthResponseModel({
required super.accessToken, required super.accessToken,
required super.refreshToken,
required super.user, required super.user,
}); });
@@ -12,6 +13,7 @@ class AuthResponseModel extends AuthResponse {
factory AuthResponseModel.fromJson(Map<String, dynamic> json) { factory AuthResponseModel.fromJson(Map<String, dynamic> json) {
return AuthResponseModel( return AuthResponseModel(
accessToken: json['access_token'] as String, accessToken: json['access_token'] as String,
refreshToken: json['refresh_token'] as String,
user: UserModel.fromJson(json['user'] as Map<String, dynamic>), user: UserModel.fromJson(json['user'] as Map<String, dynamic>),
); );
} }
@@ -20,6 +22,7 @@ class AuthResponseModel extends AuthResponse {
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'access_token': accessToken, 'access_token': accessToken,
'refresh_token': refreshToken,
'user': (user as UserModel).toJson(), 'user': (user as UserModel).toJson(),
}; };
} }
@@ -28,6 +31,7 @@ class AuthResponseModel extends AuthResponse {
factory AuthResponseModel.fromEntity(AuthResponse authResponse) { factory AuthResponseModel.fromEntity(AuthResponse authResponse) {
return AuthResponseModel( return AuthResponseModel(
accessToken: authResponse.accessToken, accessToken: authResponse.accessToken,
refreshToken: authResponse.refreshToken,
user: authResponse.user, user: authResponse.user,
); );
} }
@@ -36,6 +40,7 @@ class AuthResponseModel extends AuthResponse {
AuthResponse toEntity() { AuthResponse toEntity() {
return AuthResponse( return AuthResponse(
accessToken: accessToken, accessToken: accessToken,
refreshToken: refreshToken,
user: user, user: user,
); );
} }

View File

@@ -35,12 +35,13 @@ class AuthRepositoryImpl implements AuthRepository {
print('🔐 Repository: Got response, token length=${authResponse.accessToken.length}'); print('🔐 Repository: Got response, token length=${authResponse.accessToken.length}');
// Save token to secure storage only if rememberMe is true // Save tokens to secure storage only if rememberMe is true
if (rememberMe) { if (rememberMe) {
await secureStorage.saveAccessToken(authResponse.accessToken); await secureStorage.saveAccessToken(authResponse.accessToken);
print('🔐 Repository: Token saved to secure storage (persistent)'); await secureStorage.saveRefreshToken(authResponse.refreshToken);
print('🔐 Repository: Access token and refresh token saved to secure storage (persistent)');
} else { } else {
print('🔐 Repository: Token NOT saved (session only - rememberMe is false)'); print('🔐 Repository: Tokens NOT saved (session only - rememberMe is false)');
} }
// Set token in Dio client for subsequent requests (always for current session) // Set token in Dio client for subsequent requests (always for current session)
@@ -86,8 +87,9 @@ class AuthRepositoryImpl implements AuthRepository {
); );
final authResponse = await remoteDataSource.register(registerDto); final authResponse = await remoteDataSource.register(registerDto);
// Save token to secure storage // Save both tokens to secure storage
await secureStorage.saveAccessToken(authResponse.accessToken); await secureStorage.saveAccessToken(authResponse.accessToken);
await secureStorage.saveRefreshToken(authResponse.refreshToken);
// Set token in Dio client for subsequent requests // Set token in Dio client for subsequent requests
dioClient.setAuthToken(authResponse.accessToken); dioClient.setAuthToken(authResponse.accessToken);
@@ -127,24 +129,44 @@ class AuthRepositoryImpl implements AuthRepository {
@override @override
Future<Either<Failure, AuthResponse>> refreshToken() async { Future<Either<Failure, AuthResponse>> refreshToken() async {
try { try {
final authResponse = await remoteDataSource.refreshToken(); print('🔄 Repository: Starting token refresh...');
// Update token in secure storage // Get refresh token from storage
final storedRefreshToken = await secureStorage.getRefreshToken();
if (storedRefreshToken == null) {
print('❌ Repository: No refresh token found in storage');
return const Left(UnauthorizedFailure('No refresh token available'));
}
print('🔄 Repository: Calling datasource with refresh token...');
final authResponse = await remoteDataSource.refreshToken(storedRefreshToken);
// Update both tokens in secure storage (token rotation)
await secureStorage.saveAccessToken(authResponse.accessToken); await secureStorage.saveAccessToken(authResponse.accessToken);
await secureStorage.saveRefreshToken(authResponse.refreshToken);
print('🔄 Repository: New tokens saved to secure storage');
// Update token in Dio client // Update token in Dio client
dioClient.setAuthToken(authResponse.accessToken); dioClient.setAuthToken(authResponse.accessToken);
print('🔄 Repository: New access token set in DioClient');
return Right(authResponse); return Right(authResponse);
} on UnauthorizedException catch (e) { } on UnauthorizedException catch (e) {
print('❌ Repository: Unauthorized during refresh - ${e.message}');
// Clear invalid tokens
await secureStorage.deleteAllTokens();
return Left(UnauthorizedFailure(e.message)); return Left(UnauthorizedFailure(e.message));
} on TokenExpiredException catch (e) { } on TokenExpiredException catch (e) {
print('❌ Repository: Token expired during refresh - ${e.message}');
// Clear expired tokens
await secureStorage.deleteAllTokens();
return Left(TokenExpiredFailure(e.message)); return Left(TokenExpiredFailure(e.message));
} on NetworkException catch (e) { } on NetworkException catch (e) {
return Left(NetworkFailure(e.message)); return Left(NetworkFailure(e.message));
} on ServerException catch (e) { } on ServerException catch (e) {
return Left(ServerFailure(e.message)); return Left(ServerFailure(e.message));
} catch (e) { } catch (e) {
print('❌ Repository: Unexpected error during refresh: $e');
return Left(ServerFailure('Unexpected error: $e')); return Left(ServerFailure('Unexpected error: $e'));
} }
} }

View File

@@ -4,13 +4,15 @@ import 'user.dart';
/// Authentication response entity /// Authentication response entity
class AuthResponse extends Equatable { class AuthResponse extends Equatable {
final String accessToken; final String accessToken;
final String refreshToken;
final User user; final User user;
const AuthResponse({ const AuthResponse({
required this.accessToken, required this.accessToken,
required this.refreshToken,
required this.user, required this.user,
}); });
@override @override
List<Object?> get props => [accessToken, user]; List<Object?> get props => [accessToken, refreshToken, user];
} }

View File

@@ -1,9 +1,9 @@
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 'package:go_router/go_router.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../widgets/widgets.dart'; import '../widgets/widgets.dart';
import '../utils/validators.dart'; import '../utils/validators.dart';
import 'register_page.dart';
/// Login page with email and password authentication /// Login page with email and password authentication
class LoginPage extends ConsumerStatefulWidget { class LoginPage extends ConsumerStatefulWidget {
@@ -66,11 +66,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
} }
void _navigateToRegister() { void _navigateToRegister() {
Navigator.of(context).push( context.push('/register');
MaterialPageRoute(
builder: (context) => const RegisterPage(),
),
);
} }
void _handleForgotPassword() { void _handleForgotPassword() {
@@ -164,6 +160,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(

View File

@@ -1,5 +1,6 @@
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 'package:go_router/go_router.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../widgets/widgets.dart'; import '../widgets/widgets.dart';
import '../utils/validators.dart'; import '../utils/validators.dart';
@@ -90,7 +91,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
} }
void _navigateBackToLogin() { void _navigateBackToLogin() {
Navigator.of(context).pop(); context.pop();
} }
@override @override

View File

@@ -64,9 +64,9 @@ class AuthState {
class Auth extends _$Auth { class Auth extends _$Auth {
@override @override
AuthState build() { AuthState build() {
// Don't call async operations in build // Start with loading state to show splash screen
// Use a separate method to initialize auth state // Use a separate method to initialize auth state
return const AuthState(); return const AuthState(isLoading: true);
} }
AuthRepository get _repository => ref.read(authRepositoryProvider); AuthRepository get _repository => ref.read(authRepositoryProvider);
@@ -74,7 +74,9 @@ class Auth extends _$Auth {
/// Initialize auth state - call this on app start /// Initialize auth state - call this on app start
Future<void> initialize() async { Future<void> initialize() async {
print('🚀 Initializing auth state...'); print('🚀 Initializing auth state...');
state = state.copyWith(isLoading: true);
// Minimum loading time for smooth UX (prevent flashing)
final minimumLoadingTime = Future.delayed(const Duration(milliseconds: 800));
final isAuthenticated = await _repository.isAuthenticated(); final isAuthenticated = await _repository.isAuthenticated();
print('🚀 isAuthenticated result: $isAuthenticated'); print('🚀 isAuthenticated result: $isAuthenticated');
@@ -83,6 +85,10 @@ class Auth extends _$Auth {
print('🚀 Token found, fetching user profile...'); print('🚀 Token found, fetching user profile...');
// Get user profile // Get user profile
final result = await _repository.getProfile(); final result = await _repository.getProfile();
// Wait for minimum loading time to complete
await minimumLoadingTime;
result.fold( result.fold(
(failure) { (failure) {
print('❌ Failed to get profile: ${failure.message}'); print('❌ Failed to get profile: ${failure.message}');
@@ -103,6 +109,10 @@ class Auth extends _$Auth {
); );
} else { } else {
print('❌ No token found, user needs to login'); print('❌ No token found, user needs to login');
// Wait for minimum loading time even when not authenticated
await minimumLoadingTime;
state = const AuthState( state = const AuthState(
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,

View File

@@ -142,7 +142,7 @@ final class AuthProvider extends $NotifierProvider<Auth, AuthState> {
} }
} }
String _$authHash() => r'73c9e7b70799eba2904eb6fc65454332d4146a33'; String _$authHash() => r'24ad5a5313febf1a3ac2550adaf19f34098a8f7c';
/// Auth state notifier provider /// Auth state notifier provider

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../pages/login_page.dart'; import '../pages/login_page.dart';
import 'splash_screen.dart';
/// Wrapper widget that checks authentication status /// Wrapper widget that checks authentication status
/// Shows login page if not authenticated, otherwise shows child widget /// Shows login page if not authenticated, otherwise shows child widget
@@ -16,21 +17,27 @@ class AuthWrapper extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider); final authState = ref.watch(authProvider);
print('AuthWrapper build: isAuthenticated=${authState.isAuthenticated}, isLoading=${authState.isLoading}'); print('AuthWrapper build: isAuthenticated=${authState.isAuthenticated}, isLoading=${authState.isLoading}');
// Show loading indicator while checking auth status
// Show splash screen while checking auth status
if (authState.isLoading && authState.user == null) { if (authState.isLoading && authState.user == null) {
return const Scaffold( return const SplashScreen();
body: Center(
child: CircularProgressIndicator(),
),
);
} }
// Show child widget if authenticated, otherwise show login page // Smooth fade transition between screens
if (authState.isAuthenticated) { return AnimatedSwitcher(
return child; duration: const Duration(milliseconds: 400),
} else { switchInCurve: Curves.easeInOut,
return const LoginPage(); switchOutCurve: Curves.easeInOut,
} child: authState.isAuthenticated
? KeyedSubtree(
key: const ValueKey('main_app'),
child: child,
)
: const KeyedSubtree(
key: ValueKey('login_page'),
child: LoginPage(),
),
);
} }
} }

View File

@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
/// Splash screen shown while checking authentication status
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeIn,
),
);
_scaleAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeOutBack,
),
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: theme.colorScheme.primary,
body: SafeArea(
child: Center(
child: FadeTransition(
opacity: _fadeAnimation,
child: ScaleTransition(
scale: _scaleAnimation,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// App Icon/Logo
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Icon(
Icons.point_of_sale_rounded,
size: 64,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 32),
// App Name
Text(
'Retail POS',
style: theme.textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
const SizedBox(height: 8),
// Subtitle
Text(
'Point of Sale System',
style: theme.textTheme.bodyLarge?.copyWith(
color: Colors.white.withOpacity(0.9),
letterSpacing: 0.5,
),
),
const SizedBox(height: 48),
// Loading Indicator
SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white.withOpacity(0.9),
),
),
),
const SizedBox(height: 16),
// Loading Text
Text(
'Loading...',
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.white.withOpacity(0.8),
),
),
],
),
),
),
),
),
);
}
}

View File

@@ -6,6 +6,7 @@ abstract class CategoryLocalDataSource {
Future<List<CategoryModel>> getAllCategories(); Future<List<CategoryModel>> getAllCategories();
Future<CategoryModel?> getCategoryById(String id); Future<CategoryModel?> getCategoryById(String id);
Future<void> cacheCategories(List<CategoryModel> categories); Future<void> cacheCategories(List<CategoryModel> categories);
Future<void> updateCategory(CategoryModel category);
Future<void> clearCategories(); Future<void> clearCategories();
} }
@@ -30,6 +31,11 @@ class CategoryLocalDataSourceImpl implements CategoryLocalDataSource {
await box.putAll(categoryMap); await box.putAll(categoryMap);
} }
@override
Future<void> updateCategory(CategoryModel category) async {
await box.put(category.id, category);
}
@override @override
Future<void> clearCategories() async { Future<void> clearCategories() async {
await box.clear(); await box.clear();

View File

@@ -25,10 +25,10 @@ class CategoryModel extends HiveObject {
final int productCount; final int productCount;
@HiveField(6) @HiveField(6)
final DateTime createdAt; final DateTime? createdAt;
@HiveField(7) @HiveField(7)
final DateTime updatedAt; final DateTime? updatedAt;
CategoryModel({ CategoryModel({
required this.id, required this.id,
@@ -37,8 +37,8 @@ class CategoryModel extends HiveObject {
this.iconPath, this.iconPath,
this.color, this.color,
required this.productCount, required this.productCount,
required this.createdAt, this.createdAt,
required this.updatedAt, this.updatedAt,
}); });
/// Convert to domain entity /// Convert to domain entity
@@ -78,8 +78,12 @@ class CategoryModel extends HiveObject {
iconPath: json['iconPath'] as String?, iconPath: json['iconPath'] as String?,
color: json['color'] as String?, color: json['color'] as String?,
productCount: json['productCount'] as int? ?? 0, productCount: json['productCount'] as int? ?? 0,
createdAt: DateTime.parse(json['createdAt'] as String), createdAt: json['createdAt'] != null
updatedAt: DateTime.parse(json['updatedAt'] as String), ? DateTime.parse(json['createdAt'] as String)
: null,
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'] as String)
: null,
); );
} }
@@ -92,8 +96,8 @@ class CategoryModel extends HiveObject {
'iconPath': iconPath, 'iconPath': iconPath,
'color': color, 'color': color,
'productCount': productCount, 'productCount': productCount,
'createdAt': createdAt.toIso8601String(), 'createdAt': createdAt?.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(), 'updatedAt': updatedAt?.toIso8601String(),
}; };
} }

View File

@@ -23,8 +23,8 @@ class CategoryModelAdapter extends TypeAdapter<CategoryModel> {
iconPath: fields[3] as String?, iconPath: fields[3] as String?,
color: fields[4] as String?, color: fields[4] as String?,
productCount: (fields[5] as num).toInt(), productCount: (fields[5] as num).toInt(),
createdAt: fields[6] as DateTime, createdAt: fields[6] as DateTime?,
updatedAt: fields[7] as DateTime, updatedAt: fields[7] as DateTime?,
); );
} }

View File

@@ -18,8 +18,27 @@ class CategoryRepositoryImpl implements CategoryRepository {
@override @override
Future<Either<Failure, List<Category>>> getAllCategories() async { Future<Either<Failure, List<Category>>> getAllCategories() async {
try { try {
final categories = await localDataSource.getAllCategories(); // Try remote first (online-first)
final categories = await remoteDataSource.getAllCategories();
// Cache the results
await localDataSource.cacheCategories(categories);
return Right(categories.map((model) => model.toEntity()).toList()); return Right(categories.map((model) => model.toEntity()).toList());
} on ServerException catch (e) {
// Remote failed, try local cache
try {
final cachedCategories = await localDataSource.getAllCategories();
return Right(cachedCategories.map((model) => model.toEntity()).toList());
} on CacheException catch (cacheError) {
return Left(ServerFailure('${e.message}. Cache also unavailable.'));
}
} on NetworkException catch (e) {
// Network failed, try local cache
try {
final cachedCategories = await localDataSource.getAllCategories();
return Right(cachedCategories.map((model) => model.toEntity()).toList());
} on CacheException catch (cacheError) {
return Left(NetworkFailure('${e.message}. Cache also unavailable.'));
}
} on CacheException catch (e) { } on CacheException catch (e) {
return Left(CacheFailure(e.message)); return Left(CacheFailure(e.message));
} }
@@ -28,11 +47,33 @@ class CategoryRepositoryImpl implements CategoryRepository {
@override @override
Future<Either<Failure, Category>> getCategoryById(String id) async { Future<Either<Failure, Category>> getCategoryById(String id) async {
try { try {
final category = await localDataSource.getCategoryById(id); // Try remote first (online-first)
if (category == null) { final category = await remoteDataSource.getCategoryById(id);
return Left(NotFoundFailure('Category not found')); // Cache the result
} await localDataSource.updateCategory(category);
return Right(category.toEntity()); return Right(category.toEntity());
} on ServerException catch (e) {
// Remote failed, try local cache
try {
final cachedCategory = await localDataSource.getCategoryById(id);
if (cachedCategory == null) {
return Left(NotFoundFailure('Category not found in cache'));
}
return Right(cachedCategory.toEntity());
} on CacheException catch (cacheError) {
return Left(ServerFailure('${e.message}. Cache also unavailable.'));
}
} on NetworkException catch (e) {
// Network failed, try local cache
try {
final cachedCategory = await localDataSource.getCategoryById(id);
if (cachedCategory == null) {
return Left(NotFoundFailure('Category not found in cache'));
}
return Right(cachedCategory.toEntity());
} on CacheException catch (cacheError) {
return Left(NetworkFailure('${e.message}. Cache also unavailable.'));
}
} on CacheException catch (e) { } on CacheException catch (e) {
return Left(CacheFailure(e.message)); return Left(CacheFailure(e.message));
} }

View File

@@ -8,8 +8,8 @@ class Category extends Equatable {
final String? iconPath; final String? iconPath;
final String? color; final String? color;
final int productCount; final int productCount;
final DateTime createdAt; final DateTime? createdAt;
final DateTime updatedAt; final DateTime? updatedAt;
const Category({ const Category({
required this.id, required this.id,
@@ -18,8 +18,8 @@ class Category extends Equatable {
this.iconPath, this.iconPath,
this.color, this.color,
required this.productCount, required this.productCount,
required this.createdAt, this.createdAt,
required this.updatedAt, this.updatedAt,
}); });
@override @override

View File

@@ -1,6 +1,7 @@
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 '../../domain/entities/category.dart'; import '../../domain/entities/category.dart';
import '../providers/categories_provider.dart';
import '../../../products/presentation/providers/products_provider.dart'; import '../../../products/presentation/providers/products_provider.dart';
import '../../../products/presentation/widgets/product_card.dart'; import '../../../products/presentation/widgets/product_card.dart';
import '../../../products/presentation/widgets/product_list_item.dart'; import '../../../products/presentation/widgets/product_list_item.dart';
@@ -10,11 +11,11 @@ enum ViewMode { grid, list }
/// Category detail page showing products in the category /// Category detail page showing products in the category
class CategoryDetailPage extends ConsumerStatefulWidget { class CategoryDetailPage extends ConsumerStatefulWidget {
final Category category; final String categoryId;
const CategoryDetailPage({ const CategoryDetailPage({
super.key, super.key,
required this.category, required this.categoryId,
}); });
@override @override
@@ -26,80 +27,178 @@ class _CategoryDetailPageState extends ConsumerState<CategoryDetailPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final categoriesAsync = ref.watch(categoriesProvider);
final productsAsync = ref.watch(productsProvider); final productsAsync = ref.watch(productsProvider);
return Scaffold( return categoriesAsync.when(
appBar: AppBar( data: (categories) {
title: Text(widget.category.name), // Find the category by ID
actions: [ Category? category;
// View mode toggle try {
IconButton( category = categories.firstWhere((c) => c.id == widget.categoryId);
icon: Icon( } catch (e) {
_viewMode == ViewMode.grid // Category not found
? Icons.view_list_rounded category = null;
: Icons.grid_view_rounded, }
),
onPressed: () {
setState(() {
_viewMode =
_viewMode == ViewMode.grid ? ViewMode.list : ViewMode.grid;
});
},
tooltip: _viewMode == ViewMode.grid
? 'Switch to list view'
: 'Switch to grid view',
),
],
),
body: productsAsync.when(
data: (products) {
// Filter products by category
final categoryProducts = products
.where((product) => product.categoryId == widget.category.id)
.toList();
if (categoryProducts.isEmpty) { // Handle category not found
return Center( if (category == null) {
return Scaffold(
appBar: AppBar(
title: const Text('Category Not Found'),
),
body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
Icons.inventory_2_outlined, Icons.error_outline,
size: 64, size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.error,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No products in this category', 'Category not found',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Products will appear here once added', 'The category you are looking for does not exist',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall,
color: Theme.of(context).colorScheme.onSurfaceVariant, textAlign: TextAlign.center,
), ),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () {
Navigator.of(context).pop();
},
icon: const Icon(Icons.arrow_back),
label: const Text('Go Back'),
), ),
], ],
), ),
); ),
}
return RefreshIndicator(
onRefresh: () async {
await ref.read(productsProvider.notifier).syncProducts();
},
child: _viewMode == ViewMode.grid
? _buildGridView(categoryProducts)
: _buildListView(categoryProducts),
); );
}, }
loading: () => const Center(
return Scaffold(
appBar: AppBar(
title: Text(category.name),
actions: [
// View mode toggle
IconButton(
icon: Icon(
_viewMode == ViewMode.grid
? Icons.view_list_rounded
: Icons.grid_view_rounded,
),
onPressed: () {
setState(() {
_viewMode =
_viewMode == ViewMode.grid ? ViewMode.list : ViewMode.grid;
});
},
tooltip: _viewMode == ViewMode.grid
? 'Switch to list view'
: 'Switch to grid view',
),
],
),
body: productsAsync.when(
data: (products) {
// Filter products by category
final categoryProducts = products
.where((product) => product.categoryId == category!.id)
.toList();
if (categoryProducts.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_outlined,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No products in this category',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Products will appear here once added',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
await ref.read(productsProvider.notifier).syncProducts();
},
child: _viewMode == ViewMode.grid
? _buildGridView(categoryProducts)
: _buildListView(categoryProducts),
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Error loading products',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
error.toString(),
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () {
ref.invalidate(productsProvider);
},
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
),
);
},
loading: () => Scaffold(
appBar: AppBar(
title: const Text('Loading...'),
),
body: const Center(
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
), ),
error: (error, stack) => Center( ),
error: (error, stack) => Scaffold(
appBar: AppBar(
title: const Text('Error'),
),
body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@@ -110,7 +209,7 @@ class _CategoryDetailPageState extends ConsumerState<CategoryDetailPage> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Error loading products', 'Error loading category',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -122,7 +221,7 @@ class _CategoryDetailPageState extends ConsumerState<CategoryDetailPage> {
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon( FilledButton.icon(
onPressed: () { onPressed: () {
ref.invalidate(productsProvider); ref.invalidate(categoriesProvider);
}, },
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
label: const Text('Retry'), label: const Text('Retry'),

View File

@@ -5,12 +5,13 @@ import '../../../../core/providers/providers.dart';
part 'categories_provider.g.dart'; part 'categories_provider.g.dart';
/// Provider for categories list with API-first approach /// Provider for categories list with online-first approach
@riverpod /// keepAlive ensures data persists when switching tabs
@Riverpod(keepAlive: true)
class Categories extends _$Categories { class Categories extends _$Categories {
@override @override
Future<List<Category>> build() async { Future<List<Category>> build() async {
// API-first: Try to load from API first // Online-first: Try to load from API first
final repository = ref.watch(categoryRepositoryProvider); final repository = ref.watch(categoryRepositoryProvider);
final networkInfo = ref.watch(networkInfoProvider); final networkInfo = ref.watch(networkInfoProvider);
@@ -90,18 +91,3 @@ class Categories extends _$Categories {
}); });
} }
} }
/// Provider for selected category
@riverpod
class SelectedCategory extends _$SelectedCategory {
@override
String? build() => null;
void select(String? categoryId) {
state = categoryId;
}
void clear() {
state = null;
}
}

View File

@@ -8,22 +8,25 @@ 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
/// keepAlive ensures data persists when switching tabs
@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
/// keepAlive ensures data persists when switching tabs
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
/// keepAlive ensures data persists when switching tabs
const CategoriesProvider._() const CategoriesProvider._()
: super( : super(
from: null, from: null,
argument: null, argument: null,
retry: null, retry: null,
name: r'categoriesProvider', name: r'categoriesProvider',
isAutoDispose: true, isAutoDispose: false,
dependencies: null, dependencies: null,
$allTransitiveDependencies: null, $allTransitiveDependencies: null,
); );
@@ -36,9 +39,10 @@ final class CategoriesProvider
Categories create() => Categories(); Categories create() => Categories();
} }
String _$categoriesHash() => r'33c33b08f8926e5bbbd112285591c74a3ff0f61c'; String _$categoriesHash() => r'c26eb4b4a76ce796eb65b7843a390805528dec4a';
/// Provider for categories list with API-first approach /// Provider for categories list with online-first approach
/// keepAlive ensures data persists when switching tabs
abstract class _$Categories extends $AsyncNotifier<List<Category>> { abstract class _$Categories extends $AsyncNotifier<List<Category>> {
FutureOr<List<Category>> build(); FutureOr<List<Category>> build();
@@ -58,62 +62,3 @@ abstract class _$Categories extends $AsyncNotifier<List<Category>> {
element.handleValue(ref, created); element.handleValue(ref, created);
} }
} }
/// Provider for selected category
@ProviderFor(SelectedCategory)
const selectedCategoryProvider = SelectedCategoryProvider._();
/// Provider for selected category
final class SelectedCategoryProvider
extends $NotifierProvider<SelectedCategory, String?> {
/// Provider for selected category
const SelectedCategoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedCategoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedCategoryHash();
@$internal
@override
SelectedCategory create() => SelectedCategory();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String?>(value),
);
}
}
String _$selectedCategoryHash() => r'a47cd2de07ad285d4b73b2294ba954cb1cdd8e4c';
/// Provider for selected category
abstract class _$SelectedCategory extends $Notifier<String?> {
String? build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<String?, String?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String?, String?>,
String?,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../domain/entities/category.dart'; import '../../domain/entities/category.dart';
import '../pages/category_detail_page.dart';
/// Category card widget /// Category card widget
class CategoryCard extends StatelessWidget { class CategoryCard extends StatelessWidget {
@@ -22,12 +22,7 @@ class CategoryCard extends StatelessWidget {
child: InkWell( child: InkWell(
onTap: () { onTap: () {
// Navigate to category detail page // Navigate to category detail page
Navigator.push( context.push('/categories/${category.id}');
context,
MaterialPageRoute(
builder: (context) => CategoryDetailPage(category: category),
),
);
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),

View File

@@ -1,169 +1,405 @@
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 'package:cached_network_image/cached_network_image.dart';
import '../widgets/cart_summary.dart'; import '../../../products/presentation/providers/products_provider.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/config/image_cache_config.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();
}
class _HomePageState extends ConsumerState<HomePage> {
String _searchQuery = '';
@override
Widget build(BuildContext context) {
final productsAsync = ref.watch(productsProvider);
final categoriesAsync = ref.watch(categoriesProvider);
final selectedCategory = ref.watch(selectedCategoryProvider);
final cartAsync = ref.watch(cartProvider); final cartAsync = ref.watch(cartProvider);
final isWideScreen = MediaQuery.of(context).size.width > 600; final totalData = ref.watch(cartTotalProvider);
final theme = Theme.of(context);
return Scaffold( final cartItems = cartAsync.value ?? [];
appBar: AppBar( final itemCount = cartItems.length;
title: const Text('Point of Sale'),
actions: [
// Cart item count badge
cartAsync.whenOrNull(
data: (items) => items.isNotEmpty
? Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Center(
child: Badge(
label: Text('${items.length}'),
child: const Icon(Icons.shopping_cart),
),
),
)
: 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( return SafeArea(
BuildContext context, bottom: false,
WidgetRef ref, child: Scaffold(
dynamic product, backgroundColor: theme.colorScheme.surfaceContainerLowest,
) {
int quantity = 1;
showDialog( body: Column(
context: context, children: [
builder: (context) => StatefulBuilder( // Search bar
builder: (context, setState) => AlertDialog( Container(
title: const Text('Add to Cart'), padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
content: Column( color: theme.colorScheme.surface,
mainAxisSize: MainAxisSize.min, child: TextField(
children: [ onChanged: (value) {
Text( setState(() {
product.name, _searchQuery = value;
style: Theme.of(context).textTheme.titleMedium, });
), },
const SizedBox(height: 16), decoration: InputDecoration(
Row( hintText: 'Search Menu',
mainAxisAlignment: MainAxisAlignment.center, prefixIcon: const Icon(Icons.search, size: 20),
children: [ suffixIcon: _searchQuery.isNotEmpty
IconButton( ? IconButton(
icon: const Icon(Icons.remove_circle_outline), icon: const Icon(Icons.clear, size: 20),
onPressed: quantity > 1 onPressed: () {
? () => setState(() => quantity--) setState(() {
: null, _searchQuery = '';
), });
Padding( },
padding: const EdgeInsets.symmetric(horizontal: 16.0), )
child: Text( : IconButton(
'$quantity', icon: const Icon(Icons.tune, size: 20),
style: Theme.of(context).textTheme.headlineSmall, onPressed: () {
), // TODO: Show filters
), },
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,
), ),
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,
),
), ),
), ),
], ),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
), ),
FilledButton.icon(
onPressed: () {
// Create cart item from product
final cartItem = CartItem(
productId: product.id,
productName: product.name,
price: product.price,
quantity: quantity,
imageUrl: product.imageUrl,
addedAt: DateTime.now(),
);
// Add to cart // Category filter buttons
ref.read(cartProvider.notifier).addItem(cartItem); categoriesAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
data: (categories) {
if (categories.isEmpty) return const SizedBox.shrink();
Navigator.pop(context); return Container(
ScaffoldMessenger.of(context).showSnackBar( height: 75,
SnackBar( padding: const EdgeInsets.symmetric(vertical: 8),
content: Text('Added ${product.name} to cart'), color: theme.colorScheme.surface,
duration: const Duration(seconds: 2), 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);
},
),
),
),
],
), ),
); );
}, },
icon: const Icon(Icons.add_shopping_cart), ),
label: const Text('Add'),
// 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,
),
);
}
void _addToCart(dynamic product) {
final cartItem = CartItem(
productId: product.id,
productName: product.name,
price: product.price,
quantity: 1,
imageUrl: product.imageUrl,
addedAt: DateTime.now(),
);
ref.read(cartProvider.notifier).addItem(cartItem);
}
void _proceedToCheckout() {
// TODO: Navigate to checkout/order detail screen
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Proceeding to checkout...'),
duration: Duration(seconds: 2),
),
);
}
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,
),
), ),
], ],
), ),
@@ -171,3 +407,175 @@ class HomePage extends ConsumerWidget {
); );
} }
} }
/// Immutable product image widget that won't rebuild
class _ProductImage extends StatelessWidget {
final String productId;
final String? imageUrl;
const _ProductImage({
required this.productId,
required this.imageUrl,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: imageUrl != null && imageUrl!.isNotEmpty
? CachedNetworkImage(
key: ValueKey('product_img_$productId'),
imageUrl: imageUrl!,
width: 60,
height: 60,
fit: BoxFit.cover,
cacheManager: ProductImageCacheManager(),
memCacheWidth: 120,
memCacheHeight: 120,
maxWidthDiskCache: 240,
maxHeightDiskCache: 240,
fadeInDuration: Duration.zero, // No fade animation
fadeOutDuration: Duration.zero, // No fade animation
placeholder: (context, url) => Container(
width: 60,
height: 60,
color: theme.colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
width: 60,
height: 60,
color: theme.colorScheme.surfaceContainerHighest,
child: Icon(
Icons.image_not_supported,
color: theme.colorScheme.onSurfaceVariant,
size: 24,
),
),
)
: Container(
width: 60,
height: 60,
color: theme.colorScheme.surfaceContainerHighest,
child: Icon(
Icons.inventory_2,
color: theme.colorScheme.onSurfaceVariant,
),
),
);
}
}
/// 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 - separated into its own widget
_ProductImage(
productId: product.id,
imageUrl: product.imageUrl,
),
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,
),
],
),
),
],
),
),
);
}
}

View File

@@ -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

View File

@@ -44,7 +44,7 @@ final class CartTotalProvider
} }
} }
String _$cartTotalHash() => r'044f6d4749eec49f9ef4173fc42d149a3841df21'; String _$cartTotalHash() => r'3e4ed08789743e7149a77047651b5d99e380a696';
/// Cart totals calculation provider /// Cart totals calculation provider

View 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,
),
),
],
),
],
),
),
],
),
);
}
}

View 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,
),
),
],
),
],
),
),
),
],
),
),
);
}
}

View File

@@ -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(
@@ -39,8 +73,7 @@ class ProductSelector extends ConsumerWidget {
message: error.toString(), message: error.toString(),
onRetry: () => ref.refresh(productsProvider), onRetry: () => ref.refresh(productsProvider),
), ),
data: (paginationState) { data: (products) {
final products = paginationState.products;
if (products.isEmpty) { if (products.isEmpty) {
return const EmptyState( return const EmptyState(
@@ -51,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,
); );
} }
@@ -82,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),
); );
}, },
); );

View File

@@ -13,7 +13,7 @@ class ProductModel extends HiveObject {
final String name; final String name;
@HiveField(2) @HiveField(2)
final String description; final String? description;
@HiveField(3) @HiveField(3)
final double price; final double price;
@@ -31,22 +31,22 @@ class ProductModel extends HiveObject {
final bool isAvailable; final bool isAvailable;
@HiveField(8) @HiveField(8)
final DateTime createdAt; final DateTime? createdAt;
@HiveField(9) @HiveField(9)
final DateTime updatedAt; final DateTime? updatedAt;
ProductModel({ ProductModel({
required this.id, required this.id,
required this.name, required this.name,
required this.description, this.description,
required this.price, required this.price,
this.imageUrl, this.imageUrl,
required this.categoryId, required this.categoryId,
required this.stockQuantity, required this.stockQuantity,
required this.isAvailable, required this.isAvailable,
required this.createdAt, this.createdAt,
required this.updatedAt, this.updatedAt,
}); });
/// Convert to domain entity /// Convert to domain entity
@@ -86,14 +86,18 @@ class ProductModel extends HiveObject {
return ProductModel( return ProductModel(
id: json['id'] as String, id: json['id'] as String,
name: json['name'] as String, name: json['name'] as String,
description: json['description'] as String? ?? '', description: json['description'] as String?,
price: (json['price'] as num).toDouble(), price: (json['price'] as num).toDouble(),
imageUrl: json['imageUrl'] as String?, imageUrl: json['imageUrl'] as String?,
categoryId: json['categoryId'] as String, categoryId: json['categoryId'] as String,
stockQuantity: json['stockQuantity'] as int? ?? 0, stockQuantity: json['stockQuantity'] as int? ?? 0,
isAvailable: json['isAvailable'] as bool? ?? true, isAvailable: json['isAvailable'] as bool? ?? true,
createdAt: DateTime.parse(json['createdAt'] as String), createdAt: json['createdAt'] != null
updatedAt: DateTime.parse(json['updatedAt'] as String), ? DateTime.parse(json['createdAt'] as String)
: null,
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'] as String)
: null,
); );
} }
@@ -108,8 +112,8 @@ class ProductModel extends HiveObject {
'categoryId': categoryId, 'categoryId': categoryId,
'stockQuantity': stockQuantity, 'stockQuantity': stockQuantity,
'isAvailable': isAvailable, 'isAvailable': isAvailable,
'createdAt': createdAt.toIso8601String(), 'createdAt': createdAt?.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(), 'updatedAt': updatedAt?.toIso8601String(),
}; };
} }
} }

View File

@@ -25,8 +25,8 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
categoryId: fields[5] as String, categoryId: fields[5] as String,
stockQuantity: (fields[6] as num).toInt(), stockQuantity: (fields[6] as num).toInt(),
isAvailable: fields[7] as bool, isAvailable: fields[7] as bool,
createdAt: fields[8] as DateTime, createdAt: fields[8] as DateTime?,
updatedAt: fields[9] as DateTime, updatedAt: fields[9] as DateTime?,
); );
} }

View File

@@ -19,8 +19,27 @@ class ProductRepositoryImpl implements ProductRepository {
@override @override
Future<Either<Failure, List<Product>>> getAllProducts() async { Future<Either<Failure, List<Product>>> getAllProducts() async {
try { try {
final products = await localDataSource.getAllProducts(); // Try remote first (online-first)
final products = await remoteDataSource.getAllProducts();
// Cache the results
await localDataSource.cacheProducts(products);
return Right(products.map((model) => model.toEntity()).toList()); return Right(products.map((model) => model.toEntity()).toList());
} on ServerException catch (e) {
// Remote failed, try local cache
try {
final cachedProducts = await localDataSource.getAllProducts();
return Right(cachedProducts.map((model) => model.toEntity()).toList());
} on CacheException catch (cacheError) {
return Left(ServerFailure('${e.message}. Cache also unavailable.'));
}
} on NetworkException catch (e) {
// Network failed, try local cache
try {
final cachedProducts = await localDataSource.getAllProducts();
return Right(cachedProducts.map((model) => model.toEntity()).toList());
} on CacheException catch (cacheError) {
return Left(NetworkFailure('${e.message}. Cache also unavailable.'));
}
} on CacheException catch (e) { } on CacheException catch (e) {
return Left(CacheFailure(e.message)); return Left(CacheFailure(e.message));
} }
@@ -29,9 +48,29 @@ class ProductRepositoryImpl implements ProductRepository {
@override @override
Future<Either<Failure, List<Product>>> getProductsByCategory(String categoryId) async { Future<Either<Failure, List<Product>>> getProductsByCategory(String categoryId) async {
try { try {
final allProducts = await localDataSource.getAllProducts(); // Try remote first (online-first)
final filtered = allProducts.where((p) => p.categoryId == categoryId).toList(); final allProducts = await remoteDataSource.getAllProducts(categoryId: categoryId);
return Right(filtered.map((model) => model.toEntity()).toList()); // Cache the results
await localDataSource.cacheProducts(allProducts);
return Right(allProducts.map((model) => model.toEntity()).toList());
} on ServerException catch (e) {
// Remote failed, try local cache
try {
final cachedProducts = await localDataSource.getAllProducts();
final filtered = cachedProducts.where((p) => p.categoryId == categoryId).toList();
return Right(filtered.map((model) => model.toEntity()).toList());
} on CacheException catch (cacheError) {
return Left(ServerFailure('${e.message}. Cache also unavailable.'));
}
} on NetworkException catch (e) {
// Network failed, try local cache
try {
final cachedProducts = await localDataSource.getAllProducts();
final filtered = cachedProducts.where((p) => p.categoryId == categoryId).toList();
return Right(filtered.map((model) => model.toEntity()).toList());
} on CacheException catch (cacheError) {
return Left(NetworkFailure('${e.message}. Cache also unavailable.'));
}
} on CacheException catch (e) { } on CacheException catch (e) {
return Left(CacheFailure(e.message)); return Left(CacheFailure(e.message));
} }
@@ -40,13 +79,37 @@ class ProductRepositoryImpl implements ProductRepository {
@override @override
Future<Either<Failure, List<Product>>> searchProducts(String query) async { Future<Either<Failure, List<Product>>> searchProducts(String query) async {
try { try {
final allProducts = await localDataSource.getAllProducts(); // Try remote first (online-first)
final filtered = allProducts.where((p) { final searchResults = await remoteDataSource.getAllProducts(search: query);
final nameMatch = p.name.toLowerCase().contains(query.toLowerCase()); // Cache the results
final descMatch = p.description?.toLowerCase().contains(query.toLowerCase()) ?? false; await localDataSource.cacheProducts(searchResults);
return nameMatch || descMatch; return Right(searchResults.map((model) => model.toEntity()).toList());
}).toList(); } on ServerException catch (e) {
return Right(filtered.map((model) => model.toEntity()).toList()); // Remote failed, search in local cache
try {
final cachedProducts = await localDataSource.getAllProducts();
final filtered = cachedProducts.where((p) {
final nameMatch = p.name.toLowerCase().contains(query.toLowerCase());
final descMatch = p.description?.toLowerCase().contains(query.toLowerCase()) ?? false;
return nameMatch || descMatch;
}).toList();
return Right(filtered.map((model) => model.toEntity()).toList());
} on CacheException catch (cacheError) {
return Left(ServerFailure('${e.message}. Cache also unavailable.'));
}
} on NetworkException catch (e) {
// Network failed, search in local cache
try {
final cachedProducts = await localDataSource.getAllProducts();
final filtered = cachedProducts.where((p) {
final nameMatch = p.name.toLowerCase().contains(query.toLowerCase());
final descMatch = p.description?.toLowerCase().contains(query.toLowerCase()) ?? false;
return nameMatch || descMatch;
}).toList();
return Right(filtered.map((model) => model.toEntity()).toList());
} on CacheException catch (cacheError) {
return Left(NetworkFailure('${e.message}. Cache also unavailable.'));
}
} on CacheException catch (e) { } on CacheException catch (e) {
return Left(CacheFailure(e.message)); return Left(CacheFailure(e.message));
} }
@@ -55,11 +118,33 @@ class ProductRepositoryImpl implements ProductRepository {
@override @override
Future<Either<Failure, Product>> getProductById(String id) async { Future<Either<Failure, Product>> getProductById(String id) async {
try { try {
final product = await localDataSource.getProductById(id); // Try remote first (online-first)
if (product == null) { final product = await remoteDataSource.getProductById(id);
return Left(NotFoundFailure('Product not found')); // Cache the result
} await localDataSource.updateProduct(product);
return Right(product.toEntity()); return Right(product.toEntity());
} on ServerException catch (e) {
// Remote failed, try local cache
try {
final cachedProduct = await localDataSource.getProductById(id);
if (cachedProduct == null) {
return Left(NotFoundFailure('Product not found in cache'));
}
return Right(cachedProduct.toEntity());
} on CacheException catch (cacheError) {
return Left(ServerFailure('${e.message}. Cache also unavailable.'));
}
} on NetworkException catch (e) {
// Network failed, try local cache
try {
final cachedProduct = await localDataSource.getProductById(id);
if (cachedProduct == null) {
return Left(NotFoundFailure('Product not found in cache'));
}
return Right(cachedProduct.toEntity());
} on CacheException catch (cacheError) {
return Left(NetworkFailure('${e.message}. Cache also unavailable.'));
}
} on CacheException catch (e) { } on CacheException catch (e) {
return Left(CacheFailure(e.message)); return Left(CacheFailure(e.message));
} }
@@ -68,11 +153,7 @@ class ProductRepositoryImpl implements ProductRepository {
@override @override
Future<Either<Failure, List<Product>>> syncProducts() async { Future<Either<Failure, List<Product>>> syncProducts() async {
try { try {
final response = await remoteDataSource.getAllProducts(); final products = await remoteDataSource.getAllProducts();
final productsData = response['data'] as List<dynamic>;
final products = productsData
.map((json) => ProductModel.fromJson(json as Map<String, dynamic>))
.toList();
await localDataSource.cacheProducts(products); await localDataSource.cacheProducts(products);
final entities = products.map((model) => model.toEntity()).toList(); final entities = products.map((model) => model.toEntity()).toList();
return Right(entities); return Right(entities);

View File

@@ -10,8 +10,8 @@ class Product extends Equatable {
final String categoryId; final String categoryId;
final int stockQuantity; final int stockQuantity;
final bool isAvailable; final bool isAvailable;
final DateTime createdAt; final DateTime? createdAt;
final DateTime updatedAt; final DateTime? updatedAt;
const Product({ const Product({
required this.id, required this.id,
@@ -22,8 +22,8 @@ class Product extends Equatable {
required this.categoryId, required this.categoryId,
required this.stockQuantity, required this.stockQuantity,
required this.isAvailable, required this.isAvailable,
required this.createdAt, this.createdAt,
required this.updatedAt, this.updatedAt,
}); });
@override @override

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../domain/entities/product.dart'; import '../../domain/entities/product.dart';
import '../../data/models/product_model.dart'; import '../../data/models/product_model.dart';
import '../providers/products_provider.dart'; import '../providers/products_provider.dart';
@@ -139,7 +140,7 @@ class _BatchUpdatePageState extends ConsumerState<BatchUpdatePage> {
children: [ children: [
Expanded( Expanded(
child: OutlinedButton( child: OutlinedButton(
onPressed: _isLoading ? null : () => Navigator.pop(context), onPressed: _isLoading ? null : () => context.pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
), ),
@@ -327,7 +328,7 @@ class _BatchUpdatePageState extends ConsumerState<BatchUpdatePage> {
ref.invalidate(productsProvider); ref.invalidate(productsProvider);
if (mounted) { if (mounted) {
Navigator.pop(context); context.pop();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(

View File

@@ -3,33 +3,66 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../domain/entities/product.dart'; import '../../domain/entities/product.dart';
import '../providers/products_provider.dart';
import '../../../categories/presentation/providers/categories_provider.dart'; import '../../../categories/presentation/providers/categories_provider.dart';
import '../../../../shared/widgets/price_display.dart'; import '../../../../shared/widgets/price_display.dart';
/// Product detail page showing full product information /// Product detail page showing full product information
class ProductDetailPage extends ConsumerWidget { class ProductDetailPage extends ConsumerWidget {
final Product product; final String productId;
const ProductDetailPage({ const ProductDetailPage({
super.key, super.key,
required this.product, required this.productId,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final productsAsync = ref.watch(productsProvider);
final categoriesAsync = ref.watch(categoriesProvider); final categoriesAsync = ref.watch(categoriesProvider);
// Find category name return productsAsync.when(
final categoryName = categoriesAsync.whenOrNull( data: (products) {
data: (categories) { // Find the product by ID
final category = categories.firstWhere( Product? product;
(cat) => cat.id == product.categoryId, try {
orElse: () => categories.first, product = products.firstWhere((p) => p.id == productId);
); } catch (e) {
return category.name; // Product not found
}, return Scaffold(
); appBar: AppBar(title: const Text('Product Not Found')),
body: const Center(child: Text('Product not found')),
);
}
// Find category name
final categoryName = categoriesAsync.whenOrNull(
data: (categories) {
try {
final category = categories.firstWhere(
(cat) => cat.id == product!.categoryId,
);
return category.name;
} catch (e) {
return null;
}
},
);
return _buildProductDetail(context, product, categoryName);
},
loading: () => Scaffold(
appBar: AppBar(title: const Text('Product Details')),
body: const Center(child: CircularProgressIndicator()),
),
error: (error, stack) => Scaffold(
appBar: AppBar(title: const Text('Error')),
body: Center(child: Text('Error: $error')),
),
);
}
Widget _buildProductDetail(BuildContext context, Product product, String? categoryName) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Product Details'), title: const Text('Product Details'),
@@ -48,7 +81,7 @@ class ProductDetailPage extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Product Image // Product Image
_buildProductImage(context), _buildProductImage(context, product),
// Product Info Section // Product Info Section
Padding( Padding(
@@ -97,19 +130,19 @@ class ProductDetailPage extends ConsumerWidget {
const SizedBox(height: 24), const SizedBox(height: 24),
// Stock Information // Stock Information
_buildStockSection(context), _buildStockSection(context, product),
const SizedBox(height: 24), const SizedBox(height: 24),
// Description Section // Description Section
_buildDescriptionSection(context), _buildDescriptionSection(context, product),
const SizedBox(height: 24), const SizedBox(height: 24),
// Additional Information // Additional Information
_buildAdditionalInfo(context), _buildAdditionalInfo(context, product),
const SizedBox(height: 24), const SizedBox(height: 24),
// Action Buttons // Action Buttons
_buildActionButtons(context), _buildActionButtons(context, product),
], ],
), ),
), ),
@@ -120,7 +153,7 @@ class ProductDetailPage extends ConsumerWidget {
} }
/// Build product image section /// Build product image section
Widget _buildProductImage(BuildContext context) { Widget _buildProductImage(BuildContext context, Product product) {
return Hero( return Hero(
tag: 'product-${product.id}', tag: 'product-${product.id}',
child: Container( child: Container(
@@ -154,9 +187,9 @@ class ProductDetailPage extends ConsumerWidget {
} }
/// Build stock information section /// Build stock information section
Widget _buildStockSection(BuildContext context) { Widget _buildStockSection(BuildContext context, Product product) {
final stockColor = _getStockColor(context); final stockColor = _getStockColor(context, product);
final stockStatus = _getStockStatus(); final stockStatus = _getStockStatus(product);
return Card( return Card(
child: Padding( child: Padding(
@@ -245,8 +278,8 @@ class ProductDetailPage extends ConsumerWidget {
} }
/// Build description section /// Build description section
Widget _buildDescriptionSection(BuildContext context) { Widget _buildDescriptionSection(BuildContext context, Product product) {
if (product.description.isEmpty) { if (product.description == null || product.description!.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@@ -261,7 +294,7 @@ class ProductDetailPage extends ConsumerWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
product.description, product.description!,
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
], ],
@@ -269,7 +302,7 @@ class ProductDetailPage extends ConsumerWidget {
} }
/// Build additional information section /// Build additional information section
Widget _buildAdditionalInfo(BuildContext context) { Widget _buildAdditionalInfo(BuildContext context, Product product) {
final dateFormat = DateFormat('MMM dd, yyyy'); final dateFormat = DateFormat('MMM dd, yyyy');
return Card( return Card(
@@ -296,14 +329,18 @@ class ProductDetailPage extends ConsumerWidget {
context, context,
icon: Icons.calendar_today, icon: Icons.calendar_today,
label: 'Created', label: 'Created',
value: dateFormat.format(product.createdAt), value: product.createdAt != null
? dateFormat.format(product.createdAt!)
: 'N/A',
), ),
const Divider(height: 24), const Divider(height: 24),
_buildInfoRow( _buildInfoRow(
context, context,
icon: Icons.update, icon: Icons.update,
label: 'Last Updated', label: 'Last Updated',
value: dateFormat.format(product.updatedAt), value: product.updatedAt != null
? dateFormat.format(product.updatedAt!)
: 'N/A',
), ),
], ],
), ),
@@ -351,7 +388,7 @@ class ProductDetailPage extends ConsumerWidget {
} }
/// Build action buttons /// Build action buttons
Widget _buildActionButtons(BuildContext context) { Widget _buildActionButtons(BuildContext context, Product product) {
return Column( return Column(
children: [ children: [
// Add to Cart Button // Add to Cart Button
@@ -396,7 +433,7 @@ class ProductDetailPage extends ConsumerWidget {
} }
/// Get stock color based on quantity /// Get stock color based on quantity
Color _getStockColor(BuildContext context) { Color _getStockColor(BuildContext context, Product product) {
if (product.stockQuantity == 0) { if (product.stockQuantity == 0) {
return Colors.red; return Colors.red;
} else if (product.stockQuantity < 5) { } else if (product.stockQuantity < 5) {
@@ -407,7 +444,7 @@ class ProductDetailPage extends ConsumerWidget {
} }
/// Get stock status text /// Get stock status text
String _getStockStatus() { String _getStockStatus(Product product) {
if (product.stockQuantity == 0) { if (product.stockQuantity == 0) {
return 'Out of Stock'; return 'Out of Stock';
} else if (product.stockQuantity < 5) { } else if (product.stockQuantity < 5) {

View File

@@ -1,5 +1,6 @@
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 'package:go_router/go_router.dart';
import '../widgets/product_grid.dart'; import '../widgets/product_grid.dart';
import '../widgets/product_search_bar.dart'; import '../widgets/product_search_bar.dart';
import '../widgets/product_list_item.dart'; import '../widgets/product_list_item.dart';
@@ -99,13 +100,9 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
final selectedProducts = filteredProducts final selectedProducts = filteredProducts
.where((p) => _selectedProductIds.contains(p.id)) .where((p) => _selectedProductIds.contains(p.id))
.toList(); .toList();
Navigator.push( context.push(
context, '/products/batch-update',
MaterialPageRoute( extra: selectedProducts,
builder: (context) => BatchUpdatePage(
selectedProducts: selectedProducts,
),
),
).then((_) { ).then((_) {
setState(() { setState(() {
_isSelectionMode = false; _isSelectionMode = false;
@@ -493,10 +490,18 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
sorted.sort((a, b) => b.price.compareTo(a.price)); sorted.sort((a, b) => b.price.compareTo(a.price));
break; break;
case ProductSortOption.newest: case ProductSortOption.newest:
sorted.sort((a, b) => b.createdAt.compareTo(a.createdAt)); sorted.sort((a, b) {
final aDate = a.createdAt ?? DateTime(2000);
final bDate = b.createdAt ?? DateTime(2000);
return bDate.compareTo(aDate);
});
break; break;
case ProductSortOption.oldest: case ProductSortOption.oldest:
sorted.sort((a, b) => a.createdAt.compareTo(b.createdAt)); sorted.sort((a, b) {
final aDate = a.createdAt ?? DateTime(2000);
final bDate = b.createdAt ?? DateTime(2000);
return aDate.compareTo(bDate);
});
break; break;
} }

View File

@@ -15,7 +15,7 @@ class FilteredProducts extends _$FilteredProducts {
// Watch products state // Watch products state
final productsAsync = ref.watch(productsProvider); final productsAsync = ref.watch(productsProvider);
final products = productsAsync.when( final products = productsAsync.when(
data: (data) => data.products, data: (data) => data,
loading: () => <Product>[], loading: () => <Product>[],
error: (_, __) => <Product>[], error: (_, __) => <Product>[],
); );
@@ -91,10 +91,18 @@ class SortedProducts extends _$SortedProducts {
sorted.sort((a, b) => b.price.compareTo(a.price)); sorted.sort((a, b) => b.price.compareTo(a.price));
break; break;
case ProductSortOption.newest: case ProductSortOption.newest:
sorted.sort((a, b) => b.createdAt.compareTo(a.createdAt)); sorted.sort((a, b) {
final aDate = a.createdAt ?? DateTime(2000);
final bDate = b.createdAt ?? DateTime(2000);
return bDate.compareTo(aDate);
});
break; break;
case ProductSortOption.oldest: case ProductSortOption.oldest:
sorted.sort((a, b) => a.createdAt.compareTo(b.createdAt)); sorted.sort((a, b) {
final aDate = a.createdAt ?? DateTime(2000);
final bDate = b.createdAt ?? DateTime(2000);
return aDate.compareTo(bDate);
});
break; break;
} }

View File

@@ -50,7 +50,7 @@ final class FilteredProductsProvider
} }
} }
String _$filteredProductsHash() => r'd8ca6d80a71bf354e3afe6c38335996a8bfc74b7'; String _$filteredProductsHash() => r'97fb09ade4bc65f92f3d4844b059bb2b0660d3df';
/// Filtered products provider /// Filtered products provider
/// Combines products, search query, and category filter to provide filtered results /// Combines products, search query, and category filter to provide filtered results
@@ -131,7 +131,7 @@ final class SortedProductsProvider
} }
} }
String _$sortedProductsHash() => r'653f1e9af8c188631dadbfe9ed7d944c6876d2d3'; String _$sortedProductsHash() => r'8a526ae12a15ca7decc8880ebbd083df455875a8';
/// Provider for sorted products /// Provider for sorted products
/// Adds sorting capability on top of filtered products /// Adds sorting capability on top of filtered products

View File

@@ -5,12 +5,13 @@ import '../../../../core/providers/providers.dart';
part 'products_provider.g.dart'; part 'products_provider.g.dart';
/// Provider for products list with API-first approach /// Provider for products list with online-first approach
@riverpod /// keepAlive ensures data persists when switching tabs
@Riverpod(keepAlive: true)
class Products extends _$Products { class Products extends _$Products {
@override @override
Future<List<Product>> build() async { Future<List<Product>> build() async {
// API-first: Try to load from API first // Online-first: Try to load from API first
final repository = ref.watch(productRepositoryProvider); final repository = ref.watch(productRepositoryProvider);
final networkInfo = ref.watch(networkInfoProvider); final networkInfo = ref.watch(networkInfoProvider);

View File

@@ -8,22 +8,25 @@ 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
/// keepAlive ensures data persists when switching tabs
@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
/// keepAlive ensures data persists when switching tabs
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
/// keepAlive ensures data persists when switching tabs
const ProductsProvider._() const ProductsProvider._()
: super( : super(
from: null, from: null,
argument: null, argument: null,
retry: null, retry: null,
name: r'productsProvider', name: r'productsProvider',
isAutoDispose: true, isAutoDispose: false,
dependencies: null, dependencies: null,
$allTransitiveDependencies: null, $allTransitiveDependencies: null,
); );
@@ -36,9 +39,10 @@ final class ProductsProvider
Products create() => Products(); Products create() => Products();
} }
String _$productsHash() => r'0ff8c2de46bb4b1e29678cc811ec121c9fb4c8eb'; String _$productsHash() => r'1fa5341d86a35a3b3d6666da88e0c5db757cdcdb';
/// Provider for products list with API-first approach /// Provider for products list with online-first approach
/// keepAlive ensures data persists when switching tabs
abstract class _$Products extends $AsyncNotifier<List<Product>> { abstract class _$Products extends $AsyncNotifier<List<Product>> {
FutureOr<List<Product>> build(); FutureOr<List<Product>> build();

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:go_router/go_router.dart';
import '../../domain/entities/product.dart'; import '../../domain/entities/product.dart';
import '../pages/product_detail_page.dart';
import '../../../../shared/widgets/price_display.dart'; import '../../../../shared/widgets/price_display.dart';
/// Product card widget /// Product card widget
@@ -20,12 +20,7 @@ class ProductCard extends StatelessWidget {
child: InkWell( child: InkWell(
onTap: () { onTap: () {
// Navigate to product detail page // Navigate to product detail page
Navigator.push( context.push('/products/${product.id}');
context,
MaterialPageRoute(
builder: (context) => ProductDetailPage(product: product),
),
);
}, },
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:go_router/go_router.dart';
import '../../domain/entities/product.dart'; import '../../domain/entities/product.dart';
import '../pages/product_detail_page.dart';
import '../../../../shared/widgets/price_display.dart'; import '../../../../shared/widgets/price_display.dart';
/// Product list item widget for list view /// Product list item widget for list view
@@ -23,12 +23,7 @@ class ProductListItem extends StatelessWidget {
onTap: onTap ?? onTap: onTap ??
() { () {
// Navigate to product detail page // Navigate to product detail page
Navigator.push( context.push('/products/${product.id}');
context,
MaterialPageRoute(
builder: (context) => ProductDetailPage(product: product),
),
);
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
@@ -85,9 +80,9 @@ class ProductListItem extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
if (product.description.isNotEmpty) if (product.description != null && product.description!.isNotEmpty)
Text( Text(
product.description, product.description!,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),

View File

@@ -1,5 +1,6 @@
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 'package:go_router/go_router.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../../../auth/presentation/providers/auth_provider.dart'; import '../../../auth/presentation/providers/auth_provider.dart';
import '../../../../core/constants/app_constants.dart'; import '../../../../core/constants/app_constants.dart';
@@ -105,11 +106,11 @@ class SettingsPage extends ConsumerWidget {
content: const Text('Are you sure you want to logout?'), content: const Text('Are you sure you want to logout?'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => context.pop(false),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
FilledButton( FilledButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => context.pop(true),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error, backgroundColor: Theme.of(context).colorScheme.error,
), ),
@@ -294,7 +295,7 @@ class SettingsPage extends ConsumerWidget {
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
ref.read(settingsProvider.notifier).updateTheme(value); ref.read(settingsProvider.notifier).updateTheme(value);
Navigator.pop(context); context.pop();
} }
}, },
), ),
@@ -305,7 +306,7 @@ class SettingsPage extends ConsumerWidget {
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
ref.read(settingsProvider.notifier).updateTheme(value); ref.read(settingsProvider.notifier).updateTheme(value);
Navigator.pop(context); context.pop();
} }
}, },
), ),
@@ -316,7 +317,7 @@ class SettingsPage extends ConsumerWidget {
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
ref.read(settingsProvider.notifier).updateTheme(value); ref.read(settingsProvider.notifier).updateTheme(value);
Navigator.pop(context); context.pop();
} }
}, },
), ),
@@ -341,7 +342,7 @@ class SettingsPage extends ConsumerWidget {
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
ref.read(settingsProvider.notifier).updateLanguage(value); ref.read(settingsProvider.notifier).updateLanguage(value);
Navigator.pop(context); context.pop();
} }
}, },
), ),
@@ -352,7 +353,7 @@ class SettingsPage extends ConsumerWidget {
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
ref.read(settingsProvider.notifier).updateLanguage(value); ref.read(settingsProvider.notifier).updateLanguage(value);
Navigator.pop(context); context.pop();
} }
}, },
), ),
@@ -363,7 +364,7 @@ class SettingsPage extends ConsumerWidget {
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
ref.read(settingsProvider.notifier).updateLanguage(value); ref.read(settingsProvider.notifier).updateLanguage(value);
Navigator.pop(context); context.pop();
} }
}, },
), ),
@@ -388,7 +389,7 @@ class SettingsPage extends ConsumerWidget {
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
// TODO: Implement currency update // TODO: Implement currency update
Navigator.pop(context); context.pop();
} }
}, },
), ),
@@ -399,7 +400,7 @@ class SettingsPage extends ConsumerWidget {
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
// TODO: Implement currency update // TODO: Implement currency update
Navigator.pop(context); context.pop();
} }
}, },
), ),
@@ -410,7 +411,7 @@ class SettingsPage extends ConsumerWidget {
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
// TODO: Implement currency update // TODO: Implement currency update
Navigator.pop(context); context.pop();
} }
}, },
), ),
@@ -437,13 +438,13 @@ class SettingsPage extends ConsumerWidget {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => context.pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
// TODO: Implement store name update // TODO: Implement store name update
Navigator.pop(context); context.pop();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Store name updated')), const SnackBar(content: Text('Store name updated')),
); );
@@ -476,13 +477,13 @@ class SettingsPage extends ConsumerWidget {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => context.pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
// TODO: Implement tax rate update // TODO: Implement tax rate update
Navigator.pop(context); context.pop();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tax rate updated')), const SnackBar(content: Text('Tax rate updated')),
); );
@@ -521,7 +522,7 @@ class SettingsPage extends ConsumerWidget {
// Close loading dialog // Close loading dialog
if (context.mounted) { if (context.mounted) {
Navigator.pop(context); context.pop();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Data synced successfully')), const SnackBar(content: Text('Data synced successfully')),
); );
@@ -538,12 +539,12 @@ class SettingsPage extends ConsumerWidget {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => context.pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
Navigator.pop(context); context.pop();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cache cleared')), const SnackBar(content: Text('Cache cleared')),
); );

View File

@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// Shell widget that provides bottom navigation for the app
class AppBottomNavShell extends StatelessWidget {
const AppBottomNavShell({
super.key,
required this.child,
});
final Widget child;
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: _AppBottomNavigationBar(),
);
}
}
class _AppBottomNavigationBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
// Determine current index based on location
int currentIndex = 0;
if (location == '/') {
currentIndex = 0;
} else if (location.startsWith('/products')) {
currentIndex = 1;
} else if (location.startsWith('/categories')) {
currentIndex = 2;
} else if (location.startsWith('/settings')) {
currentIndex = 3;
}
return NavigationBar(
selectedIndex: currentIndex,
onDestinationSelected: (index) {
switch (index) {
case 0:
context.go('/');
break;
case 1:
context.go('/products');
break;
case 2:
context.go('/categories');
break;
case 3:
context.go('/settings');
break;
}
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.point_of_sale_outlined),
selectedIcon: Icon(Icons.point_of_sale),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.inventory_2_outlined),
selectedIcon: Icon(Icons.inventory_2),
label: 'Products',
),
NavigationDestination(
icon: Icon(Icons.category_outlined),
selectedIcon: Icon(Icons.category),
label: 'Categories',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
),
],
);
}
}

View File

@@ -472,14 +472,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
get_it:
dependency: "direct main"
description:
name: get_it
sha256: a4292e7cf67193f8e7c1258203104eb2a51ec8b3a04baa14695f4064c144297b
url: "https://pub.dev"
source: hosted
version: "8.2.0"
glob: glob:
dependency: transitive dependency: transitive
description: description:
@@ -488,6 +480,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.1.3"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
url: "https://pub.dev"
source: hosted
version: "14.8.1"
graphs: graphs:
dependency: transitive dependency: transitive
description: description:

View File

@@ -49,6 +49,9 @@ dependencies:
flutter_riverpod: ^3.0.0 flutter_riverpod: ^3.0.0
riverpod_annotation: ^3.0.0 riverpod_annotation: ^3.0.0
# Routing
go_router: ^14.6.2
# Network # Network
dio: ^5.7.0 dio: ^5.7.0
connectivity_plus: ^6.1.1 connectivity_plus: ^6.1.1