diff --git a/lib/application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_bloc.dart b/lib/application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_bloc.dart index 4426bd9..493edb6 100644 --- a/lib/application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_bloc.dart +++ b/lib/application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_bloc.dart @@ -28,22 +28,23 @@ class PurchasingAnalyticLoaderBloc rangeDateChanged: (e) async { emit(state.copyWith(dateFrom: e.dateFrom, dateTo: e.dateTo)); }, + outletChanged: (e) async { + emit(state.copyWith(outletId: e.outletId)); + }, fetched: (e) async { - emit( - state.copyWith( - isFetching: true, - failureOptionPurchasing: none(), - ), - ); + emit(state.copyWith( + isFetching: true, + failureOptionPurchasing: none(), + )); final result = await _analyticRepository.getPurchasing( dateFrom: state.dateFrom, dateTo: state.dateTo, + outletId: state.outletId, ); final newState = result.fold( - (f) => - state.copyWith(failureOptionPurchasing: optionOf(f)), + (f) => state.copyWith(failureOptionPurchasing: optionOf(f)), (purchasing) => state.copyWith(purchasing: purchasing), ); diff --git a/lib/application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_bloc.freezed.dart b/lib/application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_bloc.freezed.dart index 66403d9..58ce576 100644 --- a/lib/application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_bloc.freezed.dart +++ b/lib/application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_bloc.freezed.dart @@ -21,32 +21,38 @@ mixin _$PurchasingAnalyticLoaderEvent { TResult when({ required TResult Function(DateTime dateFrom, DateTime dateTo) rangeDateChanged, + required TResult Function(String? outletId) outletChanged, required TResult Function() fetched, }) => throw _privateConstructorUsedError; @optionalTypeArgs TResult? whenOrNull({ TResult? Function(DateTime dateFrom, DateTime dateTo)? rangeDateChanged, + TResult? Function(String? outletId)? outletChanged, TResult? Function()? fetched, }) => throw _privateConstructorUsedError; @optionalTypeArgs TResult maybeWhen({ TResult Function(DateTime dateFrom, DateTime dateTo)? rangeDateChanged, + TResult Function(String? outletId)? outletChanged, TResult Function()? fetched, required TResult orElse(), }) => throw _privateConstructorUsedError; @optionalTypeArgs TResult map({ required TResult Function(_RangeDateChanged value) rangeDateChanged, + required TResult Function(_OutletChanged value) outletChanged, required TResult Function(_Fetched value) fetched, }) => throw _privateConstructorUsedError; @optionalTypeArgs TResult? mapOrNull({ TResult? Function(_RangeDateChanged value)? rangeDateChanged, + TResult? Function(_OutletChanged value)? outletChanged, TResult? Function(_Fetched value)? fetched, }) => throw _privateConstructorUsedError; @optionalTypeArgs TResult maybeMap({ TResult Function(_RangeDateChanged value)? rangeDateChanged, + TResult Function(_OutletChanged value)? outletChanged, TResult Function(_Fetched value)? fetched, required TResult orElse(), }) => throw _privateConstructorUsedError; @@ -168,6 +174,7 @@ class _$RangeDateChangedImpl implements _RangeDateChanged { TResult when({ required TResult Function(DateTime dateFrom, DateTime dateTo) rangeDateChanged, + required TResult Function(String? outletId) outletChanged, required TResult Function() fetched, }) { return rangeDateChanged(dateFrom, dateTo); @@ -177,6 +184,7 @@ class _$RangeDateChangedImpl implements _RangeDateChanged { @optionalTypeArgs TResult? whenOrNull({ TResult? Function(DateTime dateFrom, DateTime dateTo)? rangeDateChanged, + TResult? Function(String? outletId)? outletChanged, TResult? Function()? fetched, }) { return rangeDateChanged?.call(dateFrom, dateTo); @@ -186,6 +194,7 @@ class _$RangeDateChangedImpl implements _RangeDateChanged { @optionalTypeArgs TResult maybeWhen({ TResult Function(DateTime dateFrom, DateTime dateTo)? rangeDateChanged, + TResult Function(String? outletId)? outletChanged, TResult Function()? fetched, required TResult orElse(), }) { @@ -199,6 +208,7 @@ class _$RangeDateChangedImpl implements _RangeDateChanged { @optionalTypeArgs TResult map({ required TResult Function(_RangeDateChanged value) rangeDateChanged, + required TResult Function(_OutletChanged value) outletChanged, required TResult Function(_Fetched value) fetched, }) { return rangeDateChanged(this); @@ -208,6 +218,7 @@ class _$RangeDateChangedImpl implements _RangeDateChanged { @optionalTypeArgs TResult? mapOrNull({ TResult? Function(_RangeDateChanged value)? rangeDateChanged, + TResult? Function(_OutletChanged value)? outletChanged, TResult? Function(_Fetched value)? fetched, }) { return rangeDateChanged?.call(this); @@ -217,6 +228,7 @@ class _$RangeDateChangedImpl implements _RangeDateChanged { @optionalTypeArgs TResult maybeMap({ TResult Function(_RangeDateChanged value)? rangeDateChanged, + TResult Function(_OutletChanged value)? outletChanged, TResult Function(_Fetched value)? fetched, required TResult orElse(), }) { @@ -243,6 +255,157 @@ abstract class _RangeDateChanged implements PurchasingAnalyticLoaderEvent { throw _privateConstructorUsedError; } +/// @nodoc +abstract class _$$OutletChangedImplCopyWith<$Res> { + factory _$$OutletChangedImplCopyWith( + _$OutletChangedImpl value, + $Res Function(_$OutletChangedImpl) then, + ) = __$$OutletChangedImplCopyWithImpl<$Res>; + @useResult + $Res call({String? outletId}); +} + +/// @nodoc +class __$$OutletChangedImplCopyWithImpl<$Res> + extends + _$PurchasingAnalyticLoaderEventCopyWithImpl<$Res, _$OutletChangedImpl> + implements _$$OutletChangedImplCopyWith<$Res> { + __$$OutletChangedImplCopyWithImpl( + _$OutletChangedImpl _value, + $Res Function(_$OutletChangedImpl) _then, + ) : super(_value, _then); + + /// Create a copy of PurchasingAnalyticLoaderEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? outletId = freezed}) { + return _then( + _$OutletChangedImpl( + freezed == outletId + ? _value.outletId + : outletId // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} + +/// @nodoc + +class _$OutletChangedImpl implements _OutletChanged { + const _$OutletChangedImpl(this.outletId); + + @override + final String? outletId; + + @override + String toString() { + return 'PurchasingAnalyticLoaderEvent.outletChanged(outletId: $outletId)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$OutletChangedImpl && + (identical(other.outletId, outletId) || + other.outletId == outletId)); + } + + @override + int get hashCode => Object.hash(runtimeType, outletId); + + /// Create a copy of PurchasingAnalyticLoaderEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$OutletChangedImplCopyWith<_$OutletChangedImpl> get copyWith => + __$$OutletChangedImplCopyWithImpl<_$OutletChangedImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime dateFrom, DateTime dateTo) + rangeDateChanged, + required TResult Function(String? outletId) outletChanged, + required TResult Function() fetched, + }) { + return outletChanged(outletId); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime dateFrom, DateTime dateTo)? rangeDateChanged, + TResult? Function(String? outletId)? outletChanged, + TResult? Function()? fetched, + }) { + return outletChanged?.call(outletId); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime dateFrom, DateTime dateTo)? rangeDateChanged, + TResult Function(String? outletId)? outletChanged, + TResult Function()? fetched, + required TResult orElse(), + }) { + if (outletChanged != null) { + return outletChanged(outletId); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_RangeDateChanged value) rangeDateChanged, + required TResult Function(_OutletChanged value) outletChanged, + required TResult Function(_Fetched value) fetched, + }) { + return outletChanged(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_RangeDateChanged value)? rangeDateChanged, + TResult? Function(_OutletChanged value)? outletChanged, + TResult? Function(_Fetched value)? fetched, + }) { + return outletChanged?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_RangeDateChanged value)? rangeDateChanged, + TResult Function(_OutletChanged value)? outletChanged, + TResult Function(_Fetched value)? fetched, + required TResult orElse(), + }) { + if (outletChanged != null) { + return outletChanged(this); + } + return orElse(); + } +} + +abstract class _OutletChanged implements PurchasingAnalyticLoaderEvent { + const factory _OutletChanged(final String? outletId) = _$OutletChangedImpl; + + String? get outletId; + + /// Create a copy of PurchasingAnalyticLoaderEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$OutletChangedImplCopyWith<_$OutletChangedImpl> get copyWith => + throw _privateConstructorUsedError; +} + /// @nodoc abstract class _$$FetchedImplCopyWith<$Res> { factory _$$FetchedImplCopyWith( @@ -288,6 +451,7 @@ class _$FetchedImpl implements _Fetched { TResult when({ required TResult Function(DateTime dateFrom, DateTime dateTo) rangeDateChanged, + required TResult Function(String? outletId) outletChanged, required TResult Function() fetched, }) { return fetched(); @@ -297,6 +461,7 @@ class _$FetchedImpl implements _Fetched { @optionalTypeArgs TResult? whenOrNull({ TResult? Function(DateTime dateFrom, DateTime dateTo)? rangeDateChanged, + TResult? Function(String? outletId)? outletChanged, TResult? Function()? fetched, }) { return fetched?.call(); @@ -306,6 +471,7 @@ class _$FetchedImpl implements _Fetched { @optionalTypeArgs TResult maybeWhen({ TResult Function(DateTime dateFrom, DateTime dateTo)? rangeDateChanged, + TResult Function(String? outletId)? outletChanged, TResult Function()? fetched, required TResult orElse(), }) { @@ -319,6 +485,7 @@ class _$FetchedImpl implements _Fetched { @optionalTypeArgs TResult map({ required TResult Function(_RangeDateChanged value) rangeDateChanged, + required TResult Function(_OutletChanged value) outletChanged, required TResult Function(_Fetched value) fetched, }) { return fetched(this); @@ -328,6 +495,7 @@ class _$FetchedImpl implements _Fetched { @optionalTypeArgs TResult? mapOrNull({ TResult? Function(_RangeDateChanged value)? rangeDateChanged, + TResult? Function(_OutletChanged value)? outletChanged, TResult? Function(_Fetched value)? fetched, }) { return fetched?.call(this); @@ -337,6 +505,7 @@ class _$FetchedImpl implements _Fetched { @optionalTypeArgs TResult maybeMap({ TResult Function(_RangeDateChanged value)? rangeDateChanged, + TResult Function(_OutletChanged value)? outletChanged, TResult Function(_Fetched value)? fetched, required TResult orElse(), }) { @@ -359,6 +528,7 @@ mixin _$PurchasingAnalyticLoaderState { bool get isFetching => throw _privateConstructorUsedError; DateTime get dateFrom => throw _privateConstructorUsedError; DateTime get dateTo => throw _privateConstructorUsedError; + String? get outletId => throw _privateConstructorUsedError; /// Create a copy of PurchasingAnalyticLoaderState /// with the given fields replaced by the non-null parameter values. @@ -384,6 +554,7 @@ abstract class $PurchasingAnalyticLoaderStateCopyWith<$Res> { bool isFetching, DateTime dateFrom, DateTime dateTo, + String? outletId, }); $PurchasingAnalyticCopyWith<$Res> get purchasing; @@ -412,6 +583,7 @@ class _$PurchasingAnalyticLoaderStateCopyWithImpl< Object? isFetching = null, Object? dateFrom = null, Object? dateTo = null, + Object? outletId = freezed, }) { return _then( _value.copyWith( @@ -435,6 +607,10 @@ class _$PurchasingAnalyticLoaderStateCopyWithImpl< ? _value.dateTo : dateTo // ignore: cast_nullable_to_non_nullable as DateTime, + outletId: freezed == outletId + ? _value.outletId + : outletId // ignore: cast_nullable_to_non_nullable + as String?, ) as $Val, ); @@ -466,6 +642,7 @@ abstract class _$$PurchasingAnalyticLoaderStateImplCopyWith<$Res> bool isFetching, DateTime dateFrom, DateTime dateTo, + String? outletId, }); @override @@ -495,6 +672,7 @@ class __$$PurchasingAnalyticLoaderStateImplCopyWithImpl<$Res> Object? isFetching = null, Object? dateFrom = null, Object? dateTo = null, + Object? outletId = freezed, }) { return _then( _$PurchasingAnalyticLoaderStateImpl( @@ -518,6 +696,10 @@ class __$$PurchasingAnalyticLoaderStateImplCopyWithImpl<$Res> ? _value.dateTo : dateTo // ignore: cast_nullable_to_non_nullable as DateTime, + outletId: freezed == outletId + ? _value.outletId + : outletId // ignore: cast_nullable_to_non_nullable + as String?, ), ); } @@ -533,6 +715,7 @@ class _$PurchasingAnalyticLoaderStateImpl this.isFetching = false, required this.dateFrom, required this.dateTo, + this.outletId, }); @override @@ -546,10 +729,12 @@ class _$PurchasingAnalyticLoaderStateImpl final DateTime dateFrom; @override final DateTime dateTo; + @override + final String? outletId; @override String toString() { - return 'PurchasingAnalyticLoaderState(purchasing: $purchasing, failureOptionPurchasing: $failureOptionPurchasing, isFetching: $isFetching, dateFrom: $dateFrom, dateTo: $dateTo)'; + return 'PurchasingAnalyticLoaderState(purchasing: $purchasing, failureOptionPurchasing: $failureOptionPurchasing, isFetching: $isFetching, dateFrom: $dateFrom, dateTo: $dateTo, outletId: $outletId)'; } @override @@ -568,7 +753,9 @@ class _$PurchasingAnalyticLoaderStateImpl other.isFetching == isFetching) && (identical(other.dateFrom, dateFrom) || other.dateFrom == dateFrom) && - (identical(other.dateTo, dateTo) || other.dateTo == dateTo)); + (identical(other.dateTo, dateTo) || other.dateTo == dateTo) && + (identical(other.outletId, outletId) || + other.outletId == outletId)); } @override @@ -579,6 +766,7 @@ class _$PurchasingAnalyticLoaderStateImpl isFetching, dateFrom, dateTo, + outletId, ); /// Create a copy of PurchasingAnalyticLoaderState @@ -603,6 +791,7 @@ abstract class _PurchasingAnalyticLoaderState final bool isFetching, required final DateTime dateFrom, required final DateTime dateTo, + final String? outletId, }) = _$PurchasingAnalyticLoaderStateImpl; @override @@ -615,6 +804,8 @@ abstract class _PurchasingAnalyticLoaderState DateTime get dateFrom; @override DateTime get dateTo; + @override + String? get outletId; /// Create a copy of PurchasingAnalyticLoaderState /// with the given fields replaced by the non-null parameter values. diff --git a/lib/application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_event.dart b/lib/application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_event.dart index 98419a2..41c0b57 100644 --- a/lib/application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_event.dart +++ b/lib/application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_event.dart @@ -7,5 +7,9 @@ class PurchasingAnalyticLoaderEvent with _$PurchasingAnalyticLoaderEvent { DateTime dateTo, ) = _RangeDateChanged; + const factory PurchasingAnalyticLoaderEvent.outletChanged( + String? outletId, + ) = _OutletChanged; + const factory PurchasingAnalyticLoaderEvent.fetched() = _Fetched; } diff --git a/lib/application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_state.dart b/lib/application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_state.dart index b3ea6b7..6d6922c 100644 --- a/lib/application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_state.dart +++ b/lib/application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_state.dart @@ -8,6 +8,7 @@ class PurchasingAnalyticLoaderState with _$PurchasingAnalyticLoaderState { @Default(false) bool isFetching, required DateTime dateFrom, required DateTime dateTo, + String? outletId, }) = _PurchasingAnalyticLoaderState; factory PurchasingAnalyticLoaderState.initial() => diff --git a/lib/common/theme/theme.dart b/lib/common/theme/theme.dart index 844f0d6..e964171 100644 --- a/lib/common/theme/theme.dart +++ b/lib/common/theme/theme.dart @@ -65,5 +65,8 @@ class ThemeApp { ), iconTheme: const IconThemeData(color: AppColor.white), ), + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: AppColor.white, + ) ); } diff --git a/lib/presentation/pages/purchase/purchase_page.dart b/lib/presentation/pages/purchase/purchase_page.dart index 770a38c..ac5473f 100644 --- a/lib/presentation/pages/purchase/purchase_page.dart +++ b/lib/presentation/pages/purchase/purchase_page.dart @@ -1,51 +1,70 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:line_icons/line_icons.dart'; +import 'package:shimmer/shimmer.dart'; +import '../../../application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_bloc.dart'; +import '../../../application/outlet/outlet_list_loader/outlet_list_loader_bloc.dart'; import '../../../common/extension/extension.dart'; import '../../../common/theme/theme.dart'; +import '../../../injection.dart'; import '../../components/appbar/appbar.dart'; -import 'widgets/purchase_tile.dart'; +import '../../components/field/date_range_picker_field.dart'; +import '../../components/spacer/spacer.dart'; +import 'widgets/ingredient_card.dart'; +import 'widgets/outlet_selector_field.dart'; +import 'widgets/purchase_daily_tile.dart'; import 'widgets/stat_card.dart'; -import 'widgets/status_chip.dart'; +import 'widgets/vendor_card.dart'; @RoutePage() -class PurchasePage extends StatefulWidget { +class PurchasePage extends StatefulWidget implements AutoRouteWrapper { const PurchasePage({super.key}); @override State createState() => _PurchasePageState(); + + @override + Widget wrappedRoute(BuildContext context) => MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => getIt() + ..add(const PurchasingAnalyticLoaderEvent.fetched()), + ), + BlocProvider( + create: (context) => getIt() + ..add(const OutletListLoaderEvent.fetched()), + ), + ], + child: this, + ); } class _PurchasePageState extends State with TickerProviderStateMixin { - late AnimationController cardAnimation; - String selectedFilter = 'Semua'; - final List filterOptions = [ - 'Semua', - 'Pending', - 'Completed', - 'Cancelled', - ]; - - final List> purchaseData = [ - ]; + late AnimationController _fadeController; + late Animation _fadeAnimation; @override void initState() { super.initState(); - - cardAnimation = AnimationController( - duration: const Duration(milliseconds: 1200), + _fadeController = AnimationController( + duration: const Duration(milliseconds: 600), vsync: this, ); - - cardAnimation.forward(); + _fadeAnimation = CurvedAnimation( + parent: _fadeController, + curve: Curves.easeOut, + ); + Future.delayed(const Duration(milliseconds: 200), () { + if (mounted) _fadeController.forward(); + }); } @override void dispose() { - cardAnimation.dispose(); + _fadeController.dispose(); super.dispose(); } @@ -53,141 +72,521 @@ class _PurchasePageState extends State Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColor.background, - body: CustomScrollView( - slivers: [ - SliverAppBar( - expandedHeight: 120.0, - floating: false, - pinned: true, - elevation: 0, - backgroundColor: AppColor.primary, - - flexibleSpace: CustomAppBar(title: context.lang.purchase), + body: MultiBlocListener( + listeners: [ + // Re-fetch when date range changes + BlocListener( + listenWhen: (prev, curr) => + prev.dateFrom != curr.dateFrom || + prev.dateTo != curr.dateTo, + listener: (context, _) => context + .read() + .add(const PurchasingAnalyticLoaderEvent.fetched()), ), - - // Stats Cards - SliverToBoxAdapter( - child: Container( - color: AppColor.background, - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - Expanded( - child: PurchaseStatCard( - title: context.lang.total_purchase, - value: 'Rp 0', - icon: LineIcons.shoppingCart, - iconColor: AppColor.success, - cardAnimation: cardAnimation, - ), - ), - const SizedBox(width: 12), - Expanded( - child: PurchaseStatCard( - title: context.lang.pending_order, - value: '0 ${context.lang.orders}', - icon: LineIcons.clock, - iconColor: AppColor.warning, - cardAnimation: cardAnimation, - ), - ), - ], - ), - ), + // Re-fetch when outlet changes + BlocListener( + listenWhen: (prev, curr) => prev.outletId != curr.outletId, + listener: (context, _) => context + .read() + .add(const PurchasingAnalyticLoaderEvent.fetched()), ), + ], + child: BlocBuilder( + builder: (context, outletListState) { + return BlocBuilder( + builder: (context, state) { + return CustomScrollView( + slivers: [ + // App Bar + SliverAppBar( + expandedHeight: 120.0, + floating: false, + pinned: true, + elevation: 0, + backgroundColor: AppColor.primary, + flexibleSpace: + CustomAppBar(title: context.lang.purchase), + ), - // Filter Section - SliverToBoxAdapter( - child: Container( - color: AppColor.surface, - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - context.lang.history_purchase, - style: AppStyle.lg.copyWith( - fontWeight: FontWeight.w600, + // Date Range + Outlet Picker + SliverToBoxAdapter( + child: FadeTransition( + opacity: _fadeAnimation, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: Column( + children: [ + DateRangePickerField( + maxDate: DateTime.now(), + startDate: state.dateFrom, + endDate: state.dateTo, + onChanged: (startDate, endDate) { + context + .read() + .add( + PurchasingAnalyticLoaderEvent + .rangeDateChanged( + startDate!, + endDate!, + ), + ); + }, + ), + const SpaceHeight(8), + PurchaseOutletSelectorField( + selectedOutletId: state.outletId, + outlets: outletListState.outlets, + isLoading: outletListState.isFetching, + onOutletChanged: (outletId) { + context + .read() + .add( + PurchasingAnalyticLoaderEvent + .outletChanged(outletId), + ); + }, + ), + ], + ), ), ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: AppColor.primary, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - '${purchaseData.length} ${context.lang.orders}', - style: AppStyle.sm.copyWith( - color: AppColor.textWhite, + ), + + const SliverToBoxAdapter(child: SpaceHeight(16)), + + // Summary Section + SliverToBoxAdapter( + child: FadeTransition( + opacity: _fadeAnimation, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.lang.summary, + style: AppStyle.xxl.copyWith( + fontWeight: FontWeight.bold, + color: AppColor.textPrimary, + ), + ), + const SpaceHeight(16), + state.isFetching + ? _buildSummaryShimmer() + : _buildSummaryCards(state), + ], ), ), ), + ), + + const SliverToBoxAdapter(child: SpaceHeight(24)), + + // Total Purchases Highlight Card + SliverToBoxAdapter( + child: FadeTransition( + opacity: _fadeAnimation, + child: state.isFetching + ? _buildHighlightShimmer() + : _buildTotalPurchasesCard(state), + ), + ), + + const SliverToBoxAdapter(child: SpaceHeight(24)), + + // Daily Breakdown Header + SliverToBoxAdapter( + child: FadeTransition( + opacity: _fadeAnimation, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + context.lang.daily_breakdown, + style: AppStyle.xxl.copyWith( + fontWeight: FontWeight.bold, + color: AppColor.textPrimary, + ), + ), + ), + ), + ), + const SliverToBoxAdapter(child: SpaceHeight(12)), + + state.isFetching + ? _buildListShimmer() + : _buildDailyList(state), + + const SliverToBoxAdapter(child: SpaceHeight(24)), + + // Ingredient Header + SliverToBoxAdapter( + child: FadeTransition( + opacity: _fadeAnimation, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Bahan Baku', + style: AppStyle.xxl.copyWith( + fontWeight: FontWeight.bold, + color: AppColor.textPrimary, + ), + ), + ), + ), + ), + const SliverToBoxAdapter(child: SpaceHeight(12)), + + state.isFetching + ? _buildListShimmer() + : _buildIngredientList(state), + + const SliverToBoxAdapter(child: SpaceHeight(24)), + + // Vendor Header + SliverToBoxAdapter( + child: FadeTransition( + opacity: _fadeAnimation, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Vendor', + style: AppStyle.xxl.copyWith( + fontWeight: FontWeight.bold, + color: AppColor.textPrimary, + ), + ), + ), + ), + ), + const SliverToBoxAdapter(child: SpaceHeight(12)), + + state.isFetching + ? _buildListShimmer() + : _buildVendorList(state), + + const SliverToBoxAdapter(child: SpaceHeight(80)), + ], + ); + }, + ); + }, + ), + ), + ); + } + + // ─── Summary Cards ──────────────────────────────────────────────────────── + + Widget _buildSummaryCards(PurchasingAnalyticLoaderState state) { + final s = state.purchasing.summary; + return Column( + children: [ + Row( + children: [ + Expanded( + child: PurchaseStatCard( + title: 'Total Pembelian', + value: s.totalPurchases.currencyFormatRp, + icon: LineIcons.shoppingCart, + iconColor: AppColor.primary, + cardAnimation: _fadeAnimation, + ), + ), + const SpaceWidth(12), + Expanded( + child: PurchaseStatCard( + title: 'Total PO', + value: '${s.totalPurchaseOrders} PO', + icon: LineIcons.fileAlt, + iconColor: AppColor.info, + cardAnimation: _fadeAnimation, + ), + ), + ], + ), + const SpaceHeight(12), + Row( + children: [ + Expanded( + child: PurchaseStatCard( + title: 'Total Qty', + value: '${s.totalQuantity} pcs', + icon: LineIcons.boxes, + iconColor: AppColor.warning, + cardAnimation: _fadeAnimation, + ), + ), + const SpaceWidth(12), + Expanded( + child: PurchaseStatCard( + title: 'Rata-rata PO', + value: s.averagePurchaseOrderValue.round().currencyFormatRp, + icon: LineIcons.dollarSign, + iconColor: AppColor.secondary, + cardAnimation: _fadeAnimation, + ), + ), + ], + ), + const SpaceHeight(12), + Row( + children: [ + Expanded( + child: PurchaseStatCard( + title: 'Bahan Baku', + value: '${s.totalIngredients} item', + icon: LineIcons.leaf, + iconColor: AppColor.secondaryDark, + cardAnimation: _fadeAnimation, + ), + ), + const SpaceWidth(12), + Expanded( + child: PurchaseStatCard( + title: 'Vendor', + value: '${s.totalVendors} vendor', + icon: LineIcons.truck, + iconColor: AppColor.primaryDark, + cardAnimation: _fadeAnimation, + ), + ), + ], + ), + ], + ); + } + + Widget _buildTotalPurchasesCard(PurchasingAnalyticLoaderState state) { + return TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 900), + curve: Curves.bounceOut, + builder: (context, value, _) { + return Transform.scale( + scale: value, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: AppColor.primaryGradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppColor.primary.withOpacity(0.35), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + LineIcons.shoppingBag, + color: AppColor.textWhite, + size: 28, + ), + ), + const SpaceWidth(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.lang.total_purchase, + style: TextStyle( + color: AppColor.textWhite.withOpacity(0.9), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SpaceHeight(4), + Text( + state.purchasing.summary.totalPurchases + .currencyFormatRp, + style: const TextStyle( + color: AppColor.textWhite, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), ], ), - const SizedBox(height: 12), - SizedBox( - height: 40, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: filterOptions.length, - itemBuilder: (context, index) { - final isSelected = - selectedFilter == filterOptions[index]; - return PurchaseStatusChip( - isSelected: isSelected, - text: filterOptions[index], - onSelected: (selected) { - setState(() { - selectedFilter = filterOptions[index]; - }); - }, - ); - }, - ), - ), - ], - ), - ), - ), - - // Purchase List - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final purchase = purchaseData[index]; - return AnimatedBuilder( - animation: cardAnimation, - builder: (context, child) { - final delay = index * 0.1; - final animValue = (cardAnimation.value - delay).clamp( - 0.0, - 1.0, - ); - - return Transform.translate( - offset: Offset(0, 30 * (1 - animValue)), - child: Opacity( - opacity: animValue, - child: PurchaseTile(purchase: purchase, index: index), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${state.purchasing.summary.totalPurchaseOrders} PO', + style: const TextStyle( + color: AppColor.textWhite, + fontSize: 16, + fontWeight: FontWeight.bold, ), - ); - }, - ); - }, childCount: purchaseData.length), + ), + Text( + 'purchase order', + style: TextStyle( + color: AppColor.textWhite.withOpacity(0.8), + fontSize: 12, + ), + ), + ], + ), + ], ), ), + ); + }, + ); + } - // Bottom spacing for FAB - const SliverToBoxAdapter(child: SizedBox(height: 80)), - ], + // ─── Lists ──────────────────────────────────────────────────────────────── + + Widget _buildDailyList(PurchasingAnalyticLoaderState state) { + if (state.purchasing.data.isEmpty) { + return SliverToBoxAdapter( + child: _buildEmptyState('Tidak ada data harian')); + } + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => PurchaseDailyTile( + data: state.purchasing.data[index], + index: index, + animation: _fadeAnimation, + ), + childCount: state.purchasing.data.length, + ), + ); + } + + Widget _buildIngredientList(PurchasingAnalyticLoaderState state) { + if (state.purchasing.ingredientData.isEmpty) { + return SliverToBoxAdapter( + child: _buildEmptyState('Tidak ada data bahan baku')); + } + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => PurchaseIngredientCard( + data: state.purchasing.ingredientData[index], + index: index, + animation: _fadeAnimation, + ), + childCount: state.purchasing.ingredientData.length, + ), + ); + } + + Widget _buildVendorList(PurchasingAnalyticLoaderState state) { + if (state.purchasing.vendorData.isEmpty) { + return SliverToBoxAdapter( + child: _buildEmptyState('Tidak ada data vendor')); + } + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => PurchaseVendorCard( + data: state.purchasing.vendorData[index], + index: index, + animation: _fadeAnimation, + ), + childCount: state.purchasing.vendorData.length, + ), + ); + } + + // ─── Empty State ────────────────────────────────────────────────────────── + + Widget _buildEmptyState(String message) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: AppColor.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColor.border.withOpacity(0.3)), + ), + child: Center( + child: Text( + message, + style: AppStyle.md.copyWith(color: AppColor.textSecondary), + ), + ), + ); + } + + // ─── Shimmer Loaders ────────────────────────────────────────────────────── + + Widget _buildSummaryShimmer() { + return Column( + children: [ + Row( + children: [ + Expanded(child: _shimmerCard(height: 100)), + const SpaceWidth(12), + Expanded(child: _shimmerCard(height: 100)), + ], + ), + const SpaceHeight(12), + Row( + children: [ + Expanded(child: _shimmerCard(height: 100)), + const SpaceWidth(12), + Expanded(child: _shimmerCard(height: 100)), + ], + ), + const SpaceHeight(12), + Row( + children: [ + Expanded(child: _shimmerCard(height: 100)), + const SpaceWidth(12), + Expanded(child: _shimmerCard(height: 100)), + ], + ), + ], + ); + } + + Widget _buildHighlightShimmer() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + child: _shimmerCard(height: 88), + ); + } + + Widget _buildListShimmer() { + return SliverList( + delegate: SliverChildBuilderDelegate( + (_, __) => Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: _shimmerCard(height: 72), + ), + childCount: 4, + ), + ); + } + + Widget _shimmerCard({required double height}) { + return Shimmer.fromColors( + baseColor: Colors.grey[300]!, + highlightColor: Colors.grey[100]!, + child: Container( + height: height, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), ), ); } diff --git a/lib/presentation/pages/purchase/widgets/ingredient_card.dart b/lib/presentation/pages/purchase/widgets/ingredient_card.dart new file mode 100644 index 0000000..303cc6e --- /dev/null +++ b/lib/presentation/pages/purchase/widgets/ingredient_card.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:line_icons/line_icons.dart'; + +import '../../../../common/extension/extension.dart'; +import '../../../../common/theme/theme.dart'; +import '../../../../domain/analytic/analytic.dart'; + +class PurchaseIngredientCard extends StatelessWidget { + final PurchasingIngredientData data; + final int index; + final Animation animation; + + const PurchaseIngredientCard({ + super.key, + required this.data, + required this.index, + required this.animation, + }); + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: animation, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColor.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColor.border.withOpacity(0.25)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Row( + children: [ + // Rank badge + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: AppColor.primaryGradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Text( + '${index + 1}', + style: AppStyle.sm.copyWith( + color: AppColor.textWhite, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + const SizedBox(width: 12), + + // Ingredient icon + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColor.secondary.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + LineIcons.leaf, + color: AppColor.secondary, + size: 20, + ), + ), + const SizedBox(width: 12), + + // Name & details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.ingredientName, + style: AppStyle.md.copyWith( + fontWeight: FontWeight.w700, + color: AppColor.textPrimary, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + _Chip( + label: '${data.quantity} pcs', + color: AppColor.warning, + ), + const SizedBox(width: 6), + _Chip( + label: '${data.purchaseOrderCount} PO', + color: AppColor.info, + ), + ], + ), + ], + ), + ), + + // Cost column + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + data.totalCost.currencyFormatRp, + style: AppStyle.md.copyWith( + fontWeight: FontWeight.w800, + color: AppColor.primary, + ), + ), + const SizedBox(height: 2), + Text( + '@ ${data.averageUnitCost.round().currencyFormatRp}', + style: AppStyle.xs.copyWith( + color: AppColor.textSecondary, + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _Chip extends StatelessWidget { + final String label; + final Color color; + + const _Chip({required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + label, + style: AppStyle.xs.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/lib/presentation/pages/purchase/widgets/outlet_selector_field.dart b/lib/presentation/pages/purchase/widgets/outlet_selector_field.dart new file mode 100644 index 0000000..874fd17 --- /dev/null +++ b/lib/presentation/pages/purchase/widgets/outlet_selector_field.dart @@ -0,0 +1,264 @@ +import 'package:flutter/material.dart'; + +import '../../../../common/theme/theme.dart'; +import '../../../../domain/outlet/outlet.dart'; + +/// Outlet selector field — styled like DateRangePickerField. +/// Opens a bottom sheet to pick an outlet or "Semua Outlet". +class PurchaseOutletSelectorField extends StatefulWidget { + final String? selectedOutletId; + final List outlets; + final bool isLoading; + final ValueChanged onOutletChanged; + + const PurchaseOutletSelectorField({ + super.key, + required this.selectedOutletId, + required this.outlets, + required this.isLoading, + required this.onOutletChanged, + }); + + @override + State createState() => + _PurchaseOutletSelectorFieldState(); +} + +class _PurchaseOutletSelectorFieldState + extends State { + bool _isPressed = false; + + Outlet? get _selectedOutlet => widget.outlets + .where((o) => o.id == widget.selectedOutletId) + .firstOrNull; + + String get _label => _selectedOutlet?.name ?? 'Semua Outlet'; + + bool get _hasValue => widget.selectedOutletId != null; + + void _showSheet() { + if (widget.isLoading) return; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => _OutletBottomSheet( + outlets: widget.outlets, + selectedOutletId: widget.selectedOutletId, + onSelected: (outletId) { + Navigator.pop(context); + widget.onOutletChanged(outletId); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _showSheet, + onTapDown: (_) => setState(() => _isPressed = true), + onTapUp: (_) => setState(() => _isPressed = false), + onTapCancel: () => setState(() => _isPressed = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: _isPressed ? AppColor.backgroundLight : AppColor.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _isPressed ? AppColor.primary : AppColor.border, + width: _isPressed ? 2 : 1, + ), + boxShadow: _isPressed + ? [ + BoxShadow( + color: AppColor.primary.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Row( + children: [ + Expanded( + child: Text( + _label, + style: TextStyle( + fontSize: 15, + fontWeight: + _hasValue ? FontWeight.w500 : FontWeight.w400, + color: _hasValue + ? AppColor.textPrimary + : AppColor.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: AppColor.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: widget.isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColor.primary, + ), + ) + : const Icon( + Icons.store_rounded, + size: 20, + color: AppColor.primary, + ), + ), + ], + ), + ), + ); + } +} + +// ─── Bottom Sheet ───────────────────────────────────────────────────────────── + +class _OutletBottomSheet extends StatelessWidget { + final List outlets; + final String? selectedOutletId; + final ValueChanged onSelected; + + const _OutletBottomSheet({ + required this.outlets, + required this.selectedOutletId, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Handle bar + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Text( + 'Pilih Outlet', + style: AppStyle.lg.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 12), + + // "Semua Outlet" option + _OutletItem( + label: 'Semua Outlet', + icon: Icons.store_rounded, + isSelected: selectedOutletId == null, + onTap: () => onSelected(null), + ), + const Divider(height: 1), + + // Individual outlets + ...outlets.map( + (outlet) => Column( + children: [ + _OutletItem( + label: outlet.name, + icon: Icons.storefront_rounded, + isSelected: selectedOutletId == outlet.id, + isActive: outlet.isActive, + onTap: () => onSelected(outlet.id), + ), + if (outlet != outlets.last) const Divider(height: 1), + ], + ), + ), + ], + ), + ), + ); + } +} + +// ─── List Item ──────────────────────────────────────────────────────────────── + +class _OutletItem extends StatelessWidget { + final String label; + final IconData icon; + final bool isSelected; + final bool? isActive; + final VoidCallback onTap; + + const _OutletItem({ + required this.label, + required this.icon, + required this.isSelected, + required this.onTap, + this.isActive, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + onTap: onTap, + contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: isSelected + ? AppColor.primary.withOpacity(0.1) + : AppColor.background, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + icon, + size: 18, + color: isSelected ? AppColor.primary : AppColor.textSecondary, + ), + ), + title: Text( + label, + style: AppStyle.md.copyWith( + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? AppColor.primary : AppColor.textPrimary, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isActive != null) ...[ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive! ? AppColor.success : AppColor.error, + ), + ), + const SizedBox(width: 8), + ], + if (isSelected) + const Icon(Icons.check_rounded, color: AppColor.primary, size: 20), + ], + ), + ); + } +} diff --git a/lib/presentation/pages/purchase/widgets/purchase_daily_tile.dart b/lib/presentation/pages/purchase/widgets/purchase_daily_tile.dart new file mode 100644 index 0000000..9f00526 --- /dev/null +++ b/lib/presentation/pages/purchase/widgets/purchase_daily_tile.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:line_icons/line_icons.dart'; + +import '../../../../common/extension/extension.dart'; +import '../../../../common/theme/theme.dart'; +import '../../../../domain/analytic/analytic.dart'; + +class PurchaseDailyTile extends StatelessWidget { + final PurchasingAnalyticData data; + final int index; + final Animation animation; + + const PurchaseDailyTile({ + super.key, + required this.data, + required this.index, + required this.animation, + }); + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: animation, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: AppColor.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColor.border.withOpacity(0.25)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: ExpansionTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + collapsedShape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColor.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + LineIcons.calendar, + color: AppColor.primary, + size: 20, + ), + ), + title: Text( + data.date.toDate, + style: AppStyle.md.copyWith( + fontWeight: FontWeight.w700, + color: AppColor.textPrimary, + ), + ), + subtitle: Text( + data.purchases.currencyFormatRp, + style: AppStyle.sm.copyWith( + color: AppColor.primary, + fontWeight: FontWeight.w600, + ), + ), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppColor.info.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${data.purchaseOrders} PO', + style: AppStyle.xs.copyWith( + color: AppColor.info, + fontWeight: FontWeight.w600, + ), + ), + ), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Row( + children: [ + Expanded( + child: _DetailItem( + icon: LineIcons.boxes, + label: 'Qty', + value: '${data.quantity} pcs', + color: AppColor.warning, + ), + ), + Expanded( + child: _DetailItem( + icon: LineIcons.leaf, + label: 'Bahan', + value: '${data.ingredients} item', + color: AppColor.secondary, + ), + ), + Expanded( + child: _DetailItem( + icon: LineIcons.truck, + label: 'Vendor', + value: '${data.vendors}', + color: AppColor.primaryDark, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _DetailItem extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final Color color; + + const _DetailItem({ + required this.icon, + required this.label, + required this.value, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: color, size: 18), + ), + const SizedBox(height: 6), + Text( + label, + style: AppStyle.xs.copyWith(color: AppColor.textSecondary), + ), + const SizedBox(height: 2), + Text( + value, + style: AppStyle.sm.copyWith( + color: AppColor.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + ], + ); + } +} diff --git a/lib/presentation/pages/purchase/widgets/purchase_tile.dart b/lib/presentation/pages/purchase/widgets/purchase_tile.dart deleted file mode 100644 index e850031..0000000 --- a/lib/presentation/pages/purchase/widgets/purchase_tile.dart +++ /dev/null @@ -1,314 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:line_icons/line_icons.dart'; - -import '../../../../common/extension/extension.dart'; -import '../../../../common/theme/theme.dart'; - -class PurchaseTile extends StatelessWidget { - final Map purchase; - final int index; - const PurchaseTile({super.key, required this.purchase, required this.index}); - - @override - Widget build(BuildContext context) { - Color statusColor; - - switch (purchase['status']) { - case 'Completed': - statusColor = AppColor.success; - break; - case 'Pending': - statusColor = AppColor.warning; - break; - case 'Cancelled': - statusColor = AppColor.error; - break; - default: - statusColor = AppColor.textSecondary; - } - return AnimatedContainer( - duration: Duration(milliseconds: 300 + (index * 50)), - curve: Curves.easeOutCubic, - margin: const EdgeInsets.only(bottom: 16), - child: Material( - elevation: 0, - borderRadius: BorderRadius.circular(20), - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [AppColor.surface, AppColor.surface.withOpacity(0.95)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: AppColor.border.withOpacity(0.2), - width: 1, - ), - boxShadow: [ - BoxShadow( - color: AppColor.primary.withOpacity(0.08), - blurRadius: 25, - offset: const Offset(0, 10), - spreadRadius: 0, - ), - BoxShadow( - color: AppColor.black.withOpacity(0.04), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: AppColor.primaryGradient, - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: AppColor.primary.withOpacity(0.3), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Text( - purchase['id'], - style: AppStyle.sm.copyWith( - fontWeight: FontWeight.w700, - color: AppColor.textWhite, - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - statusColor.withOpacity(0.15), - statusColor.withOpacity(0.05), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: statusColor.withOpacity(0.2), - width: 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: statusColor, - ), - ), - const SizedBox(width: 6), - Text( - purchase['status'], - style: AppStyle.xs.copyWith( - color: statusColor, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColor.background.withOpacity(0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppColor.border.withOpacity(0.3), - width: 1, - ), - ), - child: Column( - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppColor.primary.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - LineIcons.building, - color: AppColor.primary, - size: 16, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - purchase['supplier'], - style: AppStyle.md.copyWith( - color: AppColor.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppColor.info.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - LineIcons.calendar, - color: AppColor.info, - size: 16, - ), - ), - const SizedBox(width: 12), - Text( - purchase['date'], - style: AppStyle.sm.copyWith( - color: AppColor.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - const Spacer(), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: AppColor.secondary.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LineIcons.shoppingBag, - color: AppColor.secondary, - size: 14, - ), - const SizedBox(width: 4), - Text( - '${purchase['items']} ${context.lang.items}', - style: AppStyle.xs.copyWith( - color: AppColor.secondary, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.lang.total_purchase, - style: AppStyle.xs.copyWith( - color: AppColor.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 4), - Text( - 'Rp ${purchase['total'].toString().replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}', - style: AppStyle.lg.copyWith( - fontWeight: FontWeight.w800, - color: AppColor.primary, - ), - ), - ], - ), - Row( - children: [ - _buildActionButton(LineIcons.eye, AppColor.info, () {}), - const SizedBox(width: 8), - _buildActionButton( - LineIcons.edit, - AppColor.warning, - () {}, - ), - const SizedBox(width: 8), - _buildActionButton( - LineIcons.trash, - AppColor.error, - () {}, - ), - ], - ), - ], - ), - ], - ), - ), - ), - ); - } - - Widget _buildActionButton( - IconData icon, - Color color, - VoidCallback onPressed, - ) { - return Container( - width: 40, - height: 40, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [color.withOpacity(0.15), color.withOpacity(0.05)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: color.withOpacity(0.2), width: 1), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: onPressed, - child: Center(child: Icon(icon, color: color, size: 18)), - ), - ), - ); - } -} diff --git a/lib/presentation/pages/purchase/widgets/stat_card.dart b/lib/presentation/pages/purchase/widgets/stat_card.dart index edb17f3..b791704 100644 --- a/lib/presentation/pages/purchase/widgets/stat_card.dart +++ b/lib/presentation/pages/purchase/widgets/stat_card.dart @@ -8,6 +8,7 @@ class PurchaseStatCard extends StatelessWidget { final IconData icon; final Color iconColor; final Animation cardAnimation; + const PurchaseStatCard({ super.key, required this.title, @@ -25,65 +26,41 @@ class PurchaseStatCard extends StatelessWidget { return Transform.scale( scale: 0.8 + (cardAnimation.value * 0.2), child: Opacity( - opacity: cardAnimation.value, + opacity: cardAnimation.value.clamp(0.0, 1.0), child: Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - gradient: LinearGradient( - colors: [AppColor.surface, AppColor.surface.withOpacity(0.9)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(20), + color: AppColor.surface, + borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: AppColor.primary.withOpacity(0.1), - blurRadius: 20, - offset: const Offset(0, 8), - spreadRadius: 0, + color: AppColor.primary.withOpacity(0.08), + blurRadius: 16, + offset: const Offset(0, 6), ), BoxShadow( - color: AppColor.black.withOpacity(0.05), - blurRadius: 10, + color: AppColor.black.withOpacity(0.04), + blurRadius: 8, offset: const Offset(0, 2), ), ], border: Border.all( - color: AppColor.border.withOpacity(0.3), + color: AppColor.border.withOpacity(0.25), width: 1, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - iconColor.withOpacity(0.15), - iconColor.withOpacity(0.05), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: iconColor.withOpacity(0.2), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Icon(icon, color: iconColor, size: 24), - ), - ], + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: iconColor.withOpacity(0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: iconColor, size: 22), ), - const SizedBox(height: 16), + const SizedBox(height: 12), Text( title, style: AppStyle.sm.copyWith( @@ -91,14 +68,16 @@ class PurchaseStatCard extends StatelessWidget { fontWeight: FontWeight.w500, ), ), - const SizedBox(height: 6), + const SizedBox(height: 4), Text( value, - style: AppStyle.lg.copyWith( + style: AppStyle.md.copyWith( fontWeight: FontWeight.w800, color: AppColor.textPrimary, height: 1.2, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ], ), diff --git a/lib/presentation/pages/purchase/widgets/status_chip.dart b/lib/presentation/pages/purchase/widgets/status_chip.dart deleted file mode 100644 index 91a85ab..0000000 --- a/lib/presentation/pages/purchase/widgets/status_chip.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../../common/theme/theme.dart'; - -class PurchaseStatusChip extends StatelessWidget { - final bool isSelected; - final String text; - final Function(bool) onSelected; - const PurchaseStatusChip({ - super.key, - required this.isSelected, - required this.text, - required this.onSelected, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(right: 8), - child: FilterChip( - selected: isSelected, - label: Text( - text, - style: AppStyle.sm.copyWith( - color: isSelected ? AppColor.textWhite : AppColor.textSecondary, - ), - ), - backgroundColor: AppColor.backgroundLight, - selectedColor: AppColor.primary, - onSelected: onSelected, - ), - ); - } -} diff --git a/lib/presentation/pages/purchase/widgets/vendor_card.dart b/lib/presentation/pages/purchase/widgets/vendor_card.dart new file mode 100644 index 0000000..f808b10 --- /dev/null +++ b/lib/presentation/pages/purchase/widgets/vendor_card.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:line_icons/line_icons.dart'; + +import '../../../../common/extension/extension.dart'; +import '../../../../common/theme/theme.dart'; +import '../../../../domain/analytic/analytic.dart'; + +class PurchaseVendorCard extends StatelessWidget { + final PurchasingVendorData data; + final int index; + final Animation animation; + + const PurchaseVendorCard({ + super.key, + required this.data, + required this.index, + required this.animation, + }); + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: animation, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColor.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColor.border.withOpacity(0.25)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Row( + children: [ + // Rank badge + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: AppColor.primaryGradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Text( + '${index + 1}', + style: AppStyle.sm.copyWith( + color: AppColor.textWhite, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + const SizedBox(width: 12), + + // Vendor icon + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColor.info.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + LineIcons.truck, + color: AppColor.info, + size: 20, + ), + ), + const SizedBox(width: 12), + + // Name & details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.vendorName.trim(), + style: AppStyle.md.copyWith( + fontWeight: FontWeight.w700, + color: AppColor.textPrimary, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + _Chip( + label: '${data.purchaseOrderCount} PO', + color: AppColor.info, + ), + const SizedBox(width: 6), + _Chip( + label: '${data.ingredientCount} bahan', + color: AppColor.secondary, + ), + const SizedBox(width: 6), + _Chip( + label: '${data.quantity} pcs', + color: AppColor.warning, + ), + ], + ), + ], + ), + ), + + // Total cost + Text( + data.totalCost.currencyFormatRp, + style: AppStyle.md.copyWith( + fontWeight: FontWeight.w800, + color: AppColor.primary, + ), + ), + ], + ), + ), + ); + } +} + +class _Chip extends StatelessWidget { + final String label; + final Color color; + + const _Chip({required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + label, + style: AppStyle.xs.copyWith( + color: color, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/lib/presentation/router/app_router.gr.dart b/lib/presentation/router/app_router.gr.dart index 00f4aaf..9a88721 100644 --- a/lib/presentation/router/app_router.gr.dart +++ b/lib/presentation/router/app_router.gr.dart @@ -501,7 +501,7 @@ class PurchaseRoute extends _i26.PageRouteInfo { static _i26.PageInfo page = _i26.PageInfo( name, builder: (data) { - return const _i21.PurchasePage(); + return _i26.WrappedRoute(child: const _i21.PurchasePage()); }, ); } diff --git a/pubspec.lock b/pubspec.lock index 1c03798..91b13a6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -213,10 +213,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -865,26 +865,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" line_icons: dependency: "direct main" description: @@ -921,26 +921,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1510,10 +1510,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.10" time: dependency: transitive description: @@ -1646,10 +1646,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -1731,5 +1731,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.1 <4.0.0" + dart: ">=3.9.0-0 <4.0.0" flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml index 064d1cc..7a49f02 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "A new Flutter project." publish_to: "none" -version: 1.0.0+1 +version: 1.0.1+2 environment: sdk: ^3.8.1