feat: inventory

This commit is contained in:
efrilm 2025-08-17 23:54:28 +07:00
parent 577adb7964
commit d22ffdd6d0
20 changed files with 5118 additions and 506 deletions

View File

@ -0,0 +1,50 @@
import 'package:dartz/dartz.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart';
import '../../../domain/analytic/analytic.dart';
import '../../../domain/analytic/repositories/i_analytic_repository.dart';
part 'inventory_analytic_loader_event.dart';
part 'inventory_analytic_loader_state.dart';
part 'inventory_analytic_loader_bloc.freezed.dart';
@injectable
class InventoryAnalyticLoaderBloc
extends Bloc<InventoryAnalyticLoaderEvent, InventoryAnalyticLoaderState> {
final IAnalyticRepository _repository;
InventoryAnalyticLoaderBloc(this._repository)
: super(InventoryAnalyticLoaderState.initial()) {
on<InventoryAnalyticLoaderEvent>(_onInventoryAnalyticLoaderEvent);
}
Future<void> _onInventoryAnalyticLoaderEvent(
InventoryAnalyticLoaderEvent event,
Emitter<InventoryAnalyticLoaderState> emit,
) {
return event.map(
fetched: (e) async {
emit(
state.copyWith(
isFetching: true,
failureOptionInventoryAnalytic: none(),
),
);
final result = await _repository.getInventory(
dateFrom: DateTime.now().subtract(const Duration(days: 30)),
dateTo: DateTime.now(),
);
var data = result.fold(
(f) => state.copyWith(failureOptionInventoryAnalytic: optionOf(f)),
(inventoryAnalytic) =>
state.copyWith(inventoryAnalytic: inventoryAnalytic),
);
emit(data.copyWith(isFetching: false));
},
);
}
}

View File

@ -0,0 +1,406 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'inventory_analytic_loader_bloc.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
/// @nodoc
mixin _$InventoryAnalyticLoaderEvent {
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function() fetched,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? fetched,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? fetched,
required TResult orElse(),
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(_Fetched value) fetched,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(_Fetched value)? fetched,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(_Fetched value)? fetched,
required TResult orElse(),
}) => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $InventoryAnalyticLoaderEventCopyWith<$Res> {
factory $InventoryAnalyticLoaderEventCopyWith(
InventoryAnalyticLoaderEvent value,
$Res Function(InventoryAnalyticLoaderEvent) then,
) =
_$InventoryAnalyticLoaderEventCopyWithImpl<
$Res,
InventoryAnalyticLoaderEvent
>;
}
/// @nodoc
class _$InventoryAnalyticLoaderEventCopyWithImpl<
$Res,
$Val extends InventoryAnalyticLoaderEvent
>
implements $InventoryAnalyticLoaderEventCopyWith<$Res> {
_$InventoryAnalyticLoaderEventCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of InventoryAnalyticLoaderEvent
/// with the given fields replaced by the non-null parameter values.
}
/// @nodoc
abstract class _$$FetchedImplCopyWith<$Res> {
factory _$$FetchedImplCopyWith(
_$FetchedImpl value,
$Res Function(_$FetchedImpl) then,
) = __$$FetchedImplCopyWithImpl<$Res>;
}
/// @nodoc
class __$$FetchedImplCopyWithImpl<$Res>
extends _$InventoryAnalyticLoaderEventCopyWithImpl<$Res, _$FetchedImpl>
implements _$$FetchedImplCopyWith<$Res> {
__$$FetchedImplCopyWithImpl(
_$FetchedImpl _value,
$Res Function(_$FetchedImpl) _then,
) : super(_value, _then);
/// Create a copy of InventoryAnalyticLoaderEvent
/// with the given fields replaced by the non-null parameter values.
}
/// @nodoc
class _$FetchedImpl implements _Fetched {
const _$FetchedImpl();
@override
String toString() {
return 'InventoryAnalyticLoaderEvent.fetched()';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType && other is _$FetchedImpl);
}
@override
int get hashCode => runtimeType.hashCode;
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({required TResult Function() fetched}) {
return fetched();
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({TResult? Function()? fetched}) {
return fetched?.call();
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? fetched,
required TResult orElse(),
}) {
if (fetched != null) {
return fetched();
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(_Fetched value) fetched,
}) {
return fetched(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(_Fetched value)? fetched,
}) {
return fetched?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(_Fetched value)? fetched,
required TResult orElse(),
}) {
if (fetched != null) {
return fetched(this);
}
return orElse();
}
}
abstract class _Fetched implements InventoryAnalyticLoaderEvent {
const factory _Fetched() = _$FetchedImpl;
}
/// @nodoc
mixin _$InventoryAnalyticLoaderState {
InventoryAnalytic get inventoryAnalytic => throw _privateConstructorUsedError;
Option<AnalyticFailure> get failureOptionInventoryAnalytic =>
throw _privateConstructorUsedError;
bool get isFetching => throw _privateConstructorUsedError;
/// Create a copy of InventoryAnalyticLoaderState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$InventoryAnalyticLoaderStateCopyWith<InventoryAnalyticLoaderState>
get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $InventoryAnalyticLoaderStateCopyWith<$Res> {
factory $InventoryAnalyticLoaderStateCopyWith(
InventoryAnalyticLoaderState value,
$Res Function(InventoryAnalyticLoaderState) then,
) =
_$InventoryAnalyticLoaderStateCopyWithImpl<
$Res,
InventoryAnalyticLoaderState
>;
@useResult
$Res call({
InventoryAnalytic inventoryAnalytic,
Option<AnalyticFailure> failureOptionInventoryAnalytic,
bool isFetching,
});
$InventoryAnalyticCopyWith<$Res> get inventoryAnalytic;
}
/// @nodoc
class _$InventoryAnalyticLoaderStateCopyWithImpl<
$Res,
$Val extends InventoryAnalyticLoaderState
>
implements $InventoryAnalyticLoaderStateCopyWith<$Res> {
_$InventoryAnalyticLoaderStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of InventoryAnalyticLoaderState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? inventoryAnalytic = null,
Object? failureOptionInventoryAnalytic = null,
Object? isFetching = null,
}) {
return _then(
_value.copyWith(
inventoryAnalytic: null == inventoryAnalytic
? _value.inventoryAnalytic
: inventoryAnalytic // ignore: cast_nullable_to_non_nullable
as InventoryAnalytic,
failureOptionInventoryAnalytic:
null == failureOptionInventoryAnalytic
? _value.failureOptionInventoryAnalytic
: failureOptionInventoryAnalytic // ignore: cast_nullable_to_non_nullable
as Option<AnalyticFailure>,
isFetching: null == isFetching
? _value.isFetching
: isFetching // ignore: cast_nullable_to_non_nullable
as bool,
)
as $Val,
);
}
/// Create a copy of InventoryAnalyticLoaderState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$InventoryAnalyticCopyWith<$Res> get inventoryAnalytic {
return $InventoryAnalyticCopyWith<$Res>(_value.inventoryAnalytic, (value) {
return _then(_value.copyWith(inventoryAnalytic: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$InventoryAnalyticLoaderStateImplCopyWith<$Res>
implements $InventoryAnalyticLoaderStateCopyWith<$Res> {
factory _$$InventoryAnalyticLoaderStateImplCopyWith(
_$InventoryAnalyticLoaderStateImpl value,
$Res Function(_$InventoryAnalyticLoaderStateImpl) then,
) = __$$InventoryAnalyticLoaderStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
InventoryAnalytic inventoryAnalytic,
Option<AnalyticFailure> failureOptionInventoryAnalytic,
bool isFetching,
});
@override
$InventoryAnalyticCopyWith<$Res> get inventoryAnalytic;
}
/// @nodoc
class __$$InventoryAnalyticLoaderStateImplCopyWithImpl<$Res>
extends
_$InventoryAnalyticLoaderStateCopyWithImpl<
$Res,
_$InventoryAnalyticLoaderStateImpl
>
implements _$$InventoryAnalyticLoaderStateImplCopyWith<$Res> {
__$$InventoryAnalyticLoaderStateImplCopyWithImpl(
_$InventoryAnalyticLoaderStateImpl _value,
$Res Function(_$InventoryAnalyticLoaderStateImpl) _then,
) : super(_value, _then);
/// Create a copy of InventoryAnalyticLoaderState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? inventoryAnalytic = null,
Object? failureOptionInventoryAnalytic = null,
Object? isFetching = null,
}) {
return _then(
_$InventoryAnalyticLoaderStateImpl(
inventoryAnalytic: null == inventoryAnalytic
? _value.inventoryAnalytic
: inventoryAnalytic // ignore: cast_nullable_to_non_nullable
as InventoryAnalytic,
failureOptionInventoryAnalytic: null == failureOptionInventoryAnalytic
? _value.failureOptionInventoryAnalytic
: failureOptionInventoryAnalytic // ignore: cast_nullable_to_non_nullable
as Option<AnalyticFailure>,
isFetching: null == isFetching
? _value.isFetching
: isFetching // ignore: cast_nullable_to_non_nullable
as bool,
),
);
}
}
/// @nodoc
class _$InventoryAnalyticLoaderStateImpl
implements _InventoryAnalyticLoaderState {
const _$InventoryAnalyticLoaderStateImpl({
required this.inventoryAnalytic,
required this.failureOptionInventoryAnalytic,
this.isFetching = false,
});
@override
final InventoryAnalytic inventoryAnalytic;
@override
final Option<AnalyticFailure> failureOptionInventoryAnalytic;
@override
@JsonKey()
final bool isFetching;
@override
String toString() {
return 'InventoryAnalyticLoaderState(inventoryAnalytic: $inventoryAnalytic, failureOptionInventoryAnalytic: $failureOptionInventoryAnalytic, isFetching: $isFetching)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$InventoryAnalyticLoaderStateImpl &&
(identical(other.inventoryAnalytic, inventoryAnalytic) ||
other.inventoryAnalytic == inventoryAnalytic) &&
(identical(
other.failureOptionInventoryAnalytic,
failureOptionInventoryAnalytic,
) ||
other.failureOptionInventoryAnalytic ==
failureOptionInventoryAnalytic) &&
(identical(other.isFetching, isFetching) ||
other.isFetching == isFetching));
}
@override
int get hashCode => Object.hash(
runtimeType,
inventoryAnalytic,
failureOptionInventoryAnalytic,
isFetching,
);
/// Create a copy of InventoryAnalyticLoaderState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$InventoryAnalyticLoaderStateImplCopyWith<
_$InventoryAnalyticLoaderStateImpl
>
get copyWith =>
__$$InventoryAnalyticLoaderStateImplCopyWithImpl<
_$InventoryAnalyticLoaderStateImpl
>(this, _$identity);
}
abstract class _InventoryAnalyticLoaderState
implements InventoryAnalyticLoaderState {
const factory _InventoryAnalyticLoaderState({
required final InventoryAnalytic inventoryAnalytic,
required final Option<AnalyticFailure> failureOptionInventoryAnalytic,
final bool isFetching,
}) = _$InventoryAnalyticLoaderStateImpl;
@override
InventoryAnalytic get inventoryAnalytic;
@override
Option<AnalyticFailure> get failureOptionInventoryAnalytic;
@override
bool get isFetching;
/// Create a copy of InventoryAnalyticLoaderState
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$InventoryAnalyticLoaderStateImplCopyWith<
_$InventoryAnalyticLoaderStateImpl
>
get copyWith => throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,6 @@
part of 'inventory_analytic_loader_bloc.dart';
@freezed
class InventoryAnalyticLoaderEvent with _$InventoryAnalyticLoaderEvent {
const factory InventoryAnalyticLoaderEvent.fetched() = _Fetched;
}

View File

@ -0,0 +1,16 @@
part of 'inventory_analytic_loader_bloc.dart';
@freezed
class InventoryAnalyticLoaderState with _$InventoryAnalyticLoaderState {
const factory InventoryAnalyticLoaderState({
required InventoryAnalytic inventoryAnalytic,
required Option<AnalyticFailure> failureOptionInventoryAnalytic,
@Default(false) bool isFetching,
}) = _InventoryAnalyticLoaderState;
factory InventoryAnalyticLoaderState.initial() =>
InventoryAnalyticLoaderState(
inventoryAnalytic: InventoryAnalytic.empty(),
failureOptionInventoryAnalytic: None(),
);
}

View File

@ -8,6 +8,10 @@ class ApiPath {
static const String profitLossAnalytic = '/api/v1/analytics/profit-loss';
static const String categoryAnalytic = '/api/v1/analytics/categories';
// Inventory
static const String inventoryReportDetail =
'/api/v1/inventory/report/details';
// Category
static const String category = '/api/v1/categories';

View File

@ -7,4 +7,5 @@ part 'analytic.freezed.dart';
part 'entities/sales_analytic_entity.dart';
part 'entities/profit_loss_analytic_entity.dart';
part 'entities/category_analytic_entity.dart';
part 'entities/inventory_analytic_entity.dart';
part 'failures/analytic_failure.dart';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,119 @@
part of '../analytic.dart';
@freezed
class InventoryAnalytic with _$InventoryAnalytic {
const factory InventoryAnalytic({
required InventorySummary summary,
required List<InventoryProduct> products,
required List<InventoryIngredient> ingredients,
}) = _InventoryAnalytic;
factory InventoryAnalytic.empty() => InventoryAnalytic(
summary: InventorySummary.empty(),
products: [],
ingredients: [],
);
}
@freezed
class InventorySummary with _$InventorySummary {
const factory InventorySummary({
required int totalProducts,
required int totalIngredients,
required int totalValue,
required int lowStockProducts,
required int lowStockIngredients,
required int zeroStockProducts,
required int zeroStockIngredients,
required int totalSoldProducts,
required int totalSoldIngredients,
required String outletId,
required String outletName,
required String generatedAt,
}) = _InventorySummary;
factory InventorySummary.empty() => const InventorySummary(
totalProducts: 0,
totalIngredients: 0,
totalValue: 0,
lowStockProducts: 0,
lowStockIngredients: 0,
zeroStockProducts: 0,
zeroStockIngredients: 0,
totalSoldProducts: 0,
totalSoldIngredients: 0,
outletId: "",
outletName: "",
generatedAt: "",
);
}
@freezed
class InventoryProduct with _$InventoryProduct {
const factory InventoryProduct({
required String id,
required String productId,
required String productName,
required String categoryName,
required int quantity,
required int reorderLevel,
required int unitCost,
required int totalValue,
required int totalIn,
required int totalOut,
required bool isLowStock,
required bool isZeroStock,
required String updatedAt,
}) = _InventoryProduct;
factory InventoryProduct.empty() => const InventoryProduct(
id: "",
productId: "",
productName: "",
categoryName: "",
quantity: 0,
reorderLevel: 0,
unitCost: 0,
totalValue: 0,
totalIn: 0,
totalOut: 0,
isLowStock: false,
isZeroStock: false,
updatedAt: "",
);
}
@freezed
class InventoryIngredient with _$InventoryIngredient {
const factory InventoryIngredient({
required String id,
required String ingredientId,
required String ingredientName,
required String unitName,
required int quantity,
required int reorderLevel,
required int unitCost,
required int totalValue,
required int totalIn,
required int totalOut,
required bool isLowStock,
required bool isZeroStock,
required String updatedAt,
}) = _InventoryIngredient;
factory InventoryIngredient.empty() => const InventoryIngredient(
id: "",
ingredientId: "",
ingredientName: "",
unitName: "",
quantity: 0,
reorderLevel: 0,
unitCost: 0,
totalValue: 0,
totalIn: 0,
totalOut: 0,
isLowStock: false,
isZeroStock: false,
updatedAt: "",
);
}

View File

@ -17,4 +17,9 @@ abstract class IAnalyticRepository {
required DateTime dateFrom,
required DateTime dateTo,
});
Future<Either<AnalyticFailure, InventoryAnalytic>> getInventory({
required DateTime dateFrom,
required DateTime dateTo,
});
}

View File

@ -8,3 +8,4 @@ part 'analytic_dtos.g.dart';
part 'dto/sales_analytic_dto.dart';
part 'dto/profit_loss_analytic_dto.dart';
part 'dto/category_analytic_dto.dart';
part 'dto/inventory_analytic_dto.dart';

File diff suppressed because it is too large Load Diff

View File

@ -257,3 +257,131 @@ Map<String, dynamic> _$$CategoryAnalyticItemDtoImplToJson(
'product_count': instance.productCount,
'order_count': instance.orderCount,
};
_$InventoryAnalyticDtoImpl _$$InventoryAnalyticDtoImplFromJson(
Map<String, dynamic> json,
) => _$InventoryAnalyticDtoImpl(
summary: json['summary'] == null
? null
: InventorySummaryDto.fromJson(json['summary'] as Map<String, dynamic>),
products: (json['products'] as List<dynamic>?)
?.map((e) => InventoryProductDto.fromJson(e as Map<String, dynamic>))
.toList(),
ingredients: (json['ingredients'] as List<dynamic>?)
?.map((e) => InventoryIngredientDto.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$$InventoryAnalyticDtoImplToJson(
_$InventoryAnalyticDtoImpl instance,
) => <String, dynamic>{
'summary': instance.summary,
'products': instance.products,
'ingredients': instance.ingredients,
};
_$InventorySummaryDtoImpl _$$InventorySummaryDtoImplFromJson(
Map<String, dynamic> json,
) => _$InventorySummaryDtoImpl(
totalProducts: (json['total_products'] as num?)?.toInt(),
totalIngredients: (json['total_ingredients'] as num?)?.toInt(),
totalValue: (json['total_value'] as num?)?.toInt(),
lowStockProducts: (json['low_stock_products'] as num?)?.toInt(),
lowStockIngredients: (json['low_stock_ingredients'] as num?)?.toInt(),
zeroStockProducts: (json['zero_stock_products'] as num?)?.toInt(),
zeroStockIngredients: (json['zero_stock_ingredients'] as num?)?.toInt(),
totalSoldProducts: (json['total_sold_products'] as num?)?.toInt(),
totalSoldIngredients: (json['total_sold_ingredients'] as num?)?.toInt(),
outletId: json['outlet_id'] as String?,
outletName: json['outlet_name'] as String?,
generatedAt: json['generated_at'] as String?,
);
Map<String, dynamic> _$$InventorySummaryDtoImplToJson(
_$InventorySummaryDtoImpl instance,
) => <String, dynamic>{
'total_products': instance.totalProducts,
'total_ingredients': instance.totalIngredients,
'total_value': instance.totalValue,
'low_stock_products': instance.lowStockProducts,
'low_stock_ingredients': instance.lowStockIngredients,
'zero_stock_products': instance.zeroStockProducts,
'zero_stock_ingredients': instance.zeroStockIngredients,
'total_sold_products': instance.totalSoldProducts,
'total_sold_ingredients': instance.totalSoldIngredients,
'outlet_id': instance.outletId,
'outlet_name': instance.outletName,
'generated_at': instance.generatedAt,
};
_$InventoryProductDtoImpl _$$InventoryProductDtoImplFromJson(
Map<String, dynamic> json,
) => _$InventoryProductDtoImpl(
id: json['id'] as String?,
productId: json['product_id'] as String?,
productName: json['product_name'] as String?,
categoryName: json['category_name'] as String?,
quantity: (json['quantity'] as num?)?.toInt(),
reorderLevel: (json['reorder_level'] as num?)?.toInt(),
unitCost: (json['unit_cost'] as num?)?.toInt(),
totalValue: (json['total_value'] as num?)?.toInt(),
totalIn: (json['total_in'] as num?)?.toInt(),
totalOut: (json['total_out'] as num?)?.toInt(),
isLowStock: json['is_low_stock'] as bool?,
isZeroStock: json['is_zero_stock'] as bool?,
updatedAt: json['updated_at'] as String?,
);
Map<String, dynamic> _$$InventoryProductDtoImplToJson(
_$InventoryProductDtoImpl instance,
) => <String, dynamic>{
'id': instance.id,
'product_id': instance.productId,
'product_name': instance.productName,
'category_name': instance.categoryName,
'quantity': instance.quantity,
'reorder_level': instance.reorderLevel,
'unit_cost': instance.unitCost,
'total_value': instance.totalValue,
'total_in': instance.totalIn,
'total_out': instance.totalOut,
'is_low_stock': instance.isLowStock,
'is_zero_stock': instance.isZeroStock,
'updated_at': instance.updatedAt,
};
_$InventoryIngredientDtoImpl _$$InventoryIngredientDtoImplFromJson(
Map<String, dynamic> json,
) => _$InventoryIngredientDtoImpl(
id: json['id'] as String?,
ingredientId: json['ingredient_id'] as String?,
ingredientName: json['ingredient_name'] as String?,
unitName: json['unit_name'] as String?,
quantity: (json['quantity'] as num?)?.toInt(),
reorderLevel: (json['reorder_level'] as num?)?.toInt(),
unitCost: (json['unit_cost'] as num?)?.toInt(),
totalValue: (json['total_value'] as num?)?.toInt(),
totalIn: (json['total_in'] as num?)?.toInt(),
totalOut: (json['total_out'] as num?)?.toInt(),
isLowStock: json['is_low_stock'] as bool?,
isZeroStock: json['is_zero_stock'] as bool?,
updatedAt: json['updated_at'] as String?,
);
Map<String, dynamic> _$$InventoryIngredientDtoImplToJson(
_$InventoryIngredientDtoImpl instance,
) => <String, dynamic>{
'id': instance.id,
'ingredient_id': instance.ingredientId,
'ingredient_name': instance.ingredientName,
'unit_name': instance.unitName,
'quantity': instance.quantity,
'reorder_level': instance.reorderLevel,
'unit_cost': instance.unitCost,
'total_value': instance.totalValue,
'total_in': instance.totalIn,
'total_out': instance.totalOut,
'is_low_stock': instance.isLowStock,
'is_zero_stock': instance.isZeroStock,
'updated_at': instance.updatedAt,
};

View File

@ -98,4 +98,32 @@ class AnalyticRemoteDataProvider {
return DC.error(AnalyticFailure.serverError(e));
}
}
Future<DC<AnalyticFailure, InventoryAnalyticDto>> fetchInventory({
required String outletId,
required DateTime dateFrom,
required DateTime dateTo,
}) async {
try {
final response = await _apiClient.get(
'${ApiPath.inventoryReportDetail}/$outletId',
params: {
'date_from': dateFrom.toServerDate,
'date_to': dateTo.toServerDate,
},
headers: getAuthorizationHeader(),
);
if (response.data['data'] == null) {
return DC.error(AnalyticFailure.empty());
}
final dto = InventoryAnalyticDto.fromJson(response.data['data']);
return DC.data(dto);
} on ApiFailure catch (e, s) {
log('fetchInventoryError', name: _logName, error: e, stackTrace: s);
return DC.error(AnalyticFailure.serverError(e));
}
}
}

View File

@ -0,0 +1,139 @@
part of '../analytic_dtos.dart';
@freezed
class InventoryAnalyticDto with _$InventoryAnalyticDto {
const InventoryAnalyticDto._();
const factory InventoryAnalyticDto({
@JsonKey(name: "summary") InventorySummaryDto? summary,
@JsonKey(name: "products") List<InventoryProductDto>? products,
@JsonKey(name: "ingredients") List<InventoryIngredientDto>? ingredients,
}) = _InventoryAnalyticDto;
factory InventoryAnalyticDto.fromJson(Map<String, dynamic> json) =>
_$InventoryAnalyticDtoFromJson(json);
InventoryAnalytic toDomain() => InventoryAnalytic(
summary: summary?.toDomain() ?? InventorySummary.empty(),
products: products?.map((e) => e.toDomain()).toList() ?? [],
ingredients: ingredients?.map((e) => e.toDomain()).toList() ?? [],
);
}
@freezed
class InventorySummaryDto with _$InventorySummaryDto {
const factory InventorySummaryDto({
@JsonKey(name: "total_products") int? totalProducts,
@JsonKey(name: "total_ingredients") int? totalIngredients,
@JsonKey(name: "total_value") int? totalValue,
@JsonKey(name: "low_stock_products") int? lowStockProducts,
@JsonKey(name: "low_stock_ingredients") int? lowStockIngredients,
@JsonKey(name: "zero_stock_products") int? zeroStockProducts,
@JsonKey(name: "zero_stock_ingredients") int? zeroStockIngredients,
@JsonKey(name: "total_sold_products") int? totalSoldProducts,
@JsonKey(name: "total_sold_ingredients") int? totalSoldIngredients,
@JsonKey(name: "outlet_id") String? outletId,
@JsonKey(name: "outlet_name") String? outletName,
@JsonKey(name: "generated_at") String? generatedAt,
}) = _InventorySummaryDto;
factory InventorySummaryDto.fromJson(Map<String, dynamic> json) =>
_$InventorySummaryDtoFromJson(json);
}
extension InventorySummaryDtoX on InventorySummaryDto {
InventorySummary toDomain() => InventorySummary(
totalProducts: totalProducts ?? 0,
totalIngredients: totalIngredients ?? 0,
totalValue: totalValue ?? 0,
lowStockProducts: lowStockProducts ?? 0,
lowStockIngredients: lowStockIngredients ?? 0,
zeroStockProducts: zeroStockProducts ?? 0,
zeroStockIngredients: zeroStockIngredients ?? 0,
totalSoldProducts: totalSoldProducts ?? 0,
totalSoldIngredients: totalSoldIngredients ?? 0,
outletId: outletId ?? "",
outletName: outletName ?? "",
generatedAt: generatedAt ?? "",
);
}
@freezed
class InventoryProductDto with _$InventoryProductDto {
const factory InventoryProductDto({
@JsonKey(name: "id") String? id,
@JsonKey(name: "product_id") String? productId,
@JsonKey(name: "product_name") String? productName,
@JsonKey(name: "category_name") String? categoryName,
@JsonKey(name: "quantity") int? quantity,
@JsonKey(name: "reorder_level") int? reorderLevel,
@JsonKey(name: "unit_cost") int? unitCost,
@JsonKey(name: "total_value") int? totalValue,
@JsonKey(name: "total_in") int? totalIn,
@JsonKey(name: "total_out") int? totalOut,
@JsonKey(name: "is_low_stock") bool? isLowStock,
@JsonKey(name: "is_zero_stock") bool? isZeroStock,
@JsonKey(name: "updated_at") String? updatedAt,
}) = _InventoryProductDto;
factory InventoryProductDto.fromJson(Map<String, dynamic> json) =>
_$InventoryProductDtoFromJson(json);
}
extension InventoryProductDtoX on InventoryProductDto {
InventoryProduct toDomain() => InventoryProduct(
id: id ?? "",
productId: productId ?? "",
productName: productName ?? "",
categoryName: categoryName ?? "",
quantity: quantity ?? 0,
reorderLevel: reorderLevel ?? 0,
unitCost: unitCost ?? 0,
totalValue: totalValue ?? 0,
totalIn: totalIn ?? 0,
totalOut: totalOut ?? 0,
isLowStock: isLowStock ?? false,
isZeroStock: isZeroStock ?? false,
updatedAt: updatedAt ?? "",
);
}
@freezed
class InventoryIngredientDto with _$InventoryIngredientDto {
const factory InventoryIngredientDto({
@JsonKey(name: "id") String? id,
@JsonKey(name: "ingredient_id") String? ingredientId,
@JsonKey(name: "ingredient_name") String? ingredientName,
@JsonKey(name: "unit_name") String? unitName,
@JsonKey(name: "quantity") int? quantity,
@JsonKey(name: "reorder_level") int? reorderLevel,
@JsonKey(name: "unit_cost") int? unitCost,
@JsonKey(name: "total_value") int? totalValue,
@JsonKey(name: "total_in") int? totalIn,
@JsonKey(name: "total_out") int? totalOut,
@JsonKey(name: "is_low_stock") bool? isLowStock,
@JsonKey(name: "is_zero_stock") bool? isZeroStock,
@JsonKey(name: "updated_at") String? updatedAt,
}) = _InventoryIngredientDto;
factory InventoryIngredientDto.fromJson(Map<String, dynamic> json) =>
_$InventoryIngredientDtoFromJson(json);
}
extension InventoryIngredientDtoX on InventoryIngredientDto {
InventoryIngredient toDomain() => InventoryIngredient(
id: id ?? "",
ingredientId: ingredientId ?? "",
ingredientName: ingredientName ?? "",
unitName: unitName ?? "",
quantity: quantity ?? 0,
reorderLevel: reorderLevel ?? 0,
unitCost: unitCost ?? 0,
totalValue: totalValue ?? 0,
totalIn: totalIn ?? 0,
totalOut: totalOut ?? 0,
isLowStock: isLowStock ?? false,
isZeroStock: isZeroStock ?? false,
updatedAt: updatedAt ?? "",
);
}

View File

@ -5,14 +5,17 @@ import 'package:injectable/injectable.dart';
import '../../../domain/analytic/analytic.dart';
import '../../../domain/analytic/repositories/i_analytic_repository.dart';
import '../../../domain/auth/auth.dart';
import '../../auth/datasources/local_data_provider.dart';
import '../datasource/remote_data_provider.dart';
@Injectable(as: IAnalyticRepository)
class AnalyticRepository implements IAnalyticRepository {
final AnalyticRemoteDataProvider _dataProvider;
final AuthLocalDataProvider _authLocalDataProvider;
final String _logName = 'AnalyticRepository';
AnalyticRepository(this._dataProvider);
AnalyticRepository(this._dataProvider, this._authLocalDataProvider);
@override
Future<Either<AnalyticFailure, SalesAnalytic>> getSales({
@ -85,4 +88,31 @@ class AnalyticRepository implements IAnalyticRepository {
return left(const AnalyticFailure.unexpectedError());
}
}
@override
Future<Either<AnalyticFailure, InventoryAnalytic>> getInventory({
required DateTime dateFrom,
required DateTime dateTo,
}) async {
try {
User currentUser = await _authLocalDataProvider.currentUser();
final result = await _dataProvider.fetchInventory(
outletId: currentUser.outletId,
dateFrom: dateFrom,
dateTo: dateTo,
);
if (result.hasError) {
return left(result.error!);
}
final auth = result.data!.toDomain();
return right(auth);
} catch (e, s) {
log('getInventoryError', name: _logName, error: e, stackTrace: s);
return left(const AnalyticFailure.unexpectedError());
}
}
}

View File

@ -11,6 +11,8 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:apskel_owner_flutter/application/analytic/category_analytic_loader/category_analytic_loader_bloc.dart'
as _i1038;
import 'package:apskel_owner_flutter/application/analytic/inventory_analytic_loader/inventory_analytic_loader_bloc.dart'
as _i785;
import 'package:apskel_owner_flutter/application/analytic/profit_loss_loader/profit_loss_loader_bloc.dart'
as _i11;
import 'package:apskel_owner_flutter/application/analytic/sales_loader/sales_loader_bloc.dart'
@ -120,9 +122,6 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i866.AnalyticRemoteDataProvider>(
() => _i866.AnalyticRemoteDataProvider(gh<_i115.ApiClient>()),
);
gh.factory<_i477.IAnalyticRepository>(
() => _i393.AnalyticRepository(gh<_i866.AnalyticRemoteDataProvider>()),
);
gh.factory<_i49.IAuthRepository>(
() => _i1035.AuthRepository(
gh<_i991.AuthLocalDataProvider>(),
@ -132,6 +131,12 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i419.IProductRepository>(
() => _i121.ProductRepository(gh<_i823.ProductRemoteDataProvider>()),
);
gh.factory<_i477.IAnalyticRepository>(
() => _i393.AnalyticRepository(
gh<_i866.AnalyticRemoteDataProvider>(),
gh<_i991.AuthLocalDataProvider>(),
),
);
gh.factory<_i1020.ICategoryRepository>(
() => _i869.CategoryRepository(gh<_i333.CategoryRemoteDataProvider>()),
);
@ -150,6 +155,9 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i1038.CategoryAnalyticLoaderBloc>(
() => _i1038.CategoryAnalyticLoaderBloc(gh<_i477.IAnalyticRepository>()),
);
gh.factory<_i785.InventoryAnalyticLoaderBloc>(
() => _i785.InventoryAnalyticLoaderBloc(gh<_i477.IAnalyticRepository>()),
);
gh.factory<_i775.LoginFormBloc>(
() => _i775.LoginFormBloc(gh<_i49.IAuthRepository>()),
);

View File

@ -1,63 +1,33 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../application/analytic/inventory_analytic_loader/inventory_analytic_loader_bloc.dart';
import '../../../common/theme/theme.dart';
import '../../../domain/analytic/analytic.dart';
import '../../../injection.dart';
import '../../components/appbar/appbar.dart';
import 'widgets/ingredient_tile.dart';
import 'widgets/product_tile.dart';
import 'widgets/stat_card.dart';
import 'widgets/tabbar_delegate.dart';
// Sample inventory data for products
class ProductItem {
final String id;
final String name;
final String category;
final int quantity;
final double price;
final String status;
final String image;
ProductItem({
required this.id,
required this.name,
required this.category,
required this.quantity,
required this.price,
required this.status,
required this.image,
});
}
// Sample inventory data for ingredients
class IngredientItem {
final String id;
final String name;
final String unit;
final double quantity;
final double minQuantity;
final String status;
final String image;
IngredientItem({
required this.id,
required this.name,
required this.unit,
required this.quantity,
required this.minQuantity,
required this.status,
required this.image,
});
}
// Custom SliverPersistentHeaderDelegate untuk TabBar
@RoutePage()
class InventoryPage extends StatefulWidget {
class InventoryPage extends StatefulWidget implements AutoRouteWrapper {
const InventoryPage({super.key});
@override
State<InventoryPage> createState() => _InventoryPageState();
@override
Widget wrappedRoute(BuildContext context) => BlocProvider(
create: (_) =>
getIt<InventoryAnalyticLoaderBloc>()
..add(InventoryAnalyticLoaderEvent.fetched()),
child: this,
);
}
class _InventoryPageState extends State<InventoryPage>
@ -68,111 +38,6 @@ class _InventoryPageState extends State<InventoryPage>
late Animation<Offset> _slideAnimation;
late TabController _tabController;
final List<ProductItem> productItems = [
ProductItem(
id: '1',
name: 'Laptop Gaming ASUS ROG',
category: 'Elektronik',
quantity: 5,
price: 15000000,
status: 'available',
image: '💻',
),
ProductItem(
id: '2',
name: 'Kemeja Formal Pria',
category: 'Fashion',
quantity: 25,
price: 250000,
status: 'available',
image: '👔',
),
ProductItem(
id: '3',
name: 'Smartphone Samsung Galaxy',
category: 'Elektronik',
quantity: 12,
price: 8500000,
status: 'available',
image: '📱',
),
ProductItem(
id: '4',
name: 'Tas Ransel Travel',
category: 'Fashion',
quantity: 8,
price: 350000,
status: 'low_stock',
image: '🎒',
),
ProductItem(
id: '4',
name: 'Tas Ransel Travel',
category: 'Fashion',
quantity: 8,
price: 350000,
status: 'low_stock',
image: '🎒',
),
ProductItem(
id: '4',
name: 'Tas Ransel Travel',
category: 'Fashion',
quantity: 8,
price: 350000,
status: 'low_stock',
image: '🎒',
),
];
final List<IngredientItem> ingredientItems = [
IngredientItem(
id: '1',
name: 'Tepung Terigu',
unit: 'kg',
quantity: 50.5,
minQuantity: 10.0,
status: 'available',
image: '🌾',
),
IngredientItem(
id: '2',
name: 'Gula Pasir',
unit: 'kg',
quantity: 2.5,
minQuantity: 5.0,
status: 'low_stock',
image: '🍬',
),
IngredientItem(
id: '3',
name: 'Telur Ayam',
unit: 'butir',
quantity: 120,
minQuantity: 50,
status: 'available',
image: '🥚',
),
IngredientItem(
id: '4',
name: 'Susu Segar',
unit: 'liter',
quantity: 0,
minQuantity: 10.0,
status: 'out_of_stock',
image: '🥛',
),
IngredientItem(
id: '5',
name: 'Mentega',
unit: 'kg',
quantity: 15.2,
minQuantity: 5.0,
status: 'available',
image: '🧈',
),
];
@override
void initState() {
super.initState();
@ -244,95 +109,118 @@ class _InventoryPageState extends State<InventoryPage>
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColor.background,
body: FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
_buildSliverAppBar(),
SliverPersistentHeader(
pinned: true,
delegate: InventorySliverTabBarDelegate(
tabBar: TabBar(
body:
BlocBuilder<
InventoryAnalyticLoaderBloc,
InventoryAnalyticLoaderState
>(
builder: (context, state) {
return FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
_buildSliverAppBar(),
SliverPersistentHeader(
pinned: true,
delegate: InventorySliverTabBarDelegate(
tabBar: TabBar(
controller: _tabController,
indicator: BoxDecoration(
gradient: LinearGradient(
colors: AppColor.primaryGradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: AppColor.primary.withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding: const EdgeInsets.all(6),
labelColor: AppColor.textWhite,
unselectedLabelColor: AppColor.textSecondary,
labelStyle: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 13,
),
unselectedLabelStyle: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 13,
),
dividerColor: Colors.transparent,
splashFactory: NoSplash.splashFactory,
overlayColor: MaterialStateProperty.all(
Colors.transparent,
),
tabs: [
Tab(
height: 40,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
),
child: const Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_rounded,
size: 16,
),
SizedBox(width: 6),
Text('Produk'),
],
),
),
),
Tab(
height: 40,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
),
child: const Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(
Icons.restaurant_menu_rounded,
size: 16,
),
SizedBox(width: 6),
Text('Bahan'),
],
),
),
),
],
),
),
),
];
},
body: TabBarView(
controller: _tabController,
indicator: BoxDecoration(
gradient: LinearGradient(
colors: AppColor.primaryGradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: AppColor.primary.withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding: const EdgeInsets.all(6),
labelColor: AppColor.textWhite,
unselectedLabelColor: AppColor.textSecondary,
labelStyle: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 13,
),
unselectedLabelStyle: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 13,
),
dividerColor: Colors.transparent,
splashFactory: NoSplash.splashFactory,
overlayColor: MaterialStateProperty.all(
Colors.transparent,
),
tabs: [
Tab(
height: 40,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: const Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inventory_2_rounded, size: 16),
SizedBox(width: 6),
Text('Produk'),
],
),
),
),
Tab(
height: 40,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: const Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.restaurant_menu_rounded, size: 16),
SizedBox(width: 6),
Text('Bahan'),
],
),
),
),
children: [
_buildProductTab(state.inventoryAnalytic),
_buildIngredientTab(state.inventoryAnalytic),
],
),
),
),
];
);
},
body: TabBarView(
controller: _tabController,
children: [_buildProductTab(), _buildIngredientTab()],
),
),
),
),
);
}
@ -347,41 +235,19 @@ class _InventoryPageState extends State<InventoryPage>
);
}
Widget _buildProductTab() {
Widget _buildProductTab(InventoryAnalytic inventoryAnalytic) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(child: _buildProductStats()),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.75,
),
delegate: SliverChildBuilderDelegate(
(context, index) =>
InventoryProductTile(item: productItems[index]),
childCount: productItems.length,
),
),
SliverToBoxAdapter(
child: _buildProductStats(inventoryAnalytic.summary),
),
],
);
}
Widget _buildIngredientTab() {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(child: _buildIngredientStats()),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) =>
InventoryIngredientTile(item: ingredientItems[index]),
childCount: ingredientItems.length,
InventoryProductTile(item: inventoryAnalytic.products[index]),
childCount: inventoryAnalytic.products.length,
),
),
),
@ -389,15 +255,28 @@ class _InventoryPageState extends State<InventoryPage>
);
}
Widget _buildProductStats() {
final totalProducts = productItems.length;
final availableProducts = productItems
.where((item) => item.status == 'available')
.length;
final lowStockProducts = productItems
.where((item) => item.status == 'low_stock')
.length;
Widget _buildIngredientTab(InventoryAnalytic inventoryAnalytic) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _buildIngredientStats(inventoryAnalytic.summary),
),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => InventoryIngredientTile(
item: inventoryAnalytic.ingredients[index],
),
childCount: inventoryAnalytic.ingredients.length,
),
),
),
],
);
}
Widget _buildProductStats(InventorySummary inventory) {
return Container(
margin: const EdgeInsets.all(16),
child: Column(
@ -407,20 +286,18 @@ class _InventoryPageState extends State<InventoryPage>
Expanded(
child: _buildStatCard(
'Total Produk',
totalProducts.toString(),
inventory.totalProducts.toString(),
Icons.inventory_2_rounded,
AppColor.primary,
'+12%',
),
),
const SizedBox(width: 16),
Expanded(
child: _buildStatCard(
'Tersedia',
availableProducts.toString(),
'Produk Terjual',
inventory.totalSoldProducts.toString(),
Icons.check_circle_rounded,
AppColor.success,
'+5%',
),
),
],
@ -431,15 +308,19 @@ class _InventoryPageState extends State<InventoryPage>
Expanded(
child: _buildStatCard(
'Stok Rendah',
lowStockProducts.toString(),
inventory.lowStockProducts.toString(),
Icons.warning_rounded,
AppColor.warning,
'-8%',
),
),
const SizedBox(width: 16),
Expanded(
child: Container(), // Empty space for balance
child: _buildStatCard(
'Stok Kosong',
inventory.zeroStockProducts.toString(),
Icons.error_rounded,
AppColor.error,
),
),
],
),
@ -448,18 +329,7 @@ class _InventoryPageState extends State<InventoryPage>
);
}
Widget _buildIngredientStats() {
final totalIngredients = ingredientItems.length;
final availableIngredients = ingredientItems
.where((item) => item.status == 'available')
.length;
final lowStockIngredients = ingredientItems
.where((item) => item.status == 'low_stock')
.length;
final outOfStockIngredients = ingredientItems
.where((item) => item.status == 'out_of_stock')
.length;
Widget _buildIngredientStats(InventorySummary inventory) {
return Container(
margin: const EdgeInsets.all(16),
child: Column(
@ -469,20 +339,18 @@ class _InventoryPageState extends State<InventoryPage>
Expanded(
child: _buildStatCard(
'Total Bahan',
totalIngredients.toString(),
inventory.totalIngredients.toString(),
Icons.restaurant_menu_rounded,
AppColor.primary,
'+8%',
),
),
const SizedBox(width: 16),
Expanded(
child: _buildStatCard(
'Tersedia',
availableIngredients.toString(),
'Bahan Terjual',
inventory.totalSoldIngredients.toString(),
Icons.check_circle_rounded,
AppColor.success,
'+15%',
),
),
],
@ -493,20 +361,18 @@ class _InventoryPageState extends State<InventoryPage>
Expanded(
child: _buildStatCard(
'Stok Kurang',
lowStockIngredients.toString(),
inventory.lowStockIngredients.toString(),
Icons.warning_rounded,
AppColor.warning,
'-3%',
),
),
const SizedBox(width: 16),
Expanded(
child: _buildStatCard(
'Habis',
outOfStockIngredients.toString(),
inventory.zeroStockIngredients.toString(),
Icons.error_rounded,
AppColor.error,
'+1',
),
),
],
@ -521,7 +387,6 @@ class _InventoryPageState extends State<InventoryPage>
String value,
IconData icon,
Color color,
String change,
) {
return TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: 1),
@ -534,7 +399,6 @@ class _InventoryPageState extends State<InventoryPage>
value: value,
icon: icon,
color: color,
change: change,
),
);
},

View File

@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
import 'package:line_icons/line_icons.dart';
import 'package:intl/intl.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/analytic/analytic.dart';
import '../../../components/spacer/spacer.dart';
import '../inventory_page.dart';
class InventoryIngredientTile extends StatelessWidget {
final IngredientItem item;
final InventoryIngredient item;
const InventoryIngredientTile({super.key, required this.item});
@override
@ -16,94 +19,458 @@ class InventoryIngredientTile extends StatelessWidget {
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: _getStatusColor().withOpacity(0.2),
width: 1.5,
),
boxShadow: [
BoxShadow(
color: AppColor.primaryWithOpacity(0.1),
blurRadius: 8,
color: _getStatusColor().withOpacity(0.08),
blurRadius: 12,
offset: const Offset(0, 4),
),
BoxShadow(
color: AppColor.textLight.withOpacity(0.06),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Row(
child: Column(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
gradient: LinearGradient(colors: AppColor.backgroundGradient),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(item.image, style: const TextStyle(fontSize: 24)),
),
),
const SpaceWidth(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
// Main Row
Row(
children: [
// Enhanced Icon Container
Container(
width: 65,
height: 65,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: _getGradientColors(),
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: _getStatusColor().withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
SpaceHeight(4),
Text(
'Stok: ${item.quantity} ${item.unit}',
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
child: Stack(
children: [
Center(
child: Icon(
_getIngredientIcon(),
size: 28,
color: AppColor.white,
),
),
// Status indicator dot
Positioned(
top: 6,
right: 6,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: AppColor.white,
shape: BoxShape.circle,
border: Border.all(
color: _getStatusColor(),
width: 2,
),
),
child: Center(
child: Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: _getStatusColor(),
shape: BoxShape.circle,
),
),
),
),
),
],
),
SpaceHeight(4),
Text(
'Min: ${item.minQuantity} ${item.unit}',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
const SpaceWidth(16),
// Content Section
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Ingredient Name
Text(
item.ingredientName,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.textPrimary,
height: 1.2,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
SpaceHeight(6),
// Stock Information Row
Row(
children: [
Icon(
LineIcons.warehouse,
size: 14,
color: AppColor.textSecondary,
),
const SpaceWidth(4),
Text(
'Stok: ',
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
),
),
Text(
'${NumberFormat('#,###', 'id_ID').format(item.quantity)} ${item.unitName}',
style: AppStyle.sm.copyWith(
color: _getQuantityColor(),
fontWeight: FontWeight.w700,
),
),
],
),
SpaceHeight(4),
// Reorder Level Information
Row(
children: [
Icon(
LineIcons.exclamationTriangle,
size: 14,
color: AppColor.warning,
),
const SpaceWidth(4),
Text(
'Min: ',
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
),
),
Text(
'${NumberFormat('#,###', 'id_ID').format(item.reorderLevel)} ${item.unitName}',
style: AppStyle.sm.copyWith(
color: AppColor.warning,
fontWeight: FontWeight.w700,
),
),
],
),
SpaceHeight(6),
// Unit Cost
Row(
children: [
Icon(
LineIcons.dollarSign,
size: 14,
color: AppColor.success,
),
const SpaceWidth(4),
Text(
item.unitCost.currencyFormatRp,
style: AppStyle.sm.copyWith(
color: AppColor.success,
fontWeight: FontWeight.w700,
),
),
Text(
'/${item.unitName}',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
],
),
],
),
),
// Status Badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: _getStatusColor(),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: _getStatusColor().withOpacity(0.3),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_getStatusText(),
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.white,
fontSize: 11,
),
),
],
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: getStatusColor(item.status),
borderRadius: BorderRadius.circular(20),
),
child: Text(
getStatusText(item.status),
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textWhite,
// Additional Information Card (if low stock or has movements)
if (item.isLowStock || item.totalIn > 0 || item.totalOut > 0) ...[
SpaceHeight(12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColor.background,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.borderLight, width: 0.5),
),
child: Column(
children: [
// Warning message for low stock
if (item.isLowStock && !item.isZeroStock) ...[
Row(
children: [
Icon(
LineIcons.exclamationTriangle,
size: 16,
color: AppColor.warning,
),
const SpaceWidth(6),
Expanded(
child: Text(
'Stok mendekati batas minimum (${item.reorderLevel} ${item.unitName})',
style: AppStyle.xs.copyWith(
color: AppColor.warning,
fontWeight: FontWeight.w600,
),
),
),
],
),
if (item.totalIn > 0 || item.totalOut > 0) SpaceHeight(8),
],
// Movement Information
if (item.totalIn > 0 || item.totalOut > 0)
Row(
children: [
// Stock In
if (item.totalIn > 0) ...[
Expanded(
child: Row(
children: [
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColor.success.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Icon(
LineIcons.arrowUp,
size: 12,
color: AppColor.success,
),
),
const SpaceWidth(6),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Masuk',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
),
),
Text(
'${NumberFormat('#,###', 'id_ID').format(item.totalIn)} ${item.unitName}',
style: AppStyle.xs.copyWith(
color: AppColor.success,
fontWeight: FontWeight.w700,
),
),
],
),
],
),
),
],
// Stock Out
if (item.totalOut > 0) ...[
if (item.totalIn > 0) const SpaceWidth(16),
Expanded(
child: Row(
children: [
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColor.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Icon(
LineIcons.arrowDown,
size: 12,
color: AppColor.error,
),
),
const SpaceWidth(6),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Keluar',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
),
),
Text(
'${NumberFormat('#,###', 'id_ID').format(item.totalOut)} ${item.unitName}',
style: AppStyle.xs.copyWith(
color: AppColor.error,
fontWeight: FontWeight.w700,
),
),
],
),
],
),
),
],
// Total Value
if (item.totalValue > 0) ...[
const SpaceWidth(16),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Nilai Total',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
),
),
Text(
_formatCurrencyShort(item.totalValue),
style: AppStyle.xs.copyWith(
color: AppColor.info,
fontWeight: FontWeight.w700,
),
),
],
),
],
],
),
],
),
),
),
],
],
),
);
}
Color getStatusColor(String status) {
switch (status) {
case 'available':
return AppColor.success;
case 'low_stock':
return AppColor.warning;
case 'out_of_stock':
return AppColor.error;
default:
return AppColor.textSecondary;
}
// Helper methods
Color _getStatusColor() {
if (item.isZeroStock) return AppColor.error;
if (item.isLowStock) return AppColor.warning;
return AppColor.success;
}
String getStatusText(String status) {
switch (status) {
case 'available':
return 'Tersedia';
case 'low_stock':
return 'Stok Rendah';
case 'out_of_stock':
return 'Habis';
default:
return 'Unknown';
List<Color> _getGradientColors() {
if (item.isZeroStock) {
return [AppColor.error, AppColor.error.withOpacity(0.7)];
}
if (item.isLowStock) {
return [AppColor.warning, AppColor.warning.withOpacity(0.7)];
}
return [AppColor.success, AppColor.success.withOpacity(0.7)];
}
Color _getQuantityColor() {
if (item.isZeroStock) return AppColor.error;
if (item.isLowStock) return AppColor.warning;
return AppColor.textPrimary;
}
String _getStatusText() {
if (item.isZeroStock) return 'HABIS';
if (item.isLowStock) return 'MINIM';
return 'TERSEDIA';
}
IconData _getIngredientIcon() {
final name = item.ingredientName.toLowerCase();
// Food ingredients
if (name.contains('tepung') || name.contains('flour')) {
return LineIcons.breadSlice;
} else if (name.contains('gula') || name.contains('sugar')) {
return LineIcons.cube;
} else if (name.contains('garam') || name.contains('salt')) {
return LineIcons.breadSlice;
} else if (name.contains('minyak') || name.contains('oil')) {
return LineIcons.tint;
} else if (name.contains('susu') || name.contains('milk')) {
return LineIcons.glasses;
} else if (name.contains('telur') || name.contains('egg')) {
return LineIcons.egg;
} else if (name.contains('daging') || name.contains('meat')) {
return LineIcons.hamburger;
} else if (name.contains('sayur') || name.contains('vegetable')) {
return LineIcons.carrot;
} else if (name.contains('bumbu') || name.contains('spice')) {
return LineIcons.leaf;
} else if (name.contains('buah') || name.contains('fruit')) {
return LineIcons.apple;
} else if (name.contains('beras') || name.contains('rice')) {
return LineIcons.seedling;
} else if (name.contains('kopi') || name.contains('coffee')) {
return LineIcons.coffee;
}
// Default ingredient icon
return LineIcons.utensils;
}
String _formatCurrencyShort(int amount) {
if (amount.abs() >= 1000000000) {
return 'Rp ${(amount / 1000000000).toStringAsFixed(1)}B';
} else if (amount.abs() >= 1000000) {
return 'Rp ${(amount / 1000000).toStringAsFixed(1)}M';
} else if (amount.abs() >= 1000) {
return 'Rp ${(amount / 1000).toStringAsFixed(0)}K';
} else {
return 'Rp ${NumberFormat('#,###', 'id_ID').format(amount)}';
}
}
}

View File

@ -1,154 +1,514 @@
import 'package:flutter/material.dart';
import 'package:line_icons/line_icons.dart';
import 'package:intl/intl.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/analytic/analytic.dart';
import '../../../components/spacer/spacer.dart';
import '../inventory_page.dart';
class InventoryProductTile extends StatelessWidget {
final ProductItem item;
final InventoryProduct item;
const InventoryProductTile({super.key, required this.item});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColor.primary.withOpacity(0.08), width: 1),
border: Border.all(
color: _getStatusColor().withOpacity(0.2),
width: 1.5,
),
boxShadow: [
BoxShadow(
color: AppColor.primary.withOpacity(0.06),
blurRadius: 12,
offset: const Offset(0, 4),
color: _getStatusColor().withOpacity(0.06),
blurRadius: 16,
offset: const Offset(0, 6),
),
BoxShadow(
color: AppColor.textLight.withOpacity(0.08),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image Container
Container(
height: 85,
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: AppColor.backgroundGradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Center(
child: Container(
padding: const EdgeInsets.all(8),
// Main Content Row
Row(
children: [
// Enhanced Product Icon
Container(
width: 70,
height: 70,
decoration: BoxDecoration(
color: AppColor.textWhite.withOpacity(0.9),
borderRadius: BorderRadius.circular(12),
gradient: LinearGradient(
colors: _getGradientColors(),
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(
color: _getStatusColor().withOpacity(0.25),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Stack(
children: [
Center(
child: Icon(
_getCategoryIcon(),
size: 32,
color: AppColor.white,
),
),
// Status indicator
Positioned(
top: 6,
right: 6,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: AppColor.white,
shape: BoxShape.circle,
border: Border.all(
color: _getStatusColor(),
width: 2.5,
),
),
child: Center(
child: Container(
width: 5,
height: 5,
decoration: BoxDecoration(
color: _getStatusColor(),
shape: BoxShape.circle,
),
),
),
),
),
],
),
child: Text(item.image, style: const TextStyle(fontSize: 32)),
),
),
const SpaceWidth(16),
// Product Information
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Product Name and Category Row
Row(
children: [
Expanded(
child: Text(
item.productName,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.textPrimary,
height: 1.2,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SpaceWidth(8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: AppColor.primary.withOpacity(0.15),
width: 0.5,
),
),
child: Text(
item.categoryName,
style: AppStyle.xs.copyWith(
fontSize: 9,
fontWeight: FontWeight.w600,
color: AppColor.primary,
letterSpacing: 0.2,
),
),
),
],
),
SpaceHeight(8),
// Price and Status Row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Price
Text(
item.unitCost.currencyFormatRp,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w800,
color: AppColor.success,
fontSize: 16,
),
),
// Status Badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: _getStatusColor(),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: _getStatusColor().withOpacity(0.3),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Text(
_getStatusText(),
style: AppStyle.xs.copyWith(
fontSize: 10,
fontWeight: FontWeight.w700,
color: AppColor.white,
),
),
),
],
),
SpaceHeight(10),
// Stock Information Grid
Row(
children: [
// Quantity Info
Expanded(
child: _buildInfoItem(
LineIcons.boxes,
'Stok',
'${NumberFormat('#,###', 'id_ID').format(item.quantity)} pcs',
_getQuantityColor(),
),
),
const SpaceWidth(16),
// Total Value Info
Expanded(
child: _buildInfoItem(
LineIcons.dollarSign,
'Nilai',
_formatCurrencyShort(item.totalValue),
AppColor.info,
),
),
],
),
],
),
),
],
),
// Content Container
Expanded(
child: Padding(
// Additional Information (conditionally shown)
if (_shouldShowAdditionalInfo()) ...[
SpaceHeight(12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColor.background,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.borderLight, width: 0.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Product Name
Text(
item.name,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
height: 1.2,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
SpaceHeight(4),
// Category
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
item.category,
style: AppStyle.xs.copyWith(
fontSize: 9,
fontWeight: FontWeight.w500,
color: AppColor.primary,
),
),
),
const Spacer(),
// Price
Text(
'Rp ${item.price}',
style: AppStyle.sm.copyWith(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColor.primary,
),
),
SpaceHeight(6),
// Quantity & Status
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${item.quantity} pcs',
style: AppStyle.xs.copyWith(
fontSize: 11,
fontWeight: FontWeight.w500,
color: AppColor.textSecondary,
// Low Stock Warning
if (item.isLowStock && !item.isZeroStock) ...[
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppColor.warning.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
LineIcons.exclamationTriangle,
size: 16,
color: AppColor.warning,
),
),
),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: getStatusColor(item.status),
shape: BoxShape.circle,
const SpaceWidth(10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Stok Menipis',
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.warning,
),
),
Text(
'Segera reorder minimal ${NumberFormat('#,###', 'id_ID').format(item.reorderLevel)} pcs',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
),
],
),
],
// Zero Stock Warning
if (item.isZeroStock) ...[
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppColor.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
LineIcons.timesCircle,
size: 16,
color: AppColor.error,
),
),
const SpaceWidth(10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Stok Habis',
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.error,
),
),
Text(
'Produk tidak tersedia untuk dijual',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
],
// Movement Information
if (_hasMovementData()) ...[
if ((item.isLowStock || item.isZeroStock)) SpaceHeight(12),
Row(
children: [
if (item.totalIn > 0)
Expanded(
child: _buildMovementInfo(
LineIcons.arrowUp,
'Masuk',
'${NumberFormat('#,###', 'id_ID').format(item.totalIn)} pcs',
AppColor.success,
),
),
if (item.totalIn > 0 && item.totalOut > 0)
const SpaceWidth(16),
if (item.totalOut > 0)
Expanded(
child: _buildMovementInfo(
LineIcons.arrowDown,
'Keluar',
'${NumberFormat('#,###', 'id_ID').format(item.totalOut)} pcs',
AppColor.error,
),
),
],
),
],
],
),
),
],
],
),
);
}
Widget _buildInfoItem(
IconData icon,
String label,
String value,
Color valueColor,
) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10),
decoration: BoxDecoration(
color: AppColor.background,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColor.borderLight, width: 0.5),
),
child: Row(
children: [
Icon(icon, size: 14, color: AppColor.textSecondary),
const SpaceWidth(6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: AppStyle.xs.copyWith(
fontSize: 10,
fontWeight: FontWeight.w500,
color: AppColor.textSecondary,
),
),
SpaceHeight(1),
Text(
value,
style: AppStyle.xs.copyWith(
fontSize: 11,
fontWeight: FontWeight.w700,
color: valueColor,
),
),
],
),
),
],
),
);
}
Color getStatusColor(String status) {
switch (status) {
case 'available':
return AppColor.success;
case 'low_stock':
return AppColor.warning;
case 'out_of_stock':
return AppColor.error;
default:
return AppColor.textSecondary;
Widget _buildMovementInfo(
IconData icon,
String label,
String value,
Color color,
) {
return Row(
children: [
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, size: 12, color: color),
),
const SpaceWidth(8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
),
),
Text(
value,
style: AppStyle.xs.copyWith(
color: color,
fontWeight: FontWeight.w700,
fontSize: 11,
),
),
],
),
],
);
}
// Helper methods
bool _shouldShowAdditionalInfo() {
return item.isLowStock || item.isZeroStock || _hasMovementData();
}
bool _hasMovementData() {
return item.totalIn > 0 || item.totalOut > 0;
}
Color _getStatusColor() {
if (item.isZeroStock) return AppColor.error;
if (item.isLowStock) return AppColor.warning;
return AppColor.success;
}
List<Color> _getGradientColors() {
if (item.isZeroStock) {
return [AppColor.error, AppColor.error.withOpacity(0.7)];
}
if (item.isLowStock) {
return [AppColor.warning, AppColor.warning.withOpacity(0.7)];
}
return [AppColor.primary, AppColor.primary.withOpacity(0.7)];
}
Color _getQuantityColor() {
if (item.isZeroStock) return AppColor.error;
if (item.isLowStock) return AppColor.warning;
return AppColor.textPrimary;
}
String _getStatusText() {
if (item.isZeroStock) return 'HABIS';
if (item.isLowStock) return 'MINIM';
return 'TERSEDIA';
}
IconData _getCategoryIcon() {
final category = item.categoryName.toLowerCase();
if (category.contains('elektronik') || category.contains('gadget')) {
return LineIcons.mobilePhone;
} else if (category.contains('fashion') || category.contains('pakaian')) {
return LineIcons.tShirt;
} else if (category.contains('makanan') || category.contains('food')) {
return LineIcons.utensils;
} else if (category.contains('kesehatan') || category.contains('health')) {
return LineIcons.medkit;
} else if (category.contains('rumah') || category.contains('home')) {
return LineIcons.home;
} else if (category.contains('olahraga') || category.contains('sport')) {
return LineIcons.dumbbell;
}
return LineIcons.box;
}
String _formatCurrencyShort(int amount) {
if (amount.abs() >= 1000000000) {
return 'Rp ${(amount / 1000000000).toStringAsFixed(1)}B';
} else if (amount.abs() >= 1000000) {
return 'Rp ${(amount / 1000000).toStringAsFixed(1)}M';
} else if (amount.abs() >= 1000) {
return 'Rp ${(amount / 1000).toStringAsFixed(0)}K';
} else {
return 'Rp ${NumberFormat('#,###', 'id_ID').format(amount)}';
}
}
}

View File

@ -8,14 +8,12 @@ class InventoryStatCard extends StatelessWidget {
final String value;
final IconData icon;
final Color color;
final String change;
const InventoryStatCard({
super.key,
required this.title,
required this.value,
required this.icon,
required this.color,
required this.change,
});
@override
@ -52,20 +50,6 @@ class InventoryStatCard extends StatelessWidget {
child: Icon(icon, color: color, size: 24),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getChangeColor(change).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
change,
style: AppStyle.sm.copyWith(
color: _getChangeColor(change),
fontWeight: FontWeight.w700,
),
),
),
],
),
SpaceHeight(16),
@ -88,14 +72,4 @@ class InventoryStatCard extends StatelessWidget {
),
);
}
Color _getChangeColor(String change) {
if (change.startsWith('+')) {
return AppColor.success;
} else if (change.startsWith('-')) {
return AppColor.error;
} else {
return AppColor.warning;
}
}
}