feat: home bloc

This commit is contained in:
efrilm 2025-08-19 12:23:53 +07:00
parent 9b51bf2bee
commit b731704a3d
15 changed files with 934 additions and 664 deletions

View File

@ -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<HomeEvent, HomeState> {
final IAnalyticRepository _analyticRepository;
HomeBloc(this._analyticRepository) : super(HomeState.initial()) {
on<HomeEvent>(_onHomeEvent);
}
Future<void> _onHomeEvent(HomeEvent event, Emitter<HomeState> 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));
},
);
}
}

View File

@ -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>(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<TResult extends Object?>({
required TResult Function() fetchedDashboard,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? fetchedDashboard,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? fetchedDashboard,
required TResult orElse(),
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(_FetchedDashboard value) fetchedDashboard,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(_FetchedDashboard value)? fetchedDashboard,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
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<TResult extends Object?>({
required TResult Function() fetchedDashboard,
}) {
return fetchedDashboard();
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? fetchedDashboard,
}) {
return fetchedDashboard?.call();
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? fetchedDashboard,
required TResult orElse(),
}) {
if (fetchedDashboard != null) {
return fetchedDashboard();
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(_FetchedDashboard value) fetchedDashboard,
}) {
return fetchedDashboard(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(_FetchedDashboard value)? fetchedDashboard,
}) {
return fetchedDashboard?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
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<AnalyticFailure> 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<HomeState> 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<AnalyticFailure> 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<AnalyticFailure>,
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<AnalyticFailure> 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<AnalyticFailure>,
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<AnalyticFailure> 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<AnalyticFailure> failureOptionDashboard,
final bool isFetching,
}) = _$HomeStateImpl;
@override
DashboardAnalytic get dashboard;
@override
Option<AnalyticFailure> 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;
}

View File

@ -0,0 +1,6 @@
part of 'home_bloc.dart';
@freezed
class HomeEvent with _$HomeEvent {
const factory HomeEvent.fetchedDashboard() = _FetchedDashboard;
}

View File

@ -0,0 +1,15 @@
part of 'home_bloc.dart';
@freezed
class HomeState with _$HomeState {
const factory HomeState({
required DashboardAnalytic dashboard,
required Option<AnalyticFailure> failureOptionDashboard,
@Default(false) bool isFetching,
}) = _HomeState;
factory HomeState.initial() => HomeState(
dashboard: DashboardAnalytic.empty(),
failureOptionDashboard: none(),
);
}

View File

@ -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>()),
);

View File

@ -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<HomePage> createState() => _HomePageState();
@override
Widget wrappedRoute(BuildContext context) => BlocProvider(
create: (context) => getIt<HomeBloc>()..add(HomeEvent.fetchedDashboard()),
child: this,
);
}
class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
@ -58,106 +67,119 @@ class _HomePageState extends State<HomePage> 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<HomeBloc, HomeState>(
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),
],
),
),
);
},
),
),
],
);
},
),
);
}

View File

@ -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,
),
],
),
),
],
),
);
}
}

View File

@ -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,
),
),
],
],
),
],
),
);
}
}

View File

@ -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<HomeHeader> createState() => _HomeHeaderState();
@ -467,7 +468,7 @@ class _HomeHeaderState extends State<HomeHeader> 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,

View File

@ -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,
),
),
],
);
}
}

View File

@ -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',
),
),
],

View File

@ -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),

View File

@ -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,
),
),
],
);
}
}

View File

@ -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<DashboardTopProduct> 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,
);
},
),
],
),
);
}
}

View File

@ -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,
),
),
],
),
);
}
}