From 9189b65ebf72673fb29cf3d3e08bb40b2c6254c9 Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Thu, 23 Oct 2025 17:03:58 +0700 Subject: [PATCH] fix --- .vscode/settings.json | 11 + lib/app.dart | 55 +---- lib/core/router/app_router.dart | 156 +++++++++++++ lib/core/router/app_router.g.dart | 55 +++++ .../auth/presentation/pages/login_page.dart | 8 +- .../presentation/pages/register_page.dart | 3 +- .../providers/auth_provider.g.dart | 2 +- .../pages/category_detail_page.dart | 215 +++++++++++++----- .../providers/categories_provider.dart | 3 +- .../providers/categories_provider.g.dart | 8 +- .../presentation/widgets/category_card.dart | 9 +- .../presentation/pages/batch_update_page.dart | 5 +- .../pages/product_detail_page.dart | 85 ++++--- .../presentation/pages/products_page.dart | 11 +- .../providers/products_provider.dart | 3 +- .../providers/products_provider.g.dart | 8 +- .../presentation/widgets/product_card.dart | 9 +- .../widgets/product_list_item.dart | 9 +- .../presentation/pages/settings_page.dart | 37 +-- lib/shared/widgets/app_bottom_nav_shell.dart | 81 +++++++ pubspec.lock | 8 + pubspec.yaml | 3 + 22 files changed, 589 insertions(+), 195 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 lib/core/router/app_router.dart create mode 100644 lib/core/router/app_router.g.dart create mode 100644 lib/shared/widgets/app_bottom_nav_shell.dart diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..bb777f5 --- /dev/null +++ b/.vscode/settings.json @@ -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 + } +} \ No newline at end of file diff --git a/lib/app.dart b/lib/app.dart index ba476a4..92112a8 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,15 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'core/router/app_router.dart'; import 'core/theme/app_theme.dart'; -import 'features/auth/presentation/presentation.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/auth/presentation/providers/auth_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 { const RetailApp({super.key}); @@ -32,54 +28,15 @@ class _RetailAppState extends ConsumerState { @override Widget build(BuildContext context) { final themeMode = ref.watch(themeModeFromThemeProvider); + final router = ref.watch(routerProvider); - return MaterialApp( + return MaterialApp.router( title: 'Retail POS', debugShowCheckedModeBanner: false, theme: AppTheme.lightTheme, darkTheme: AppTheme.darkTheme, themeMode: themeMode, - // Wrap the home with AuthWrapper to require login - home: const AuthWrapper( - child: MainScreen(), - ), - ); - } -} - -/// Main screen with bottom navigation (only accessible after login) -class MainScreen extends ConsumerStatefulWidget { - const MainScreen({super.key}); - - @override - ConsumerState createState() => _MainScreenState(); -} - -class _MainScreenState extends ConsumerState { - int _currentIndex = 0; - - final List _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; - }); - }, - ), + routerConfig: router, ); } } diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart new file mode 100644 index 0000000..d6dd338 --- /dev/null +++ b/lib/core/router/app_router.dart @@ -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?; + 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(), + ), + ), + ], + ), + ], + ); +} diff --git a/lib/core/router/app_router.g.dart b/lib/core/router/app_router.g.dart new file mode 100644 index 0000000..359183d --- /dev/null +++ b/lib/core/router/app_router.g.dart @@ -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 + with $Provider { + /// 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 $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(value), + ); + } +} + +String _$routerHash() => r'3c7108371f8529a70e1e479728e8da132246bab4'; diff --git a/lib/features/auth/presentation/pages/login_page.dart b/lib/features/auth/presentation/pages/login_page.dart index d793095..9c3b625 100644 --- a/lib/features/auth/presentation/pages/login_page.dart +++ b/lib/features/auth/presentation/pages/login_page.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../providers/auth_provider.dart'; import '../widgets/widgets.dart'; import '../utils/validators.dart'; -import 'register_page.dart'; /// Login page with email and password authentication class LoginPage extends ConsumerStatefulWidget { @@ -66,11 +66,7 @@ class _LoginPageState extends ConsumerState { } void _navigateToRegister() { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const RegisterPage(), - ), - ); + context.push('/register'); } void _handleForgotPassword() { diff --git a/lib/features/auth/presentation/pages/register_page.dart b/lib/features/auth/presentation/pages/register_page.dart index ea5fcb0..35c4e6a 100644 --- a/lib/features/auth/presentation/pages/register_page.dart +++ b/lib/features/auth/presentation/pages/register_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../providers/auth_provider.dart'; import '../widgets/widgets.dart'; import '../utils/validators.dart'; @@ -90,7 +91,7 @@ class _RegisterPageState extends ConsumerState { } void _navigateBackToLogin() { - Navigator.of(context).pop(); + context.pop(); } @override diff --git a/lib/features/auth/presentation/providers/auth_provider.g.dart b/lib/features/auth/presentation/providers/auth_provider.g.dart index c4dc542..3df35e9 100644 --- a/lib/features/auth/presentation/providers/auth_provider.g.dart +++ b/lib/features/auth/presentation/providers/auth_provider.g.dart @@ -142,7 +142,7 @@ final class AuthProvider extends $NotifierProvider { } } -String _$authHash() => r'73c9e7b70799eba2904eb6fc65454332d4146a33'; +String _$authHash() => r'24ad5a5313febf1a3ac2550adaf19f34098a8f7c'; /// Auth state notifier provider diff --git a/lib/features/categories/presentation/pages/category_detail_page.dart b/lib/features/categories/presentation/pages/category_detail_page.dart index 78efc03..879a0b5 100644 --- a/lib/features/categories/presentation/pages/category_detail_page.dart +++ b/lib/features/categories/presentation/pages/category_detail_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../domain/entities/category.dart'; +import '../providers/categories_provider.dart'; import '../../../products/presentation/providers/products_provider.dart'; import '../../../products/presentation/widgets/product_card.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 class CategoryDetailPage extends ConsumerStatefulWidget { - final Category category; + final String categoryId; const CategoryDetailPage({ super.key, - required this.category, + required this.categoryId, }); @override @@ -26,80 +27,178 @@ class _CategoryDetailPageState extends ConsumerState { @override Widget build(BuildContext context) { + final categoriesAsync = ref.watch(categoriesProvider); final productsAsync = ref.watch(productsProvider); - return Scaffold( - appBar: AppBar( - title: Text(widget.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 == widget.category.id) - .toList(); + return categoriesAsync.when( + data: (categories) { + // Find the category by ID + Category? category; + try { + category = categories.firstWhere((c) => c.id == widget.categoryId); + } catch (e) { + // Category not found + category = null; + } - if (categoryProducts.isEmpty) { - return Center( + // Handle category not found + if (category == null) { + return Scaffold( + appBar: AppBar( + title: const Text('Category Not Found'), + ), + body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.inventory_2_outlined, + Icons.error_outline, size: 64, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of(context).colorScheme.error, ), const SizedBox(height: 16), Text( - 'No products in this category', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + 'Category not found', + style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), Text( - 'Products will appear here once added', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + 'The category you are looking for does not exist', + style: Theme.of(context).textTheme.bodySmall, + 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(), ), - error: (error, stack) => Center( + ), + error: (error, stack) => Scaffold( + appBar: AppBar( + title: const Text('Error'), + ), + body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -110,7 +209,7 @@ class _CategoryDetailPageState extends ConsumerState { ), const SizedBox(height: 16), Text( - 'Error loading products', + 'Error loading category', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), @@ -122,7 +221,7 @@ class _CategoryDetailPageState extends ConsumerState { const SizedBox(height: 16), FilledButton.icon( onPressed: () { - ref.invalidate(productsProvider); + ref.invalidate(categoriesProvider); }, icon: const Icon(Icons.refresh), label: const Text('Retry'), diff --git a/lib/features/categories/presentation/providers/categories_provider.dart b/lib/features/categories/presentation/providers/categories_provider.dart index e0b7f41..bc4ba59 100644 --- a/lib/features/categories/presentation/providers/categories_provider.dart +++ b/lib/features/categories/presentation/providers/categories_provider.dart @@ -6,7 +6,8 @@ import '../../../../core/providers/providers.dart'; part 'categories_provider.g.dart'; /// Provider for categories list with online-first approach -@riverpod +/// keepAlive ensures data persists when switching tabs +@Riverpod(keepAlive: true) class Categories extends _$Categories { @override Future> build() async { diff --git a/lib/features/categories/presentation/providers/categories_provider.g.dart b/lib/features/categories/presentation/providers/categories_provider.g.dart index f87207a..f6b3203 100644 --- a/lib/features/categories/presentation/providers/categories_provider.g.dart +++ b/lib/features/categories/presentation/providers/categories_provider.g.dart @@ -9,21 +9,24 @@ part of 'categories_provider.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning /// Provider for categories list with online-first approach +/// keepAlive ensures data persists when switching tabs @ProviderFor(Categories) const categoriesProvider = CategoriesProvider._(); /// Provider for categories list with online-first approach +/// keepAlive ensures data persists when switching tabs final class CategoriesProvider extends $AsyncNotifierProvider> { /// Provider for categories list with online-first approach + /// keepAlive ensures data persists when switching tabs const CategoriesProvider._() : super( from: null, argument: null, retry: null, name: r'categoriesProvider', - isAutoDispose: true, + isAutoDispose: false, dependencies: null, $allTransitiveDependencies: null, ); @@ -36,9 +39,10 @@ final class CategoriesProvider Categories create() => Categories(); } -String _$categoriesHash() => r'33c33b08f8926e5bbbd112285591c74a3ff0f61c'; +String _$categoriesHash() => r'c26eb4b4a76ce796eb65b7843a390805528dec4a'; /// Provider for categories list with online-first approach +/// keepAlive ensures data persists when switching tabs abstract class _$Categories extends $AsyncNotifier> { FutureOr> build(); diff --git a/lib/features/categories/presentation/widgets/category_card.dart b/lib/features/categories/presentation/widgets/category_card.dart index 7d9476e..1b9aaba 100644 --- a/lib/features/categories/presentation/widgets/category_card.dart +++ b/lib/features/categories/presentation/widgets/category_card.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../../domain/entities/category.dart'; -import '../pages/category_detail_page.dart'; /// Category card widget class CategoryCard extends StatelessWidget { @@ -22,12 +22,7 @@ class CategoryCard extends StatelessWidget { child: InkWell( onTap: () { // Navigate to category detail page - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CategoryDetailPage(category: category), - ), - ); + context.push('/categories/${category.id}'); }, child: Padding( padding: const EdgeInsets.all(16.0), diff --git a/lib/features/products/presentation/pages/batch_update_page.dart b/lib/features/products/presentation/pages/batch_update_page.dart index cda4009..76b16a3 100644 --- a/lib/features/products/presentation/pages/batch_update_page.dart +++ b/lib/features/products/presentation/pages/batch_update_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../../domain/entities/product.dart'; import '../../data/models/product_model.dart'; import '../providers/products_provider.dart'; @@ -139,7 +140,7 @@ class _BatchUpdatePageState extends ConsumerState { children: [ Expanded( child: OutlinedButton( - onPressed: _isLoading ? null : () => Navigator.pop(context), + onPressed: _isLoading ? null : () => context.pop(), child: const Text('Cancel'), ), ), @@ -327,7 +328,7 @@ class _BatchUpdatePageState extends ConsumerState { ref.invalidate(productsProvider); if (mounted) { - Navigator.pop(context); + context.pop(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( diff --git a/lib/features/products/presentation/pages/product_detail_page.dart b/lib/features/products/presentation/pages/product_detail_page.dart index 54338f5..de7e60d 100644 --- a/lib/features/products/presentation/pages/product_detail_page.dart +++ b/lib/features/products/presentation/pages/product_detail_page.dart @@ -3,33 +3,66 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:intl/intl.dart'; import '../../domain/entities/product.dart'; +import '../providers/products_provider.dart'; import '../../../categories/presentation/providers/categories_provider.dart'; import '../../../../shared/widgets/price_display.dart'; /// Product detail page showing full product information class ProductDetailPage extends ConsumerWidget { - final Product product; + final String productId; const ProductDetailPage({ super.key, - required this.product, + required this.productId, }); @override Widget build(BuildContext context, WidgetRef ref) { + final productsAsync = ref.watch(productsProvider); final categoriesAsync = ref.watch(categoriesProvider); - // Find category name - final categoryName = categoriesAsync.whenOrNull( - data: (categories) { - final category = categories.firstWhere( - (cat) => cat.id == product.categoryId, - orElse: () => categories.first, - ); - return category.name; - }, - ); + return productsAsync.when( + data: (products) { + // Find the product by ID + Product? product; + try { + product = products.firstWhere((p) => p.id == productId); + } catch (e) { + // 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( appBar: AppBar( title: const Text('Product Details'), @@ -48,7 +81,7 @@ class ProductDetailPage extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ // Product Image - _buildProductImage(context), + _buildProductImage(context, product), // Product Info Section Padding( @@ -97,19 +130,19 @@ class ProductDetailPage extends ConsumerWidget { const SizedBox(height: 24), // Stock Information - _buildStockSection(context), + _buildStockSection(context, product), const SizedBox(height: 24), // Description Section - _buildDescriptionSection(context), + _buildDescriptionSection(context, product), const SizedBox(height: 24), // Additional Information - _buildAdditionalInfo(context), + _buildAdditionalInfo(context, product), const SizedBox(height: 24), // Action Buttons - _buildActionButtons(context), + _buildActionButtons(context, product), ], ), ), @@ -120,7 +153,7 @@ class ProductDetailPage extends ConsumerWidget { } /// Build product image section - Widget _buildProductImage(BuildContext context) { + Widget _buildProductImage(BuildContext context, Product product) { return Hero( tag: 'product-${product.id}', child: Container( @@ -154,9 +187,9 @@ class ProductDetailPage extends ConsumerWidget { } /// Build stock information section - Widget _buildStockSection(BuildContext context) { - final stockColor = _getStockColor(context); - final stockStatus = _getStockStatus(); + Widget _buildStockSection(BuildContext context, Product product) { + final stockColor = _getStockColor(context, product); + final stockStatus = _getStockStatus(product); return Card( child: Padding( @@ -245,7 +278,7 @@ class ProductDetailPage extends ConsumerWidget { } /// Build description section - Widget _buildDescriptionSection(BuildContext context) { + Widget _buildDescriptionSection(BuildContext context, Product product) { if (product.description == null || product.description!.isEmpty) { return const SizedBox.shrink(); } @@ -269,7 +302,7 @@ class ProductDetailPage extends ConsumerWidget { } /// Build additional information section - Widget _buildAdditionalInfo(BuildContext context) { + Widget _buildAdditionalInfo(BuildContext context, Product product) { final dateFormat = DateFormat('MMM dd, yyyy'); return Card( @@ -355,7 +388,7 @@ class ProductDetailPage extends ConsumerWidget { } /// Build action buttons - Widget _buildActionButtons(BuildContext context) { + Widget _buildActionButtons(BuildContext context, Product product) { return Column( children: [ // Add to Cart Button @@ -400,7 +433,7 @@ class ProductDetailPage extends ConsumerWidget { } /// Get stock color based on quantity - Color _getStockColor(BuildContext context) { + Color _getStockColor(BuildContext context, Product product) { if (product.stockQuantity == 0) { return Colors.red; } else if (product.stockQuantity < 5) { @@ -411,7 +444,7 @@ class ProductDetailPage extends ConsumerWidget { } /// Get stock status text - String _getStockStatus() { + String _getStockStatus(Product product) { if (product.stockQuantity == 0) { return 'Out of Stock'; } else if (product.stockQuantity < 5) { diff --git a/lib/features/products/presentation/pages/products_page.dart b/lib/features/products/presentation/pages/products_page.dart index 4aa362d..15da3b8 100644 --- a/lib/features/products/presentation/pages/products_page.dart +++ b/lib/features/products/presentation/pages/products_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../widgets/product_grid.dart'; import '../widgets/product_search_bar.dart'; import '../widgets/product_list_item.dart'; @@ -99,13 +100,9 @@ class _ProductsPageState extends ConsumerState { final selectedProducts = filteredProducts .where((p) => _selectedProductIds.contains(p.id)) .toList(); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => BatchUpdatePage( - selectedProducts: selectedProducts, - ), - ), + context.push( + '/products/batch-update', + extra: selectedProducts, ).then((_) { setState(() { _isSelectionMode = false; diff --git a/lib/features/products/presentation/providers/products_provider.dart b/lib/features/products/presentation/providers/products_provider.dart index 795caad..4a3b508 100644 --- a/lib/features/products/presentation/providers/products_provider.dart +++ b/lib/features/products/presentation/providers/products_provider.dart @@ -6,7 +6,8 @@ import '../../../../core/providers/providers.dart'; part 'products_provider.g.dart'; /// Provider for products list with online-first approach -@riverpod +/// keepAlive ensures data persists when switching tabs +@Riverpod(keepAlive: true) class Products extends _$Products { @override Future> build() async { diff --git a/lib/features/products/presentation/providers/products_provider.g.dart b/lib/features/products/presentation/providers/products_provider.g.dart index bddeaad..178f66e 100644 --- a/lib/features/products/presentation/providers/products_provider.g.dart +++ b/lib/features/products/presentation/providers/products_provider.g.dart @@ -9,21 +9,24 @@ part of 'products_provider.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning /// Provider for products list with online-first approach +/// keepAlive ensures data persists when switching tabs @ProviderFor(Products) const productsProvider = ProductsProvider._(); /// Provider for products list with online-first approach +/// keepAlive ensures data persists when switching tabs final class ProductsProvider extends $AsyncNotifierProvider> { /// Provider for products list with online-first approach + /// keepAlive ensures data persists when switching tabs const ProductsProvider._() : super( from: null, argument: null, retry: null, name: r'productsProvider', - isAutoDispose: true, + isAutoDispose: false, dependencies: null, $allTransitiveDependencies: null, ); @@ -36,9 +39,10 @@ final class ProductsProvider Products create() => Products(); } -String _$productsHash() => r'0ff8c2de46bb4b1e29678cc811ec121c9fb4c8eb'; +String _$productsHash() => r'1fa5341d86a35a3b3d6666da88e0c5db757cdcdb'; /// Provider for products list with online-first approach +/// keepAlive ensures data persists when switching tabs abstract class _$Products extends $AsyncNotifier> { FutureOr> build(); diff --git a/lib/features/products/presentation/widgets/product_card.dart b/lib/features/products/presentation/widgets/product_card.dart index 5e5ac02..2ce8b8a 100644 --- a/lib/features/products/presentation/widgets/product_card.dart +++ b/lib/features/products/presentation/widgets/product_card.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:go_router/go_router.dart'; import '../../domain/entities/product.dart'; -import '../pages/product_detail_page.dart'; import '../../../../shared/widgets/price_display.dart'; /// Product card widget @@ -20,12 +20,7 @@ class ProductCard extends StatelessWidget { child: InkWell( onTap: () { // Navigate to product detail page - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ProductDetailPage(product: product), - ), - ); + context.push('/products/${product.id}'); }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/features/products/presentation/widgets/product_list_item.dart b/lib/features/products/presentation/widgets/product_list_item.dart index 2f95e6d..c5c53be 100644 --- a/lib/features/products/presentation/widgets/product_list_item.dart +++ b/lib/features/products/presentation/widgets/product_list_item.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:go_router/go_router.dart'; import '../../domain/entities/product.dart'; -import '../pages/product_detail_page.dart'; import '../../../../shared/widgets/price_display.dart'; /// Product list item widget for list view @@ -23,12 +23,7 @@ class ProductListItem extends StatelessWidget { onTap: onTap ?? () { // Navigate to product detail page - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ProductDetailPage(product: product), - ), - ); + context.push('/products/${product.id}'); }, child: Padding( padding: const EdgeInsets.all(12.0), diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index e9610e5..e6a67c9 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../providers/settings_provider.dart'; import '../../../auth/presentation/providers/auth_provider.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?'), actions: [ TextButton( - onPressed: () => Navigator.pop(context, false), + onPressed: () => context.pop(false), child: const Text('Cancel'), ), FilledButton( - onPressed: () => Navigator.pop(context, true), + onPressed: () => context.pop(true), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, ), @@ -294,7 +295,7 @@ class SettingsPage extends ConsumerWidget { onChanged: (value) { if (value != null) { ref.read(settingsProvider.notifier).updateTheme(value); - Navigator.pop(context); + context.pop(); } }, ), @@ -305,7 +306,7 @@ class SettingsPage extends ConsumerWidget { onChanged: (value) { if (value != null) { ref.read(settingsProvider.notifier).updateTheme(value); - Navigator.pop(context); + context.pop(); } }, ), @@ -316,7 +317,7 @@ class SettingsPage extends ConsumerWidget { onChanged: (value) { if (value != null) { ref.read(settingsProvider.notifier).updateTheme(value); - Navigator.pop(context); + context.pop(); } }, ), @@ -341,7 +342,7 @@ class SettingsPage extends ConsumerWidget { onChanged: (value) { if (value != null) { ref.read(settingsProvider.notifier).updateLanguage(value); - Navigator.pop(context); + context.pop(); } }, ), @@ -352,7 +353,7 @@ class SettingsPage extends ConsumerWidget { onChanged: (value) { if (value != null) { ref.read(settingsProvider.notifier).updateLanguage(value); - Navigator.pop(context); + context.pop(); } }, ), @@ -363,7 +364,7 @@ class SettingsPage extends ConsumerWidget { onChanged: (value) { if (value != null) { ref.read(settingsProvider.notifier).updateLanguage(value); - Navigator.pop(context); + context.pop(); } }, ), @@ -388,7 +389,7 @@ class SettingsPage extends ConsumerWidget { onChanged: (value) { if (value != null) { // TODO: Implement currency update - Navigator.pop(context); + context.pop(); } }, ), @@ -399,7 +400,7 @@ class SettingsPage extends ConsumerWidget { onChanged: (value) { if (value != null) { // TODO: Implement currency update - Navigator.pop(context); + context.pop(); } }, ), @@ -410,7 +411,7 @@ class SettingsPage extends ConsumerWidget { onChanged: (value) { if (value != null) { // TODO: Implement currency update - Navigator.pop(context); + context.pop(); } }, ), @@ -437,13 +438,13 @@ class SettingsPage extends ConsumerWidget { ), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => context.pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () { // TODO: Implement store name update - Navigator.pop(context); + context.pop(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Store name updated')), ); @@ -476,13 +477,13 @@ class SettingsPage extends ConsumerWidget { ), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => context.pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () { // TODO: Implement tax rate update - Navigator.pop(context); + context.pop(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Tax rate updated')), ); @@ -521,7 +522,7 @@ class SettingsPage extends ConsumerWidget { // Close loading dialog if (context.mounted) { - Navigator.pop(context); + context.pop(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Data synced successfully')), ); @@ -538,12 +539,12 @@ class SettingsPage extends ConsumerWidget { ), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => context.pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () { - Navigator.pop(context); + context.pop(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Cache cleared')), ); diff --git a/lib/shared/widgets/app_bottom_nav_shell.dart b/lib/shared/widgets/app_bottom_nav_shell.dart new file mode 100644 index 0000000..ef578a7 --- /dev/null +++ b/lib/shared/widgets/app_bottom_nav_shell.dart @@ -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', + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index d6def18..b8328dd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -480,6 +480,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2682744..75872e9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,6 +49,9 @@ dependencies: flutter_riverpod: ^3.0.0 riverpod_annotation: ^3.0.0 + # Routing + go_router: ^14.6.2 + # Network dio: ^5.7.0 connectivity_plus: ^6.1.1