This commit is contained in:
2025-10-28 00:43:32 +07:00
parent de49f564b1
commit df99d0c9e3
19 changed files with 1000 additions and 1453 deletions

View File

@@ -1,7 +1,10 @@
import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/errors/exceptions.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/network/api_response.dart';
import '../models/product_detail_request_model.dart';
import '../models/product_model.dart';
import '../models/product_stage_model.dart';
/// Abstract interface for products remote data source
abstract class ProductsRemoteDataSource {
@@ -13,6 +16,14 @@ abstract class ProductsRemoteDataSource {
/// Returns List<ProductModel>
/// Throws [ServerException] if the API call fails
Future<List<ProductModel>> getProducts(int warehouseId, String type);
/// Get product stages for a product in a warehouse
///
/// [request] - Request containing warehouseId and productId
///
/// Returns List<ProductStageModel> with all stages for the product
/// Throws [ServerException] if the API call fails
Future<List<ProductStageModel>> getProductDetail(ProductDetailRequestModel request);
}
/// Implementation of ProductsRemoteDataSource using ApiClient
@@ -59,4 +70,55 @@ class ProductsRemoteDataSourceImpl implements ProductsRemoteDataSource {
throw ServerException('Failed to get products: ${e.toString()}');
}
}
@override
Future<List<ProductStageModel>> getProductDetail(
ProductDetailRequestModel request) async {
try {
// Make API call to get product stages
final response = await apiClient.post(
ApiEndpoints.productDetail,
data: request.toJson(),
);
// Parse the API response - the Value field contains a list of stage objects
final apiResponse = ApiResponse.fromJson(
response.data as Map<String, dynamic>,
(json) {
// The API returns a list of stages for the product
final list = json as List;
if (list.isEmpty) {
throw const ServerException('Product stages not found');
}
// Parse all stages from the list
return list
.map((e) => ProductStageModel.fromJson(e as Map<String, dynamic>))
.toList();
},
);
// Check if the API call was successful
if (apiResponse.isSuccess && apiResponse.value != null) {
return apiResponse.value!;
} else {
// Throw exception with error message from API
throw ServerException(
apiResponse.errors.isNotEmpty
? apiResponse.errors.first
: 'Failed to get product stages',
);
}
} catch (e) {
// Re-throw ServerException as-is
if (e is ServerException) {
rethrow;
}
// Re-throw NetworkException as-is
if (e is NetworkException) {
rethrow;
}
// Wrap other exceptions in ServerException
throw ServerException('Failed to get product stages: ${e.toString()}');
}
}
}

View File

@@ -0,0 +1,25 @@
/// Request model for getting product details in a warehouse
///
/// Used to fetch product stage information for a specific warehouse and product
class ProductDetailRequestModel {
final int warehouseId;
final int productId;
const ProductDetailRequestModel({
required this.warehouseId,
required this.productId,
});
/// Convert to JSON for API request
Map<String, dynamic> toJson() {
return {
'WareHouseId': warehouseId,
'ProductId': productId,
};
}
@override
String toString() {
return 'ProductDetailRequestModel(warehouseId: $warehouseId, productId: $productId)';
}
}

View File

@@ -0,0 +1,67 @@
import '../../domain/entities/product_stage_entity.dart';
/// Product stage model for data layer
/// Represents a product at a specific production stage
class ProductStageModel extends ProductStageEntity {
const ProductStageModel({
required super.productId,
required super.productStageId,
required super.actionTypeId,
required super.passedQuantity,
required super.issuedQuantity,
required super.issuedQuantityWeight,
required super.passedQuantityWeight,
required super.stageName,
required super.createdDate,
});
/// Create ProductStageModel from JSON
factory ProductStageModel.fromJson(Map<String, dynamic> json) {
return ProductStageModel(
productId: json['ProductId'] as int,
productStageId: json['ProductStageId'] as int?,
actionTypeId: json['ActionTypeId'] as int?,
passedQuantity: json['PassedQuantity'] as int,
issuedQuantity: json['IssuedQuantity'] as int,
issuedQuantityWeight: (json['IssuedQuantityWeight'] as num).toDouble(),
passedQuantityWeight: (json['PassedQuantityWeight'] as num).toDouble(),
stageName: json['StageName'] as String?,
createdDate: json['CreatedDate'] as String,
);
}
/// Convert to JSON
Map<String, dynamic> toJson() {
return {
'ProductId': productId,
'ProductStageId': productStageId,
'ActionTypeId': actionTypeId,
'PassedQuantity': passedQuantity,
'IssuedQuantity': issuedQuantity,
'IssuedQuantityWeight': issuedQuantityWeight,
'PassedQuantityWeight': passedQuantityWeight,
'StageName': stageName,
'CreatedDate': createdDate,
};
}
/// Convert to entity
ProductStageEntity toEntity() {
return ProductStageEntity(
productId: productId,
productStageId: productStageId,
actionTypeId: actionTypeId,
passedQuantity: passedQuantity,
issuedQuantity: issuedQuantity,
issuedQuantityWeight: issuedQuantityWeight,
passedQuantityWeight: passedQuantityWeight,
stageName: stageName,
createdDate: createdDate,
);
}
@override
String toString() {
return 'ProductStageModel(productId: $productId, stageName: $stageName, passedQuantity: $passedQuantity)';
}
}

View File

@@ -2,8 +2,10 @@ import 'package:dartz/dartz.dart';
import '../../../../core/errors/exceptions.dart';
import '../../../../core/errors/failures.dart';
import '../../domain/entities/product_entity.dart';
import '../../domain/entities/product_stage_entity.dart';
import '../../domain/repositories/products_repository.dart';
import '../datasources/products_remote_datasource.dart';
import '../models/product_detail_request_model.dart';
/// Implementation of ProductsRepository
/// Handles data operations and error conversion
@@ -34,4 +36,33 @@ class ProductsRepositoryImpl implements ProductsRepository {
return Left(ServerFailure('Unexpected error: ${e.toString()}'));
}
}
@override
Future<Either<Failure, List<ProductStageEntity>>> getProductDetail(
int warehouseId,
int productId,
) async {
try {
// Create request model
final request = ProductDetailRequestModel(
warehouseId: warehouseId,
productId: productId,
);
// Fetch product stages from remote data source
final stages = await remoteDataSource.getProductDetail(request);
// Convert models to entities and return success
return Right(stages.map((stage) => stage.toEntity()).toList());
} on ServerException catch (e) {
// Convert ServerException to ServerFailure
return Left(ServerFailure(e.message));
} on NetworkException catch (e) {
// Convert NetworkException to NetworkFailure
return Left(NetworkFailure(e.message));
} catch (e) {
// Handle any other exceptions
return Left(ServerFailure('Unexpected error: ${e.toString()}'));
}
}
}

View File

@@ -0,0 +1,58 @@
import 'package:equatable/equatable.dart';
/// Product stage entity - pure domain model
/// Represents a product at a specific production stage with quantities
class ProductStageEntity extends Equatable {
final int productId;
final int? productStageId;
final int? actionTypeId;
final int passedQuantity;
final int issuedQuantity;
final double issuedQuantityWeight;
final double passedQuantityWeight;
final String? stageName;
final String createdDate;
const ProductStageEntity({
required this.productId,
required this.productStageId,
required this.actionTypeId,
required this.passedQuantity,
required this.issuedQuantity,
required this.issuedQuantityWeight,
required this.passedQuantityWeight,
required this.stageName,
required this.createdDate,
});
/// Get display name for the stage
/// Returns "No Stage" if stageName is null
String get displayName => stageName ?? 'No Stage';
/// Check if this is a valid stage (has a stage name)
bool get hasStage => stageName != null && stageName!.isNotEmpty;
/// Check if this stage has any passed quantity
bool get hasPassedQuantity => passedQuantity > 0;
/// Check if this stage has any issued quantity
bool get hasIssuedQuantity => issuedQuantity > 0;
@override
List<Object?> get props => [
productId,
productStageId,
actionTypeId,
passedQuantity,
issuedQuantity,
issuedQuantityWeight,
passedQuantityWeight,
stageName,
createdDate,
];
@override
String toString() {
return 'ProductStageEntity(productId: $productId, stageName: $stageName, passedQuantity: $passedQuantity)';
}
}

View File

@@ -1,6 +1,7 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/product_entity.dart';
import '../entities/product_stage_entity.dart';
/// Abstract repository interface for products
/// Defines the contract for product data operations
@@ -15,4 +16,15 @@ abstract class ProductsRepository {
int warehouseId,
String type,
);
/// Get product stages for a product in a warehouse
///
/// [warehouseId] - The ID of the warehouse
/// [productId] - The ID of the product
///
/// Returns Either<Failure, List<ProductStageEntity>>
Future<Either<Failure, List<ProductStageEntity>>> getProductDetail(
int warehouseId,
int productId,
);
}

View File

@@ -0,0 +1,25 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/product_stage_entity.dart';
import '../repositories/products_repository.dart';
/// Use case for getting product stages
/// Encapsulates the business logic for fetching product stage information
class GetProductDetailUseCase {
final ProductsRepository repository;
GetProductDetailUseCase(this.repository);
/// Execute the use case
///
/// [warehouseId] - The ID of the warehouse
/// [productId] - The ID of the product
///
/// Returns Either<Failure, List<ProductStageEntity>>
Future<Either<Failure, List<ProductStageEntity>>> call(
int warehouseId,
int productId,
) async {
return await repository.getProductDetail(warehouseId, productId);
}
}

View File

@@ -0,0 +1,460 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/di/providers.dart';
import '../../domain/entities/product_stage_entity.dart';
/// Product detail page
/// Displays product stages as chips and shows selected stage information
class ProductDetailPage extends ConsumerStatefulWidget {
final int warehouseId;
final int productId;
final String warehouseName;
const ProductDetailPage({
super.key,
required this.warehouseId,
required this.productId,
required this.warehouseName,
});
@override
ConsumerState<ProductDetailPage> createState() => _ProductDetailPageState();
}
class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
late String _providerKey;
@override
void initState() {
super.initState();
_providerKey = '${widget.warehouseId}_${widget.productId}';
// Load product stages when page is initialized
Future.microtask(() {
ref.read(productDetailProvider(_providerKey).notifier).loadProductDetail(
widget.warehouseId,
widget.productId,
);
});
}
Future<void> _onRefresh() async {
await ref.read(productDetailProvider(_providerKey).notifier).refreshProductDetail(
widget.warehouseId,
widget.productId,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
// Watch the product detail state
final productDetailState = ref.watch(productDetailProvider(_providerKey));
final stages = productDetailState.stages;
final selectedStage = productDetailState.selectedStage;
final isLoading = productDetailState.isLoading;
final error = productDetailState.error;
final selectedIndex = productDetailState.selectedStageIndex;
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Product Stages',
style: textTheme.titleMedium,
),
Text(
widget.warehouseName,
style: textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _onRefresh,
tooltip: 'Refresh',
),
],
),
body: _buildBody(
isLoading: isLoading,
error: error,
stages: stages,
selectedStage: selectedStage,
selectedIndex: selectedIndex,
theme: theme,
),
);
}
Widget _buildBody({
required bool isLoading,
required String? error,
required List<ProductStageEntity> stages,
required ProductStageEntity? selectedStage,
required int selectedIndex,
required ThemeData theme,
}) {
if (isLoading && stages.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (error != null && stages.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 48,
color: theme.colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Error',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.error,
),
),
const SizedBox(height: 8),
Text(
error,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _onRefresh,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
);
}
if (stages.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_outlined,
size: 48,
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
'No stages found',
style: theme.textTheme.titleLarge,
),
],
),
);
}
return RefreshIndicator(
onRefresh: _onRefresh,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Stage chips section
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
border: Border(
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(
spacing: 8,
runSpacing: 8,
children: List.generate(stages.length, (index) {
final stage = stages[index];
final isSelected = index == selectedIndex;
return FilterChip(
selected: isSelected,
label: Text(stage.displayName),
onSelected: (_) {
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,
),
);
}),
),
],
),
),
// Stage details section
Expanded(
child: selectedStage == null
? const Center(child: Text('No stage selected'))
: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Stage header
_buildStageHeader(selectedStage, theme),
const SizedBox(height: 16),
// Quantity information
_buildSectionCard(
theme: theme,
title: 'Quantities',
icon: Icons.inventory_outlined,
children: [
_buildInfoRow('Passed Quantity', '${selectedStage.passedQuantity}'),
_buildInfoRow(
'Passed Weight',
'${selectedStage.passedQuantityWeight.toStringAsFixed(2)} kg',
),
const Divider(height: 24),
_buildInfoRow('Issued Quantity', '${selectedStage.issuedQuantity}'),
_buildInfoRow(
'Issued Weight',
'${selectedStage.issuedQuantityWeight.toStringAsFixed(2)} kg',
),
],
),
const SizedBox(height: 16),
// Stage information
_buildSectionCard(
theme: theme,
title: 'Stage Information',
icon: Icons.info_outlined,
children: [
_buildInfoRow('Product ID', '${selectedStage.productId}'),
if (selectedStage.productStageId != null)
_buildInfoRow('Stage ID', '${selectedStage.productStageId}'),
if (selectedStage.actionTypeId != null)
_buildInfoRow('Action Type ID', '${selectedStage.actionTypeId}'),
_buildInfoRow('Stage Name', selectedStage.displayName),
],
),
const SizedBox(height: 16),
// Status indicators
_buildStatusCards(selectedStage, theme),
],
),
),
),
],
),
);
}
Widget _buildStageHeader(ProductStageEntity stage, ThemeData theme) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.build_circle_outlined,
size: 32,
color: theme.colorScheme.primary,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
stage.displayName,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Product ID: ${stage.productId}',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
);
}
Widget _buildSectionCard({
required ThemeData theme,
required String title,
required IconData icon,
required List<Widget> children,
}) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
...children,
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 16),
Expanded(
flex: 3,
child: Text(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.end,
),
),
],
),
);
}
Widget _buildStatusCards(ProductStageEntity stage, ThemeData theme) {
return Row(
children: [
Expanded(
child: Card(
color: stage.hasPassedQuantity
? Colors.green.withValues(alpha: 0.1)
: Colors.grey.withValues(alpha: 0.1),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Icon(
stage.hasPassedQuantity ? Icons.check_circle : Icons.cancel,
color: stage.hasPassedQuantity ? Colors.green : Colors.grey,
size: 32,
),
const SizedBox(height: 8),
Text(
'Has Passed',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Card(
color: stage.hasIssuedQuantity
? Colors.blue.withValues(alpha: 0.1)
: Colors.grey.withValues(alpha: 0.1),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Icon(
stage.hasIssuedQuantity ? Icons.check_circle : Icons.cancel,
color: stage.hasIssuedQuantity ? Colors.blue : Colors.grey,
size: 32,
),
const SizedBox(height: 8),
Text(
'Has Issued',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
),
),
),
),
],
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/di/providers.dart';
import '../../../../core/router/app_router.dart';
import '../widgets/product_list_item.dart';
/// Products list page
@@ -261,13 +262,11 @@ class _ProductsPageState extends ConsumerState<ProductsPage> {
return ProductListItem(
product: product,
onTap: () {
// Handle product tap if needed
// For now, just show a snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Selected: ${product.fullName}'),
duration: const Duration(seconds: 1),
),
// Navigate to product detail page
context.goToProductDetail(
warehouseId: widget.warehouseId,
productId: product.id,
warehouseName: widget.warehouseName,
);
},
);

View File

@@ -0,0 +1,106 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/entities/product_stage_entity.dart';
import '../../domain/usecases/get_product_detail_usecase.dart';
/// Product detail state class
/// Holds the current state of product stages and selection
class ProductDetailState {
final List<ProductStageEntity> stages;
final int selectedStageIndex;
final bool isLoading;
final String? error;
const ProductDetailState({
this.stages = const [],
this.selectedStageIndex = 0,
this.isLoading = false,
this.error,
});
/// Get the currently selected stage
ProductStageEntity? get selectedStage {
if (stages.isEmpty || selectedStageIndex >= stages.length) {
return null;
}
return stages[selectedStageIndex];
}
/// Check if there are any stages loaded
bool get hasStages => stages.isNotEmpty;
ProductDetailState copyWith({
List<ProductStageEntity>? stages,
int? selectedStageIndex,
bool? isLoading,
String? error,
}) {
return ProductDetailState(
stages: stages ?? this.stages,
selectedStageIndex: selectedStageIndex ?? this.selectedStageIndex,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
}
/// Product detail notifier
/// Manages the product detail state and business logic
class ProductDetailNotifier extends StateNotifier<ProductDetailState> {
final GetProductDetailUseCase getProductDetailUseCase;
ProductDetailNotifier(this.getProductDetailUseCase)
: super(const ProductDetailState());
/// Load product stages for a specific warehouse and product
///
/// [warehouseId] - The ID of the warehouse
/// [productId] - The ID of the product
Future<void> loadProductDetail(int warehouseId, int productId) async {
// Set loading state
state = state.copyWith(
isLoading: true,
error: null,
);
// Call the use case
final result = await getProductDetailUseCase(warehouseId, productId);
// Handle the result
result.fold(
(failure) {
// Handle failure
state = state.copyWith(
isLoading: false,
error: failure.message,
stages: [],
);
},
(stages) {
// Handle success - load all stages
state = state.copyWith(
isLoading: false,
error: null,
stages: stages,
selectedStageIndex: 0, // Select first stage by default
);
},
);
}
/// Select a stage by index
void selectStage(int index) {
if (index >= 0 && index < state.stages.length) {
state = state.copyWith(selectedStageIndex: index);
}
}
/// Refresh product stages
Future<void> refreshProductDetail(int warehouseId, int productId) async {
await loadProductDetail(warehouseId, productId);
}
/// Clear product detail
void clearProductDetail() {
state = const ProductDetailState();
}
}