This commit is contained in:
Phuoc Nguyen
2025-10-28 14:24:58 +07:00
parent df99d0c9e3
commit e14ae56c3c
10 changed files with 329 additions and 78 deletions

View File

@@ -28,12 +28,12 @@ android {
ndkVersion flutter.ndkVersion ndkVersion flutter.ndkVersion
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_20 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_20 targetCompatibility JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = '20' jvmTarget = '17'
} }
sourceSets { sourceSets {

View File

@@ -4,7 +4,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.2.1' classpath 'com.android.tools.build:gradle:8.6.0'
} }
} }

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip

View File

@@ -19,8 +19,8 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.2.1" apply false id "com.android.application" version "8.9.1" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false id "org.jetbrains.kotlin.android" version "2.2.21" apply false
} }
include ":app" include ":app"

View File

@@ -20,7 +20,7 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false id("org.jetbrains.kotlin.android") version "2.2.21" apply false
} }
include(":app") include(":app")

View File

@@ -33,10 +33,10 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 mobile_scanner: 77265f3dc8d580810e91849d4a0811a90467ed5e
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e

View File

@@ -125,8 +125,9 @@ class AppRouter {
/// Product Detail Route /// Product Detail Route
/// Path: /product-detail /// Path: /product-detail
/// Takes warehouseId, productId, and warehouseName as extra parameter /// Takes warehouseId, productId, warehouseName, and optional stageId as extra parameter
/// Shows detailed information for a specific product /// Shows detailed information for a specific product
/// If stageId is provided, only that stage is shown, otherwise all stages are shown
GoRoute( GoRoute(
path: '/product-detail', path: '/product-detail',
name: 'product-detail', name: 'product-detail',
@@ -147,6 +148,8 @@ class AppRouter {
final warehouseId = params['warehouseId'] as int?; final warehouseId = params['warehouseId'] as int?;
final productId = params['productId'] as int?; final productId = params['productId'] as int?;
final warehouseName = params['warehouseName'] as String?; final warehouseName = params['warehouseName'] as String?;
// Extract optional stageId
final stageId = params['stageId'] as int?;
// Validate parameters // Validate parameters
if (warehouseId == null || productId == null || warehouseName == null) { if (warehouseId == null || productId == null || warehouseName == null) {
@@ -162,6 +165,7 @@ class AppRouter {
warehouseId: warehouseId, warehouseId: warehouseId,
productId: productId, productId: productId,
warehouseName: warehouseName, warehouseName: warehouseName,
stageId: stageId,
); );
}, },
), ),
@@ -375,10 +379,12 @@ extension AppRouterExtension on BuildContext {
/// [warehouseId] - ID of the warehouse /// [warehouseId] - ID of the warehouse
/// [productId] - ID of the product to view /// [productId] - ID of the product to view
/// [warehouseName] - Name of the warehouse (for display) /// [warehouseName] - Name of the warehouse (for display)
/// [stageId] - Optional ID of specific stage to show (if null, show all stages)
void goToProductDetail({ void goToProductDetail({
required int warehouseId, required int warehouseId,
required int productId, required int productId,
required String warehouseName, required String warehouseName,
int? stageId,
}) { }) {
push( push(
'/product-detail', '/product-detail',
@@ -386,6 +392,7 @@ extension AppRouterExtension on BuildContext {
'warehouseId': warehouseId, 'warehouseId': warehouseId,
'productId': productId, 'productId': productId,
'warehouseName': warehouseName, 'warehouseName': warehouseName,
if (stageId != null) 'stageId': stageId,
}, },
); );
} }

View File

@@ -6,16 +6,19 @@ import '../../domain/entities/product_stage_entity.dart';
/// Product detail page /// Product detail page
/// Displays product stages as chips and shows selected stage information /// Displays product stages as chips and shows selected stage information
/// If [stageId] is provided, only that stage is shown, otherwise all stages are shown
class ProductDetailPage extends ConsumerStatefulWidget { class ProductDetailPage extends ConsumerStatefulWidget {
final int warehouseId; final int warehouseId;
final int productId; final int productId;
final String warehouseName; final String warehouseName;
final int? stageId;
const ProductDetailPage({ const ProductDetailPage({
super.key, super.key,
required this.warehouseId, required this.warehouseId,
required this.productId, required this.productId,
required this.warehouseName, required this.warehouseName,
this.stageId,
}); });
@override @override
@@ -31,11 +34,22 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
_providerKey = '${widget.warehouseId}_${widget.productId}'; _providerKey = '${widget.warehouseId}_${widget.productId}';
// Load product stages when page is initialized // Load product stages when page is initialized
Future.microtask(() { Future.microtask(() async {
ref.read(productDetailProvider(_providerKey).notifier).loadProductDetail( await ref.read(productDetailProvider(_providerKey).notifier).loadProductDetail(
widget.warehouseId, widget.warehouseId,
widget.productId, widget.productId,
); );
// If stageId is provided, auto-select that stage
if (widget.stageId != null) {
final stages = ref.read(productDetailProvider(_providerKey)).stages;
final stageIndex = stages.indexWhere(
(stage) => stage.productStageId == widget.stageId,
);
if (stageIndex != -1) {
ref.read(productDetailProvider(_providerKey).notifier).selectStage(stageIndex);
}
}
}); });
} }
@@ -166,76 +180,162 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
); );
} }
// Filter stages if stageId is provided
final displayStages = widget.stageId != null
? stages.where((stage) => stage.productStageId == widget.stageId).toList()
: stages;
// When stageId is provided but no matching stage found
if (widget.stageId != null && displayStages.isEmpty && stages.isNotEmpty) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: theme.colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Stage Not Found',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.error,
),
),
const SizedBox(height: 8),
Text(
'Stage with ID ${widget.stageId} was not found in this product.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back),
label: const Text('Go Back'),
),
],
),
),
),
);
}
return RefreshIndicator( return RefreshIndicator(
onRefresh: _onRefresh, onRefresh: _onRefresh,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Stage chips section // Stage chips section
Container( if (displayStages.isNotEmpty)
width: double.infinity, Container(
padding: const EdgeInsets.all(16), width: double.infinity,
decoration: BoxDecoration( padding: const EdgeInsets.all(16),
color: theme.colorScheme.surfaceContainerHighest, decoration: BoxDecoration(
border: Border( color: theme.colorScheme.surfaceContainerHighest,
bottom: BorderSide( border: Border(
color: theme.colorScheme.outline.withValues(alpha: 0.2), bottom: BorderSide(
), color: theme.colorScheme.outline.withValues(alpha: 0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Production Stages (${stages.length})',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(height: 12), ),
Wrap( child: Column(
spacing: 8, crossAxisAlignment: CrossAxisAlignment.start,
runSpacing: 8, children: [
children: List.generate(stages.length, (index) { Row(
final stage = stages[index]; children: [
final isSelected = index == selectedIndex; Text(
widget.stageId != null
return FilterChip( ? 'Selected Stage'
selected: isSelected, : 'Production Stages (${displayStages.length})',
label: Text(stage.displayName), style: theme.textTheme.titleSmall?.copyWith(
onSelected: (_) { fontWeight: FontWeight.bold,
ref ),
.read(productDetailProvider(_providerKey).notifier)
.selectStage(index);
},
backgroundColor: theme.colorScheme.surface,
selectedColor: theme.colorScheme.primaryContainer,
checkmarkColor: theme.colorScheme.primary,
labelStyle: TextStyle(
color: isSelected
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurface,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
), ),
); if (widget.stageId != null) ...[
}), const Spacer(),
), Container(
], padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'ID: ${widget.stageId}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(displayStages.length, (index) {
final stage = displayStages[index];
// When stageId is provided, check if this stage matches the stageId
// Otherwise, check if the index matches the selected index
final isSelected = widget.stageId != null
? stage.productStageId == widget.stageId
: stages.indexOf(stage) == selectedIndex;
return FilterChip(
selected: isSelected,
label: Text(stage.displayName),
onSelected: widget.stageId == null
? (_) {
ref
.read(productDetailProvider(_providerKey).notifier)
.selectStage(stages.indexOf(stage));
}
: null, // Disable selection when specific stage is shown
backgroundColor: theme.colorScheme.surface,
selectedColor: theme.colorScheme.primaryContainer,
checkmarkColor: theme.colorScheme.primary,
labelStyle: TextStyle(
color: isSelected
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurface,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
);
}),
),
],
),
), ),
),
// Stage details section // Stage details section
Expanded( Expanded(
child: selectedStage == null child: () {
? const Center(child: Text('No stage selected')) // When stageId is provided, use the filtered stage
: SingleChildScrollView( final stageToShow = widget.stageId != null && displayStages.isNotEmpty
? displayStages.first
: selectedStage;
if (stageToShow == null) {
return const Center(child: Text('No stage selected'));
}
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Stage header // Stage header
_buildStageHeader(selectedStage, theme), _buildStageHeader(stageToShow, theme),
const SizedBox(height: 16), const SizedBox(height: 16),
// Quantity information // Quantity information
@@ -244,16 +344,16 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
title: 'Quantities', title: 'Quantities',
icon: Icons.inventory_outlined, icon: Icons.inventory_outlined,
children: [ children: [
_buildInfoRow('Passed Quantity', '${selectedStage.passedQuantity}'), _buildInfoRow('Passed Quantity', '${stageToShow.passedQuantity}'),
_buildInfoRow( _buildInfoRow(
'Passed Weight', 'Passed Weight',
'${selectedStage.passedQuantityWeight.toStringAsFixed(2)} kg', '${stageToShow.passedQuantityWeight.toStringAsFixed(2)} kg',
), ),
const Divider(height: 24), const Divider(height: 24),
_buildInfoRow('Issued Quantity', '${selectedStage.issuedQuantity}'), _buildInfoRow('Issued Quantity', '${stageToShow.issuedQuantity}'),
_buildInfoRow( _buildInfoRow(
'Issued Weight', 'Issued Weight',
'${selectedStage.issuedQuantityWeight.toStringAsFixed(2)} kg', '${stageToShow.issuedQuantityWeight.toStringAsFixed(2)} kg',
), ),
], ],
), ),
@@ -265,21 +365,22 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
title: 'Stage Information', title: 'Stage Information',
icon: Icons.info_outlined, icon: Icons.info_outlined,
children: [ children: [
_buildInfoRow('Product ID', '${selectedStage.productId}'), _buildInfoRow('Product ID', '${stageToShow.productId}'),
if (selectedStage.productStageId != null) if (stageToShow.productStageId != null)
_buildInfoRow('Stage ID', '${selectedStage.productStageId}'), _buildInfoRow('Stage ID', '${stageToShow.productStageId}'),
if (selectedStage.actionTypeId != null) if (stageToShow.actionTypeId != null)
_buildInfoRow('Action Type ID', '${selectedStage.actionTypeId}'), _buildInfoRow('Action Type ID', '${stageToShow.actionTypeId}'),
_buildInfoRow('Stage Name', selectedStage.displayName), _buildInfoRow('Stage Name', stageToShow.displayName),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Status indicators // Status indicators
_buildStatusCards(selectedStage, theme), _buildStatusCards(stageToShow, theme),
], ],
), ),
), );
}(),
), ),
], ],
), ),

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:mobile_scanner/mobile_scanner.dart';
import '../../../../core/di/providers.dart'; import '../../../../core/di/providers.dart';
import '../../../../core/router/app_router.dart'; import '../../../../core/router/app_router.dart';
@@ -41,6 +42,141 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
await ref.read(productsProvider.notifier).refreshProducts(); await ref.read(productsProvider.notifier).refreshProducts();
} }
void _showBarcodeScanner() {
final controller = MobileScannerController(
formats: const [BarcodeFormat.code128],
facing: CameraFacing.back,
);
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => Container(
height: MediaQuery.of(context).size.height * 0.7,
decoration: const BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade900,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(20),
),
),
child: Row(
children: [
const Icon(
Icons.qr_code_scanner,
color: Colors.white,
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Scan Barcode',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () {
controller.dispose();
Navigator.pop(context);
},
),
],
),
),
// Scanner
Expanded(
child: MobileScanner(
controller: controller,
onDetect: (capture) {
final List<Barcode> barcodes = capture.barcodes;
if (barcodes.isNotEmpty) {
final barcode = barcodes.first.rawValue;
if (barcode != null) {
controller.dispose();
Navigator.pop(context);
_handleScannedBarcode(barcode);
}
}
},
),
),
// Instructions
Container(
padding: const EdgeInsets.all(16),
color: Colors.grey.shade900,
child: const Text(
'Position the Code 128 barcode within the frame to scan',
style: TextStyle(
color: Colors.white70,
fontSize: 14,
),
textAlign: TextAlign.center,
),
),
],
),
),
).whenComplete(() => controller.dispose());
}
void _handleScannedBarcode(String barcode) {
// Parse barcode to extract productId and optional stageId
// Format 1: "123" (only productId)
// Format 2: "123-456" (productId-stageId)
int? productId;
int? stageId;
if (barcode.contains('-')) {
// Format: productId-stageId
final parts = barcode.split('-');
if (parts.length == 2) {
productId = int.tryParse(parts[0]);
stageId = int.tryParse(parts[1]);
}
} else {
// Format: productId only
productId = int.tryParse(barcode);
}
if (productId == null) {
// Invalid barcode format
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Invalid barcode format: "$barcode"'),
backgroundColor: Colors.red,
action: SnackBarAction(
label: 'OK',
textColor: Colors.white,
onPressed: () {},
),
),
);
return;
}
// Navigate to product detail with productId and optional stageId
context.goToProductDetail(
warehouseId: widget.warehouseId,
productId: productId,
warehouseName: widget.warehouseName,
stageId: stageId,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -83,6 +219,13 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
products: products, products: products,
theme: theme, theme: theme,
), ),
floatingActionButton: products.isNotEmpty
? FloatingActionButton(
onPressed: _showBarcodeScanner,
tooltip: 'Scan Barcode',
child: const Icon(Icons.qr_code_scanner),
)
: null,
); );
} }