diff --git a/lib/application/home/home_bloc.dart b/lib/application/home/home_bloc.dart new file mode 100644 index 0000000..fc0e385 --- /dev/null +++ b/lib/application/home/home_bloc.dart @@ -0,0 +1,38 @@ +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 'home_event.dart'; +part 'home_state.dart'; +part 'home_bloc.freezed.dart'; + +@injectable +class HomeBloc extends Bloc { + final IAnalyticRepository _analyticRepository; + HomeBloc(this._analyticRepository) : super(HomeState.initial()) { + on(_onHomeEvent); + } + Future _onHomeEvent(HomeEvent event, Emitter emit) { + return event.map( + fetchedDashboard: (e) async { + emit(state.copyWith(isFetching: true, failureOptionDashboard: none())); + + final result = await _analyticRepository.getDashboard( + dateFrom: DateTime.now(), + dateTo: DateTime.now(), + ); + + var data = result.fold( + (f) => state.copyWith(failureOptionDashboard: optionOf(f)), + (dashboard) => state.copyWith(dashboard: dashboard), + ); + + emit(data.copyWith(isFetching: false)); + }, + ); + } +} diff --git a/lib/application/home/home_bloc.freezed.dart b/lib/application/home/home_bloc.freezed.dart new file mode 100644 index 0000000..6ad0699 --- /dev/null +++ b/lib/application/home/home_bloc.freezed.dart @@ -0,0 +1,370 @@ +// 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 'home_bloc.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(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 _$HomeEvent { + @optionalTypeArgs + TResult when({ + required TResult Function() fetchedDashboard, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? fetchedDashboard, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? fetchedDashboard, + required TResult orElse(), + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(_FetchedDashboard value) fetchedDashboard, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_FetchedDashboard value)? fetchedDashboard, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_FetchedDashboard value)? fetchedDashboard, + required TResult orElse(), + }) => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $HomeEventCopyWith<$Res> { + factory $HomeEventCopyWith(HomeEvent value, $Res Function(HomeEvent) then) = + _$HomeEventCopyWithImpl<$Res, HomeEvent>; +} + +/// @nodoc +class _$HomeEventCopyWithImpl<$Res, $Val extends HomeEvent> + implements $HomeEventCopyWith<$Res> { + _$HomeEventCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of HomeEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc +abstract class _$$FetchedDashboardImplCopyWith<$Res> { + factory _$$FetchedDashboardImplCopyWith( + _$FetchedDashboardImpl value, + $Res Function(_$FetchedDashboardImpl) then, + ) = __$$FetchedDashboardImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$FetchedDashboardImplCopyWithImpl<$Res> + extends _$HomeEventCopyWithImpl<$Res, _$FetchedDashboardImpl> + implements _$$FetchedDashboardImplCopyWith<$Res> { + __$$FetchedDashboardImplCopyWithImpl( + _$FetchedDashboardImpl _value, + $Res Function(_$FetchedDashboardImpl) _then, + ) : super(_value, _then); + + /// Create a copy of HomeEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$FetchedDashboardImpl implements _FetchedDashboard { + const _$FetchedDashboardImpl(); + + @override + String toString() { + return 'HomeEvent.fetchedDashboard()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is _$FetchedDashboardImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() fetchedDashboard, + }) { + return fetchedDashboard(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? fetchedDashboard, + }) { + return fetchedDashboard?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? fetchedDashboard, + required TResult orElse(), + }) { + if (fetchedDashboard != null) { + return fetchedDashboard(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_FetchedDashboard value) fetchedDashboard, + }) { + return fetchedDashboard(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_FetchedDashboard value)? fetchedDashboard, + }) { + return fetchedDashboard?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_FetchedDashboard value)? fetchedDashboard, + required TResult orElse(), + }) { + if (fetchedDashboard != null) { + return fetchedDashboard(this); + } + return orElse(); + } +} + +abstract class _FetchedDashboard implements HomeEvent { + const factory _FetchedDashboard() = _$FetchedDashboardImpl; +} + +/// @nodoc +mixin _$HomeState { + DashboardAnalytic get dashboard => throw _privateConstructorUsedError; + Option get failureOptionDashboard => + throw _privateConstructorUsedError; + bool get isFetching => throw _privateConstructorUsedError; + + /// Create a copy of HomeState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $HomeStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $HomeStateCopyWith<$Res> { + factory $HomeStateCopyWith(HomeState value, $Res Function(HomeState) then) = + _$HomeStateCopyWithImpl<$Res, HomeState>; + @useResult + $Res call({ + DashboardAnalytic dashboard, + Option failureOptionDashboard, + bool isFetching, + }); + + $DashboardAnalyticCopyWith<$Res> get dashboard; +} + +/// @nodoc +class _$HomeStateCopyWithImpl<$Res, $Val extends HomeState> + implements $HomeStateCopyWith<$Res> { + _$HomeStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of HomeState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? dashboard = null, + Object? failureOptionDashboard = null, + Object? isFetching = null, + }) { + return _then( + _value.copyWith( + dashboard: null == dashboard + ? _value.dashboard + : dashboard // ignore: cast_nullable_to_non_nullable + as DashboardAnalytic, + failureOptionDashboard: null == failureOptionDashboard + ? _value.failureOptionDashboard + : failureOptionDashboard // ignore: cast_nullable_to_non_nullable + as Option, + isFetching: null == isFetching + ? _value.isFetching + : isFetching // ignore: cast_nullable_to_non_nullable + as bool, + ) + as $Val, + ); + } + + /// Create a copy of HomeState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $DashboardAnalyticCopyWith<$Res> get dashboard { + return $DashboardAnalyticCopyWith<$Res>(_value.dashboard, (value) { + return _then(_value.copyWith(dashboard: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$HomeStateImplCopyWith<$Res> + implements $HomeStateCopyWith<$Res> { + factory _$$HomeStateImplCopyWith( + _$HomeStateImpl value, + $Res Function(_$HomeStateImpl) then, + ) = __$$HomeStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + DashboardAnalytic dashboard, + Option failureOptionDashboard, + bool isFetching, + }); + + @override + $DashboardAnalyticCopyWith<$Res> get dashboard; +} + +/// @nodoc +class __$$HomeStateImplCopyWithImpl<$Res> + extends _$HomeStateCopyWithImpl<$Res, _$HomeStateImpl> + implements _$$HomeStateImplCopyWith<$Res> { + __$$HomeStateImplCopyWithImpl( + _$HomeStateImpl _value, + $Res Function(_$HomeStateImpl) _then, + ) : super(_value, _then); + + /// Create a copy of HomeState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? dashboard = null, + Object? failureOptionDashboard = null, + Object? isFetching = null, + }) { + return _then( + _$HomeStateImpl( + dashboard: null == dashboard + ? _value.dashboard + : dashboard // ignore: cast_nullable_to_non_nullable + as DashboardAnalytic, + failureOptionDashboard: null == failureOptionDashboard + ? _value.failureOptionDashboard + : failureOptionDashboard // ignore: cast_nullable_to_non_nullable + as Option, + isFetching: null == isFetching + ? _value.isFetching + : isFetching // ignore: cast_nullable_to_non_nullable + as bool, + ), + ); + } +} + +/// @nodoc + +class _$HomeStateImpl implements _HomeState { + const _$HomeStateImpl({ + required this.dashboard, + required this.failureOptionDashboard, + this.isFetching = false, + }); + + @override + final DashboardAnalytic dashboard; + @override + final Option failureOptionDashboard; + @override + @JsonKey() + final bool isFetching; + + @override + String toString() { + return 'HomeState(dashboard: $dashboard, failureOptionDashboard: $failureOptionDashboard, isFetching: $isFetching)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$HomeStateImpl && + (identical(other.dashboard, dashboard) || + other.dashboard == dashboard) && + (identical(other.failureOptionDashboard, failureOptionDashboard) || + other.failureOptionDashboard == failureOptionDashboard) && + (identical(other.isFetching, isFetching) || + other.isFetching == isFetching)); + } + + @override + int get hashCode => + Object.hash(runtimeType, dashboard, failureOptionDashboard, isFetching); + + /// Create a copy of HomeState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$HomeStateImplCopyWith<_$HomeStateImpl> get copyWith => + __$$HomeStateImplCopyWithImpl<_$HomeStateImpl>(this, _$identity); +} + +abstract class _HomeState implements HomeState { + const factory _HomeState({ + required final DashboardAnalytic dashboard, + required final Option failureOptionDashboard, + final bool isFetching, + }) = _$HomeStateImpl; + + @override + DashboardAnalytic get dashboard; + @override + Option get failureOptionDashboard; + @override + bool get isFetching; + + /// Create a copy of HomeState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$HomeStateImplCopyWith<_$HomeStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/application/home/home_event.dart b/lib/application/home/home_event.dart new file mode 100644 index 0000000..5f3909d --- /dev/null +++ b/lib/application/home/home_event.dart @@ -0,0 +1,6 @@ +part of 'home_bloc.dart'; + +@freezed +class HomeEvent with _$HomeEvent { + const factory HomeEvent.fetchedDashboard() = _FetchedDashboard; +} diff --git a/lib/application/home/home_state.dart b/lib/application/home/home_state.dart new file mode 100644 index 0000000..7b0901a --- /dev/null +++ b/lib/application/home/home_state.dart @@ -0,0 +1,15 @@ +part of 'home_bloc.dart'; + +@freezed +class HomeState with _$HomeState { + const factory HomeState({ + required DashboardAnalytic dashboard, + required Option failureOptionDashboard, + @Default(false) bool isFetching, + }) = _HomeState; + + factory HomeState.initial() => HomeState( + dashboard: DashboardAnalytic.empty(), + failureOptionDashboard: none(), + ); +} diff --git a/lib/injection.config.dart b/lib/injection.config.dart index 4e24815..581d9e7 100644 --- a/lib/injection.config.dart +++ b/lib/injection.config.dart @@ -32,6 +32,7 @@ import 'package:apskel_owner_flutter/application/category/category_loader/catego as _i183; import 'package:apskel_owner_flutter/application/customer/customer_loader/customer_loader_bloc.dart' as _i972; +import 'package:apskel_owner_flutter/application/home/home_bloc.dart' as _i473; import 'package:apskel_owner_flutter/application/language/language_bloc.dart' as _i455; import 'package:apskel_owner_flutter/application/order/order_loader/order_loader_bloc.dart' @@ -200,6 +201,9 @@ extension GetItInjectableX on _i174.GetIt { gh.factory<_i889.SalesLoaderBloc>( () => _i889.SalesLoaderBloc(gh<_i477.IAnalyticRepository>()), ); + gh.factory<_i473.HomeBloc>( + () => _i473.HomeBloc(gh<_i477.IAnalyticRepository>()), + ); gh.factory<_i337.CurrentOutletLoaderBloc>( () => _i337.CurrentOutletLoaderBloc(gh<_i197.IOutletRepository>()), ); diff --git a/lib/presentation/pages/home/home_page.dart b/lib/presentation/pages/home/home_page.dart index 8eebf3e..78ba82e 100644 --- a/lib/presentation/pages/home/home_page.dart +++ b/lib/presentation/pages/home/home_page.dart @@ -1,22 +1,31 @@ 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 '../../../application/home/home_bloc.dart'; +import '../../../common/constant/app_constant.dart'; import '../../../common/theme/theme.dart'; +import '../../../injection.dart'; import '../../components/button/button.dart'; import '../../components/spacer/spacer.dart'; -import 'widgets/activity.dart'; import 'widgets/feature.dart'; import 'widgets/header.dart'; -import 'widgets/performance.dart'; import 'widgets/stats.dart'; +import 'widgets/top_product.dart'; @RoutePage() -class HomePage extends StatefulWidget { +class HomePage extends StatefulWidget implements AutoRouteWrapper { const HomePage({super.key}); @override State createState() => _HomePageState(); + + @override + Widget wrappedRoute(BuildContext context) => BlocProvider( + create: (context) => getIt()..add(HomeEvent.fetchedDashboard()), + child: this, + ); } class _HomePageState extends State with TickerProviderStateMixin { @@ -58,106 +67,119 @@ class _HomePageState extends State with TickerProviderStateMixin { Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColor.background, - body: CustomScrollView( - physics: const BouncingScrollPhysics(parent: ClampingScrollPhysics()), - slivers: [ - // SliverAppBar with HomeHeader as background - SliverAppBar( - expandedHeight: 260, // Adjust based on HomeHeader height - floating: true, - pinned: true, - snap: true, - elevation: 0, - scrolledUnderElevation: 8, - backgroundColor: AppColor.primary, - surfaceTintColor: Colors.transparent, - flexibleSpace: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - // Calculate collapse progress (0.0 = expanded, 1.0 = collapsed) - final double expandedHeight = 200; - final double collapsedHeight = - kToolbarHeight + MediaQuery.of(context).padding.top; - final double currentHeight = constraints.maxHeight; + body: BlocBuilder( + builder: (context, state) { + return CustomScrollView( + physics: const BouncingScrollPhysics( + parent: ClampingScrollPhysics(), + ), + slivers: [ + // SliverAppBar with HomeHeader as background + SliverAppBar( + expandedHeight: 260, // Adjust based on HomeHeader height + floating: true, + pinned: true, + snap: true, + elevation: 0, + scrolledUnderElevation: 8, + backgroundColor: AppColor.primary, + surfaceTintColor: Colors.transparent, + flexibleSpace: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + // Calculate collapse progress (0.0 = expanded, 1.0 = collapsed) + final double expandedHeight = 200; + final double collapsedHeight = + kToolbarHeight + MediaQuery.of(context).padding.top; + final double currentHeight = constraints.maxHeight; - double collapseProgress = - 1.0 - - ((currentHeight - collapsedHeight) / - (expandedHeight - collapsedHeight)); - collapseProgress = collapseProgress.clamp(0.0, 1.0); + double collapseProgress = + 1.0 - + ((currentHeight - collapsedHeight) / + (expandedHeight - collapsedHeight)); + collapseProgress = collapseProgress.clamp(0.0, 1.0); - return FlexibleSpaceBar( - title: Opacity( - opacity: collapseProgress, // Title muncul saat collapse - child: Row( - children: [ - Expanded( - child: Text( - 'AppSkel POS Owner', - style: AppStyle.xl.copyWith( - fontWeight: FontWeight.w700, - fontSize: 18, - letterSpacing: -0.5, - color: AppColor.white, + return FlexibleSpaceBar( + title: Opacity( + opacity: collapseProgress, // Title muncul saat collapse + child: Row( + children: [ + Expanded( + child: Text( + AppConstant.appName, + style: AppStyle.xl.copyWith( + fontWeight: FontWeight.w700, + fontSize: 18, + letterSpacing: -0.5, + color: AppColor.white, + ), + ), ), - ), + ActionIconButton( + onTap: () {}, + icon: LineIcons.bell, + ), + ], ), - ActionIconButton(onTap: () {}, icon: LineIcons.bell), - ], - ), - ), - titlePadding: const EdgeInsets.only( - left: 20, - right: 12, - bottom: 16, - ), - background: AnimatedBuilder( - animation: _headerAnimationController, - builder: (context, child) { - return Transform.translate( - offset: Offset( - 0, - 50 * (1 - _headerAnimationController.value), - ), - child: Opacity( - opacity: _headerAnimationController.value, - child: HomeHeader(), - ), - ); - }, - ), - ); - }, - ), - ), + ), + titlePadding: const EdgeInsets.only( + left: 20, + right: 12, + bottom: 16, + ), + background: AnimatedBuilder( + animation: _headerAnimationController, + builder: (context, child) { + return Transform.translate( + offset: Offset( + 0, + 50 * (1 - _headerAnimationController.value), + ), + child: Opacity( + opacity: _headerAnimationController.value, + child: HomeHeader( + totalRevenue: + state.dashboard.overview.totalSales, + ), + ), + ); + }, + ), + ); + }, + ), + ), - // Main Content - SliverToBoxAdapter( - child: AnimatedBuilder( - animation: _contentAnimationController, - builder: (context, child) { - return Transform.translate( - offset: Offset( - 0, - 30 * (1 - _contentAnimationController.value), - ), - child: Opacity( - opacity: _contentAnimationController.value, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - HomeFeature(), - HomeStats(), - HomeActivity(), - HomePerformance(), - const SpaceHeight(40), - ], - ), - ), - ); - }, - ), - ), - ], + // Main Content + SliverToBoxAdapter( + child: AnimatedBuilder( + animation: _contentAnimationController, + builder: (context, child) { + return Transform.translate( + offset: Offset( + 0, + 30 * (1 - _contentAnimationController.value), + ), + child: Opacity( + opacity: _contentAnimationController.value, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HomeFeature(), + HomeStats(overview: state.dashboard.overview), + HomeTopProduct( + products: state.dashboard.topProducts, + ), + const SpaceHeight(40), + ], + ), + ), + ); + }, + ), + ), + ], + ); + }, ), ); } diff --git a/lib/presentation/pages/home/widgets/activity.dart b/lib/presentation/pages/home/widgets/activity.dart deleted file mode 100644 index dfa5b7e..0000000 --- a/lib/presentation/pages/home/widgets/activity.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../../common/theme/theme.dart'; -import '../../../components/spacer/spacer.dart'; -import 'activity_tile.dart'; - -class HomeActivity extends StatelessWidget { - const HomeActivity({super.key}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric( - vertical: 24, - horizontal: AppValue.padding, - ).copyWith(bottom: 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Aktivitas Terkini', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - color: AppColor.textPrimary, - letterSpacing: -0.5, - ), - ), - TextButton.icon( - onPressed: () {}, - icon: const Icon(Icons.arrow_forward_rounded, size: 16), - label: const Text('Lihat Semua'), - style: TextButton.styleFrom( - foregroundColor: AppColor.primary, - textStyle: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - ), - ], - ), - const SpaceHeight(16), - Container( - decoration: BoxDecoration( - color: AppColor.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColor.border.withOpacity(0.5)), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.04), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], - ), - child: Column( - children: [ - HomeActivityTile( - title: 'Transaksi Berhasil', - subtitle: 'Kasir-01 • Rp 125.000', - time: '2 menit lalu', - icon: Icons.check_circle_rounded, - color: AppColor.success, - isHighlighted: true, - ), - const Divider(height: 1, color: AppColor.border), - HomeActivityTile( - title: 'Stok Menipis', - subtitle: 'Kopi Arabica • 5 unit tersisa', - time: '15 menit lalu', - icon: Icons.warning_amber_rounded, - color: AppColor.warning, - isHighlighted: false, - ), - const Divider(height: 1, color: AppColor.border), - HomeActivityTile( - title: 'Login Kasir', - subtitle: 'Sari masuk shift pagi', - time: '1 Jam lalu', - icon: Icons.login_rounded, - color: AppColor.info, - isHighlighted: false, - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/presentation/pages/home/widgets/activity_tile.dart b/lib/presentation/pages/home/widgets/activity_tile.dart deleted file mode 100644 index 75ec095..0000000 --- a/lib/presentation/pages/home/widgets/activity_tile.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../../common/theme/theme.dart'; -import '../../../components/spacer/spacer.dart'; - -class HomeActivityTile extends StatelessWidget { - final String title; - final String subtitle; - final String time; - final IconData icon; - final Color color; - final bool isHighlighted; - const HomeActivityTile({ - super.key, - required this.title, - required this.subtitle, - required this.time, - required this.icon, - required this.color, - required this.isHighlighted, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: isHighlighted ? color.withOpacity(0.02) : Colors.transparent, - borderRadius: isHighlighted ? BorderRadius.circular(16) : null, - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [color.withOpacity(0.1), 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: Icon(icon, color: color, size: 20), - ), - const SpaceWidth(16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - title, - style: AppStyle.md.copyWith( - fontWeight: FontWeight.w600, - color: AppColor.textPrimary, - letterSpacing: -0.2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SpaceHeight(4), - Text( - subtitle, - style: AppStyle.sm.copyWith( - color: AppColor.textSecondary, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - time, - style: AppStyle.xs.copyWith( - fontSize: 11, - color: AppColor.textLight, - fontWeight: FontWeight.w500, - ), - ), - if (isHighlighted) ...[ - const SpaceHeight(4), - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - ), - ), - ], - ], - ), - ], - ), - ); - } -} diff --git a/lib/presentation/pages/home/widgets/header.dart b/lib/presentation/pages/home/widgets/header.dart index dc74720..7139811 100644 --- a/lib/presentation/pages/home/widgets/header.dart +++ b/lib/presentation/pages/home/widgets/header.dart @@ -11,7 +11,8 @@ import '../../../../domain/auth/auth.dart'; import '../../../components/spacer/spacer.dart'; class HomeHeader extends StatefulWidget { - const HomeHeader({super.key}); + final int totalRevenue; + const HomeHeader({super.key, required this.totalRevenue}); @override State createState() => _HomeHeaderState(); @@ -467,7 +468,7 @@ class _HomeHeaderState extends State with TickerProviderStateMixin { ), const SizedBox(width: 6), Text( - '${context.lang.sales_today} +25%', + '${context.lang.sales_today} ${widget.totalRevenue.currencyFormatRp}', style: AppStyle.sm.copyWith( color: AppColor.white, fontWeight: FontWeight.w600, diff --git a/lib/presentation/pages/home/widgets/performance.dart b/lib/presentation/pages/home/widgets/performance.dart deleted file mode 100644 index f128625..0000000 --- a/lib/presentation/pages/home/widgets/performance.dart +++ /dev/null @@ -1,281 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../../common/theme/theme.dart'; -import '../../../components/spacer/spacer.dart'; - -class HomePerformance extends StatelessWidget { - const HomePerformance({super.key}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric( - vertical: 24, - horizontal: AppValue.padding, - ).copyWith(bottom: 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Performa Minggu Ini', - style: AppStyle.h6.copyWith( - fontWeight: FontWeight.w700, - color: AppColor.textPrimary, - letterSpacing: -0.5, - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppColor.success.withOpacity(0.1), - AppColor.success.withOpacity(0.05), - ], - ), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: AppColor.success.withOpacity(0.2), - width: 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.arrow_upward_rounded, - color: AppColor.success, - size: 14, - ), - const SpaceWidth(4), - Text( - '89%', - style: AppStyle.sm.copyWith( - color: AppColor.success, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ), - ], - ), - const SpaceHeight(20), - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: AppColor.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColor.border.withOpacity(0.5)), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.04), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], - ), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildPerformanceBar( - 'Sen', - 0.8, - AppColor.primary, - 'Rp 2.1M', - ), - _buildPerformanceBar( - 'Sel', - 0.6, - AppColor.primary, - 'Rp 1.8M', - ), - _buildPerformanceBar( - 'Rab', - 0.9, - AppColor.success, - 'Rp 2.4M', - ), - _buildPerformanceBar( - 'Kam', - 0.7, - AppColor.primary, - 'Rp 1.9M', - ), - _buildPerformanceBar( - 'Jum', - 1.0, - AppColor.success, - 'Rp 2.5M', - ), - _buildPerformanceBar( - 'Sab', - 0.85, - AppColor.success, - 'Rp 2.2M', - ), - _buildPerformanceBar( - 'Min', - 0.4, - AppColor.textLight, - 'Rp 1.2M', - ), - ], - ), - const SpaceHeight(24), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppColor.primary.withOpacity(0.05), - AppColor.primary.withOpacity(0.02), - ], - ), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppColor.primary.withOpacity(0.1), - width: 1, - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Target Minggu Ini', - style: TextStyle( - fontSize: 12, - color: AppColor.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - const SpaceHeight(4), - Text( - 'Rp 15.000.000', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: AppColor.textPrimary, - ), - ), - ], - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - 'Tercapai', - style: TextStyle( - fontSize: 12, - color: AppColor.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - const SpaceHeight(4), - Row( - children: [ - Text( - '89%', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: AppColor.success, - ), - ), - const SpaceWidth(4), - Icon( - Icons.trending_up_rounded, - color: AppColor.success, - size: 16, - ), - ], - ), - ], - ), - ], - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildPerformanceBar( - String day, - double percentage, - Color color, - String amount, - ) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Amount label - Text( - amount, - style: AppStyle.xs.copyWith( - fontSize: 10, - fontWeight: FontWeight.w600, - color: AppColor.textSecondary, - ), - ), - const SpaceHeight(8), - // Performance bar - Container( - height: 80, - width: 12, - decoration: BoxDecoration( - color: AppColor.border.withOpacity(0.3), - borderRadius: BorderRadius.circular(6), - ), - child: Align( - alignment: Alignment.bottomCenter, - child: AnimatedContainer( - duration: Duration(milliseconds: 800 + (day.hashCode % 400)), - curve: Curves.easeOutCubic, - height: 80 * percentage, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [color, color.withOpacity(0.7)], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - borderRadius: BorderRadius.circular(6), - boxShadow: [ - BoxShadow( - color: color.withOpacity(0.3), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - ), - ), - ), - const SpaceHeight(12), - // Day label - Text( - day, - style: AppStyle.xs.copyWith( - color: AppColor.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - ], - ); - } -} diff --git a/lib/presentation/pages/home/widgets/stats.dart b/lib/presentation/pages/home/widgets/stats.dart index 721b78e..6a1f612 100644 --- a/lib/presentation/pages/home/widgets/stats.dart +++ b/lib/presentation/pages/home/widgets/stats.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:line_icons/line_icons.dart'; import '../../../../common/theme/theme.dart'; +import '../../../../domain/analytic/analytic.dart'; import '../../../components/spacer/spacer.dart'; import 'stats_tile.dart'; +import 'title.dart'; class HomeStats extends StatelessWidget { - const HomeStats({super.key}); + final DashboardOverview overview; + const HomeStats({super.key, required this.overview}); @override Widget build(BuildContext context) { @@ -17,74 +21,30 @@ class HomeStats extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Ringkasan Hari Ini', - style: AppStyle.h6.copyWith( - fontWeight: FontWeight.w700, - color: AppColor.textPrimary, - letterSpacing: -0.5, - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: AppColor.success.withOpacity(0.1), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: AppColor.success.withOpacity(0.2), - width: 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.trending_up_rounded, - color: AppColor.success, - size: 14, - ), - const SpaceWidth(4), - Text( - 'Live', - style: AppStyle.sm.copyWith( - color: AppColor.success, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), + HomeTitle(title: 'Ringkasan Hari Ini'), const SpaceHeight(20), Row( children: [ Expanded( child: HomeStatsTile( - title: 'Total Penjualan', - value: 'Rp 2.450.000', - icon: Icons.trending_up_rounded, - color: AppColor.success, - change: '+12%', - subtitle: 'dari kemarin', + title: 'Pesanan', + value: overview.totalOrders.toString(), + icon: Icons.receipt_long_rounded, + color: AppColor.info, + subtitle: 'Hari ini', ), ), const SpaceWidth(16), Expanded( child: HomeStatsTile( - title: 'Transaksi', - value: '85', - icon: Icons.receipt_long_rounded, - color: AppColor.info, - change: '+8%', - subtitle: 'lebih tinggi', + title: 'Pelanggan Baru', + value: overview.totalCustomers.toString(), + icon: Icons.person_add_outlined, + color: AppColor.primary, + subtitle: overview.totalCustomers < 1 + ? 'Hari ini' + : 'bertambah', ), ), ], @@ -94,23 +54,21 @@ class HomeStats extends StatelessWidget { children: [ Expanded( child: HomeStatsTile( - title: 'Profit Bersih', - value: 'Rp 735.000', - icon: Icons.account_balance_wallet_rounded, + title: 'Refund', + value: overview.refundedOrders.toString(), + icon: LineIcons.alternateExchange, color: AppColor.warning, - change: '+15%', - subtitle: 'margin sehat', + subtitle: 'Hari ini', ), ), const SpaceWidth(16), Expanded( child: HomeStatsTile( - title: 'Pelanggan Baru', - value: '42', - icon: Icons.person_add_rounded, - color: AppColor.primary, - change: '+3%', - subtitle: 'bertambah', + title: 'Void', + value: overview.voidedOrders.toString(), + icon: Icons.cancel_rounded, + color: AppColor.error, + subtitle: 'Hari ini', ), ), ], diff --git a/lib/presentation/pages/home/widgets/stats_tile.dart b/lib/presentation/pages/home/widgets/stats_tile.dart index fa61461..054163b 100644 --- a/lib/presentation/pages/home/widgets/stats_tile.dart +++ b/lib/presentation/pages/home/widgets/stats_tile.dart @@ -8,7 +8,6 @@ class HomeStatsTile extends StatelessWidget { final String value; final IconData icon; final Color color; - final String change; final String subtitle; const HomeStatsTile({ super.key, @@ -16,7 +15,6 @@ class HomeStatsTile extends StatelessWidget { required this.value, required this.icon, required this.color, - required this.change, required this.subtitle, }); @@ -56,20 +54,6 @@ class HomeStatsTile extends StatelessWidget { ), child: Icon(icon, color: color, size: 20), ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: AppColor.success.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - change, - style: AppStyle.xs.copyWith( - color: AppColor.success, - fontWeight: FontWeight.w700, - ), - ), - ), ], ), const SpaceHeight(16), diff --git a/lib/presentation/pages/home/widgets/title.dart b/lib/presentation/pages/home/widgets/title.dart new file mode 100644 index 0000000..10cc2e8 --- /dev/null +++ b/lib/presentation/pages/home/widgets/title.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +import '../../../../common/theme/theme.dart'; + +class HomeTitle extends StatelessWidget { + final String title; + const HomeTitle({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: AppStyle.h6.copyWith( + fontWeight: FontWeight.w700, + color: AppColor.textPrimary, + letterSpacing: -0.5, + ), + ), + ], + ); + } +} diff --git a/lib/presentation/pages/home/widgets/top_product.dart b/lib/presentation/pages/home/widgets/top_product.dart new file mode 100644 index 0000000..9e8f436 --- /dev/null +++ b/lib/presentation/pages/home/widgets/top_product.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +import '../../../../common/theme/theme.dart'; +import '../../../../domain/analytic/analytic.dart'; +import '../../../components/spacer/spacer.dart'; +import 'title.dart'; +import 'top_product_tile.dart'; + +class HomeTopProduct extends StatelessWidget { + final List products; + const HomeTopProduct({super.key, required this.products}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 24, + horizontal: AppValue.padding, + ).copyWith(bottom: 0), + child: Column( + children: [ + HomeTitle(title: 'Product Terlaris Hari Ini'), + SpaceHeight(20), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: products.length, + itemBuilder: (context, index) { + return HomeTopProductTile( + product: products[index], + ranking: index + 1, + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/presentation/pages/home/widgets/top_product_tile.dart b/lib/presentation/pages/home/widgets/top_product_tile.dart new file mode 100644 index 0000000..841d68e --- /dev/null +++ b/lib/presentation/pages/home/widgets/top_product_tile.dart @@ -0,0 +1,287 @@ +import 'package:flutter/material.dart'; + +import '../../../../common/extension/extension.dart'; +import '../../../../common/theme/theme.dart'; +import '../../../../domain/analytic/analytic.dart'; + +class HomeTopProductTile extends StatelessWidget { + final DashboardTopProduct product; + final int ranking; + final VoidCallback? onTap; + + const HomeTopProductTile({ + super.key, + required this.product, + required this.ranking, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + child: Material( + elevation: 2, + borderRadius: BorderRadius.circular(16), + shadowColor: AppColor.primary.withOpacity(0.1), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColor.white, AppColor.backgroundLight], + ), + border: Border.all(color: AppColor.borderLight, width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row - Ranking dan Revenue + Row( + children: [ + _buildRankingBadge(), + const Spacer(), + _buildRevenueDisplay(), + ], + ), + + const SizedBox(height: 12), + + // Product Name + Text( + product.productName, + style: AppStyle.lg.copyWith( + fontWeight: FontWeight.w600, + color: AppColor.textPrimary, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + + const SizedBox(height: 8), + + // Category + _buildCategoryChip(), + + const SizedBox(height: 12), + + // Metrics dalam Grid 2x2 + _buildMetricsGrid(), + ], + ), + ), + ), + ), + ); + } + + Widget _buildRankingBadge() { + Color badgeColor; + IconData icon; + + switch (ranking) { + case 1: + badgeColor = const Color(0xFFFFD700); // Gold + icon = Icons.emoji_events; + break; + case 2: + badgeColor = const Color(0xFFC0C0C0); // Silver + icon = Icons.emoji_events; + break; + case 3: + badgeColor = const Color(0xFFCD7F32); // Bronze + icon = Icons.emoji_events; + break; + default: + badgeColor = AppColor.primary; + icon = Icons.star; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: badgeColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: badgeColor.withOpacity(0.3), width: 1.5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: badgeColor, size: 16), + const SizedBox(width: 6), + Text( + 'Rank #$ranking', + style: AppStyle.sm.copyWith( + color: badgeColor, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } + + Widget _buildCategoryChip() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppColor.secondary.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppColor.secondary.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.category_outlined, size: 14, color: AppColor.secondary), + const SizedBox(width: 6), + Flexible( + child: Text( + product.categoryName, + style: AppStyle.sm.copyWith( + color: AppColor.secondary, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + Widget _buildMetricsGrid() { + return Row( + children: [ + Expanded( + child: Column( + children: [ + _buildMetricCard( + icon: Icons.shopping_cart_outlined, + label: 'Quantity Sold', + value: product.quantitySold.toString(), + color: AppColor.info, + ), + const SizedBox(height: 8), + _buildMetricCard( + icon: Icons.attach_money, + label: 'Average Price', + value: product.averagePrice.round().currencyFormatRp, + color: AppColor.success, + ), + ], + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + children: [ + _buildMetricCard( + icon: Icons.receipt_outlined, + label: 'Total Orders', + value: product.orderCount.toString(), + color: AppColor.warning, + ), + const SizedBox(height: 8), + _buildMetricCard( + icon: Icons.trending_up, + label: 'Performance', + value: 'Top $ranking', + color: AppColor.primary, + ), + ], + ), + ), + ], + ); + } + + Widget _buildMetricCard({ + required IconData icon, + required String label, + required String value, + required Color color, + }) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.2), width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Expanded( + child: Text( + label, + style: AppStyle.xs.copyWith( + color: AppColor.textSecondary, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + value, + style: AppStyle.md.copyWith( + color: color, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } + + Widget _buildRevenueDisplay() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: AppColor.primaryGradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: AppColor.primary.withOpacity(0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + product.revenue.currencyFormatRp, + style: AppStyle.md.copyWith( + color: AppColor.white, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } +}