Compare commits

...

3 Commits

Author SHA1 Message Date
efrilm
65ba81f311 feat: dashboard 2025-08-18 01:50:50 +07:00
efrilm
51289d7829 feat: customer page 2025-08-18 00:30:17 +07:00
efrilm
d22ffdd6d0 feat: inventory 2025-08-17 23:54:28 +07:00
53 changed files with 14181 additions and 1419 deletions

View File

@ -0,0 +1,50 @@
import 'package:bloc/bloc.dart';
import 'package:dartz/dartz.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 'dashboard_analytic_loader_event.dart';
part 'dashboard_analytic_loader_state.dart';
part 'dashboard_analytic_loader_bloc.freezed.dart';
@injectable
class DashboardAnalyticLoaderBloc
extends Bloc<DashboardAnalyticLoaderEvent, DashboardAnalyticLoaderState> {
final IAnalyticRepository _repository;
DashboardAnalyticLoaderBloc(this._repository)
: super(DashboardAnalyticLoaderState.initial()) {
on<DashboardAnalyticLoaderEvent>(_onDashboardAnalyticLoaderEvent);
}
Future<void> _onDashboardAnalyticLoaderEvent(
DashboardAnalyticLoaderEvent event,
Emitter<DashboardAnalyticLoaderState> emit,
) {
return event.map(
fetched: (e) async {
emit(
state.copyWith(
isFetching: true,
failureOptionDashboardAnalytic: none(),
),
);
final result = await _repository.getDashboard(
dateFrom: DateTime.now().subtract(const Duration(days: 30)),
dateTo: DateTime.now(),
);
var data = result.fold(
(f) => state.copyWith(failureOptionDashboardAnalytic: optionOf(f)),
(dashboardAnalytic) =>
state.copyWith(dashboardAnalytic: dashboardAnalytic),
);
emit(data.copyWith(isFetching: false));
},
);
}
}

View File

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

View File

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

View File

@ -0,0 +1,16 @@
part of 'dashboard_analytic_loader_bloc.dart';
@freezed
class DashboardAnalyticLoaderState with _$DashboardAnalyticLoaderState {
const factory DashboardAnalyticLoaderState({
required DashboardAnalytic dashboardAnalytic,
required Option<AnalyticFailure> failureOptionDashboardAnalytic,
@Default(false) bool isFetching,
}) = _DashboardAnalyticLoaderState;
factory DashboardAnalyticLoaderState.initial() =>
DashboardAnalyticLoaderState(
dashboardAnalytic: DashboardAnalytic.empty(),
failureOptionDashboardAnalytic: none(),
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,87 @@
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/customer/customer.dart';
part 'customer_loader_event.dart';
part 'customer_loader_state.dart';
part 'customer_loader_bloc.freezed.dart';
@injectable
class CustomerLoaderBloc
extends Bloc<CustomerLoaderEvent, CustomerLoaderState> {
final ICustomerRepository _repository;
CustomerLoaderBloc(this._repository) : super(CustomerLoaderState.initial()) {
on<CustomerLoaderEvent>(_onCustomerLoaderEvent);
}
Future<void> _onCustomerLoaderEvent(
CustomerLoaderEvent event,
Emitter<CustomerLoaderState> emit,
) {
return event.map(
searchChanged: (e) async {
emit(state.copyWith(search: e.search));
},
fetched: (e) async {
var newState = state;
if (e.isRefresh) {
newState = state.copyWith(isFetching: true);
emit(newState);
}
newState = await _mapFetchedToState(state, isRefresh: e.isRefresh);
emit(newState);
},
);
}
Future<CustomerLoaderState> _mapFetchedToState(
CustomerLoaderState state, {
bool isRefresh = false,
}) async {
state = state.copyWith(isFetching: false);
if (state.hasReachedMax && state.customers.isNotEmpty && !isRefresh) {
return state;
}
if (isRefresh) {
state = state.copyWith(
page: 1,
failureOptionCustomer: none(),
hasReachedMax: false,
customers: [],
);
}
final failureOrCustomer = await _repository.get(
page: state.page,
search: state.search,
);
state = failureOrCustomer.fold(
(f) {
if (state.customers.isNotEmpty) {
return state.copyWith(hasReachedMax: true);
}
return state.copyWith(failureOptionCustomer: optionOf(f));
},
(customers) {
return state.copyWith(
customers: List.from(state.customers)..addAll(customers),
failureOptionCustomer: none(),
page: state.page + 1,
hasReachedMax: customers.length < 10,
);
},
);
return state;
}
}

View File

@ -0,0 +1,653 @@
// 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 'customer_loader_bloc.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
/// @nodoc
mixin _$CustomerLoaderEvent {
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(String search) searchChanged,
required TResult Function(bool isRefresh) fetched,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(String search)? searchChanged,
TResult? Function(bool isRefresh)? fetched,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(String search)? searchChanged,
TResult Function(bool isRefresh)? fetched,
required TResult orElse(),
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(_SearchChanged value) searchChanged,
required TResult Function(_Fetched value) fetched,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(_SearchChanged value)? searchChanged,
TResult? Function(_Fetched value)? fetched,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(_SearchChanged value)? searchChanged,
TResult Function(_Fetched value)? fetched,
required TResult orElse(),
}) => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $CustomerLoaderEventCopyWith<$Res> {
factory $CustomerLoaderEventCopyWith(
CustomerLoaderEvent value,
$Res Function(CustomerLoaderEvent) then,
) = _$CustomerLoaderEventCopyWithImpl<$Res, CustomerLoaderEvent>;
}
/// @nodoc
class _$CustomerLoaderEventCopyWithImpl<$Res, $Val extends CustomerLoaderEvent>
implements $CustomerLoaderEventCopyWith<$Res> {
_$CustomerLoaderEventCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of CustomerLoaderEvent
/// with the given fields replaced by the non-null parameter values.
}
/// @nodoc
abstract class _$$SearchChangedImplCopyWith<$Res> {
factory _$$SearchChangedImplCopyWith(
_$SearchChangedImpl value,
$Res Function(_$SearchChangedImpl) then,
) = __$$SearchChangedImplCopyWithImpl<$Res>;
@useResult
$Res call({String search});
}
/// @nodoc
class __$$SearchChangedImplCopyWithImpl<$Res>
extends _$CustomerLoaderEventCopyWithImpl<$Res, _$SearchChangedImpl>
implements _$$SearchChangedImplCopyWith<$Res> {
__$$SearchChangedImplCopyWithImpl(
_$SearchChangedImpl _value,
$Res Function(_$SearchChangedImpl) _then,
) : super(_value, _then);
/// Create a copy of CustomerLoaderEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({Object? search = null}) {
return _then(
_$SearchChangedImpl(
null == search
? _value.search
: search // ignore: cast_nullable_to_non_nullable
as String,
),
);
}
}
/// @nodoc
class _$SearchChangedImpl implements _SearchChanged {
const _$SearchChangedImpl(this.search);
@override
final String search;
@override
String toString() {
return 'CustomerLoaderEvent.searchChanged(search: $search)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SearchChangedImpl &&
(identical(other.search, search) || other.search == search));
}
@override
int get hashCode => Object.hash(runtimeType, search);
/// Create a copy of CustomerLoaderEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SearchChangedImplCopyWith<_$SearchChangedImpl> get copyWith =>
__$$SearchChangedImplCopyWithImpl<_$SearchChangedImpl>(this, _$identity);
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(String search) searchChanged,
required TResult Function(bool isRefresh) fetched,
}) {
return searchChanged(search);
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(String search)? searchChanged,
TResult? Function(bool isRefresh)? fetched,
}) {
return searchChanged?.call(search);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(String search)? searchChanged,
TResult Function(bool isRefresh)? fetched,
required TResult orElse(),
}) {
if (searchChanged != null) {
return searchChanged(search);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(_SearchChanged value) searchChanged,
required TResult Function(_Fetched value) fetched,
}) {
return searchChanged(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(_SearchChanged value)? searchChanged,
TResult? Function(_Fetched value)? fetched,
}) {
return searchChanged?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(_SearchChanged value)? searchChanged,
TResult Function(_Fetched value)? fetched,
required TResult orElse(),
}) {
if (searchChanged != null) {
return searchChanged(this);
}
return orElse();
}
}
abstract class _SearchChanged implements CustomerLoaderEvent {
const factory _SearchChanged(final String search) = _$SearchChangedImpl;
String get search;
/// Create a copy of CustomerLoaderEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SearchChangedImplCopyWith<_$SearchChangedImpl> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class _$$FetchedImplCopyWith<$Res> {
factory _$$FetchedImplCopyWith(
_$FetchedImpl value,
$Res Function(_$FetchedImpl) then,
) = __$$FetchedImplCopyWithImpl<$Res>;
@useResult
$Res call({bool isRefresh});
}
/// @nodoc
class __$$FetchedImplCopyWithImpl<$Res>
extends _$CustomerLoaderEventCopyWithImpl<$Res, _$FetchedImpl>
implements _$$FetchedImplCopyWith<$Res> {
__$$FetchedImplCopyWithImpl(
_$FetchedImpl _value,
$Res Function(_$FetchedImpl) _then,
) : super(_value, _then);
/// Create a copy of CustomerLoaderEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({Object? isRefresh = null}) {
return _then(
_$FetchedImpl(
isRefresh: null == isRefresh
? _value.isRefresh
: isRefresh // ignore: cast_nullable_to_non_nullable
as bool,
),
);
}
}
/// @nodoc
class _$FetchedImpl implements _Fetched {
const _$FetchedImpl({this.isRefresh = false});
@override
@JsonKey()
final bool isRefresh;
@override
String toString() {
return 'CustomerLoaderEvent.fetched(isRefresh: $isRefresh)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$FetchedImpl &&
(identical(other.isRefresh, isRefresh) ||
other.isRefresh == isRefresh));
}
@override
int get hashCode => Object.hash(runtimeType, isRefresh);
/// Create a copy of CustomerLoaderEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$FetchedImplCopyWith<_$FetchedImpl> get copyWith =>
__$$FetchedImplCopyWithImpl<_$FetchedImpl>(this, _$identity);
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(String search) searchChanged,
required TResult Function(bool isRefresh) fetched,
}) {
return fetched(isRefresh);
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(String search)? searchChanged,
TResult? Function(bool isRefresh)? fetched,
}) {
return fetched?.call(isRefresh);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(String search)? searchChanged,
TResult Function(bool isRefresh)? fetched,
required TResult orElse(),
}) {
if (fetched != null) {
return fetched(isRefresh);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(_SearchChanged value) searchChanged,
required TResult Function(_Fetched value) fetched,
}) {
return fetched(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(_SearchChanged value)? searchChanged,
TResult? Function(_Fetched value)? fetched,
}) {
return fetched?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(_SearchChanged value)? searchChanged,
TResult Function(_Fetched value)? fetched,
required TResult orElse(),
}) {
if (fetched != null) {
return fetched(this);
}
return orElse();
}
}
abstract class _Fetched implements CustomerLoaderEvent {
const factory _Fetched({final bool isRefresh}) = _$FetchedImpl;
bool get isRefresh;
/// Create a copy of CustomerLoaderEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
_$$FetchedImplCopyWith<_$FetchedImpl> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
mixin _$CustomerLoaderState {
List<Customer> get customers => throw _privateConstructorUsedError;
Option<CustomerFailure> get failureOptionCustomer =>
throw _privateConstructorUsedError;
String? get categoryId => throw _privateConstructorUsedError;
String? get search => throw _privateConstructorUsedError;
bool get isFetching => throw _privateConstructorUsedError;
bool get hasReachedMax => throw _privateConstructorUsedError;
int get page => throw _privateConstructorUsedError;
/// Create a copy of CustomerLoaderState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$CustomerLoaderStateCopyWith<CustomerLoaderState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $CustomerLoaderStateCopyWith<$Res> {
factory $CustomerLoaderStateCopyWith(
CustomerLoaderState value,
$Res Function(CustomerLoaderState) then,
) = _$CustomerLoaderStateCopyWithImpl<$Res, CustomerLoaderState>;
@useResult
$Res call({
List<Customer> customers,
Option<CustomerFailure> failureOptionCustomer,
String? categoryId,
String? search,
bool isFetching,
bool hasReachedMax,
int page,
});
}
/// @nodoc
class _$CustomerLoaderStateCopyWithImpl<$Res, $Val extends CustomerLoaderState>
implements $CustomerLoaderStateCopyWith<$Res> {
_$CustomerLoaderStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of CustomerLoaderState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? customers = null,
Object? failureOptionCustomer = null,
Object? categoryId = freezed,
Object? search = freezed,
Object? isFetching = null,
Object? hasReachedMax = null,
Object? page = null,
}) {
return _then(
_value.copyWith(
customers: null == customers
? _value.customers
: customers // ignore: cast_nullable_to_non_nullable
as List<Customer>,
failureOptionCustomer: null == failureOptionCustomer
? _value.failureOptionCustomer
: failureOptionCustomer // ignore: cast_nullable_to_non_nullable
as Option<CustomerFailure>,
categoryId: freezed == categoryId
? _value.categoryId
: categoryId // ignore: cast_nullable_to_non_nullable
as String?,
search: freezed == search
? _value.search
: search // ignore: cast_nullable_to_non_nullable
as String?,
isFetching: null == isFetching
? _value.isFetching
: isFetching // ignore: cast_nullable_to_non_nullable
as bool,
hasReachedMax: null == hasReachedMax
? _value.hasReachedMax
: hasReachedMax // ignore: cast_nullable_to_non_nullable
as bool,
page: null == page
? _value.page
: page // ignore: cast_nullable_to_non_nullable
as int,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$CustomerLoaderStateImplCopyWith<$Res>
implements $CustomerLoaderStateCopyWith<$Res> {
factory _$$CustomerLoaderStateImplCopyWith(
_$CustomerLoaderStateImpl value,
$Res Function(_$CustomerLoaderStateImpl) then,
) = __$$CustomerLoaderStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
List<Customer> customers,
Option<CustomerFailure> failureOptionCustomer,
String? categoryId,
String? search,
bool isFetching,
bool hasReachedMax,
int page,
});
}
/// @nodoc
class __$$CustomerLoaderStateImplCopyWithImpl<$Res>
extends _$CustomerLoaderStateCopyWithImpl<$Res, _$CustomerLoaderStateImpl>
implements _$$CustomerLoaderStateImplCopyWith<$Res> {
__$$CustomerLoaderStateImplCopyWithImpl(
_$CustomerLoaderStateImpl _value,
$Res Function(_$CustomerLoaderStateImpl) _then,
) : super(_value, _then);
/// Create a copy of CustomerLoaderState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? customers = null,
Object? failureOptionCustomer = null,
Object? categoryId = freezed,
Object? search = freezed,
Object? isFetching = null,
Object? hasReachedMax = null,
Object? page = null,
}) {
return _then(
_$CustomerLoaderStateImpl(
customers: null == customers
? _value._customers
: customers // ignore: cast_nullable_to_non_nullable
as List<Customer>,
failureOptionCustomer: null == failureOptionCustomer
? _value.failureOptionCustomer
: failureOptionCustomer // ignore: cast_nullable_to_non_nullable
as Option<CustomerFailure>,
categoryId: freezed == categoryId
? _value.categoryId
: categoryId // ignore: cast_nullable_to_non_nullable
as String?,
search: freezed == search
? _value.search
: search // ignore: cast_nullable_to_non_nullable
as String?,
isFetching: null == isFetching
? _value.isFetching
: isFetching // ignore: cast_nullable_to_non_nullable
as bool,
hasReachedMax: null == hasReachedMax
? _value.hasReachedMax
: hasReachedMax // ignore: cast_nullable_to_non_nullable
as bool,
page: null == page
? _value.page
: page // ignore: cast_nullable_to_non_nullable
as int,
),
);
}
}
/// @nodoc
class _$CustomerLoaderStateImpl implements _CustomerLoaderState {
const _$CustomerLoaderStateImpl({
required final List<Customer> customers,
required this.failureOptionCustomer,
this.categoryId,
this.search,
this.isFetching = false,
this.hasReachedMax = false,
this.page = 1,
}) : _customers = customers;
final List<Customer> _customers;
@override
List<Customer> get customers {
if (_customers is EqualUnmodifiableListView) return _customers;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_customers);
}
@override
final Option<CustomerFailure> failureOptionCustomer;
@override
final String? categoryId;
@override
final String? search;
@override
@JsonKey()
final bool isFetching;
@override
@JsonKey()
final bool hasReachedMax;
@override
@JsonKey()
final int page;
@override
String toString() {
return 'CustomerLoaderState(customers: $customers, failureOptionCustomer: $failureOptionCustomer, categoryId: $categoryId, search: $search, isFetching: $isFetching, hasReachedMax: $hasReachedMax, page: $page)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$CustomerLoaderStateImpl &&
const DeepCollectionEquality().equals(
other._customers,
_customers,
) &&
(identical(other.failureOptionCustomer, failureOptionCustomer) ||
other.failureOptionCustomer == failureOptionCustomer) &&
(identical(other.categoryId, categoryId) ||
other.categoryId == categoryId) &&
(identical(other.search, search) || other.search == search) &&
(identical(other.isFetching, isFetching) ||
other.isFetching == isFetching) &&
(identical(other.hasReachedMax, hasReachedMax) ||
other.hasReachedMax == hasReachedMax) &&
(identical(other.page, page) || other.page == page));
}
@override
int get hashCode => Object.hash(
runtimeType,
const DeepCollectionEquality().hash(_customers),
failureOptionCustomer,
categoryId,
search,
isFetching,
hasReachedMax,
page,
);
/// Create a copy of CustomerLoaderState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$CustomerLoaderStateImplCopyWith<_$CustomerLoaderStateImpl> get copyWith =>
__$$CustomerLoaderStateImplCopyWithImpl<_$CustomerLoaderStateImpl>(
this,
_$identity,
);
}
abstract class _CustomerLoaderState implements CustomerLoaderState {
const factory _CustomerLoaderState({
required final List<Customer> customers,
required final Option<CustomerFailure> failureOptionCustomer,
final String? categoryId,
final String? search,
final bool isFetching,
final bool hasReachedMax,
final int page,
}) = _$CustomerLoaderStateImpl;
@override
List<Customer> get customers;
@override
Option<CustomerFailure> get failureOptionCustomer;
@override
String? get categoryId;
@override
String? get search;
@override
bool get isFetching;
@override
bool get hasReachedMax;
@override
int get page;
/// Create a copy of CustomerLoaderState
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$CustomerLoaderStateImplCopyWith<_$CustomerLoaderStateImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,9 @@
part of 'customer_loader_bloc.dart';
@freezed
class CustomerLoaderEvent with _$CustomerLoaderEvent {
const factory CustomerLoaderEvent.searchChanged(String search) =
_SearchChanged;
const factory CustomerLoaderEvent.fetched({@Default(false) bool isRefresh}) =
_Fetched;
}

View File

@ -0,0 +1,17 @@
part of 'customer_loader_bloc.dart';
@freezed
class CustomerLoaderState with _$CustomerLoaderState {
const factory CustomerLoaderState({
required List<Customer> customers,
required Option<CustomerFailure> failureOptionCustomer,
String? categoryId,
String? search,
@Default(false) bool isFetching,
@Default(false) bool hasReachedMax,
@Default(1) int page,
}) = _CustomerLoaderState;
factory CustomerLoaderState.initial() =>
CustomerLoaderState(customers: [], failureOptionCustomer: none());
}

View File

@ -7,10 +7,18 @@ class ApiPath {
static const String salesAnalytic = '/api/v1/analytics/sales';
static const String profitLossAnalytic = '/api/v1/analytics/profit-loss';
static const String categoryAnalytic = '/api/v1/analytics/categories';
static const String dashboardAnalytic = '/api/v1/analytics/dashboard';
// Inventory
static const String inventoryReportDetail =
'/api/v1/inventory/report/details';
// Category
static const String category = '/api/v1/categories';
// Product
static const String product = '/api/v1/products';
// Customer
static const String customer = '/api/v1/customers';
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,118 @@
part of '../analytic.dart';
@freezed
class DashboardAnalytic with _$DashboardAnalytic {
const factory DashboardAnalytic({
required String organizationId,
required String outletId,
required String dateFrom,
required String dateTo,
required DashboardOverview overview,
required List<DashboardTopProduct> topProducts,
required List<DashboardPaymentMethod> paymentMethods,
required List<DashboardRecentSale> recentSales,
}) = _DashboardAnalytic;
factory DashboardAnalytic.empty() => DashboardAnalytic(
organizationId: '',
outletId: '',
dateFrom: '',
dateTo: '',
overview: DashboardOverview.empty(),
topProducts: const [],
paymentMethods: const [],
recentSales: const [],
);
}
@freezed
class DashboardOverview with _$DashboardOverview {
const factory DashboardOverview({
required int totalSales,
required int totalOrders,
required double averageOrderValue,
required int totalCustomers,
required int voidedOrders,
required int refundedOrders,
}) = _DashboardOverview;
factory DashboardOverview.empty() => const DashboardOverview(
totalSales: 0,
totalOrders: 0,
averageOrderValue: 0.0,
totalCustomers: 0,
voidedOrders: 0,
refundedOrders: 0,
);
}
@freezed
class DashboardTopProduct with _$DashboardTopProduct {
const factory DashboardTopProduct({
required String productId,
required String productName,
required String categoryId,
required String categoryName,
required int quantitySold,
required int revenue,
required double averagePrice,
required int orderCount,
}) = _DashboardTopProduct;
factory DashboardTopProduct.empty() => const DashboardTopProduct(
productId: '',
productName: '',
categoryId: '',
categoryName: '',
quantitySold: 0,
revenue: 0,
averagePrice: 0.0,
orderCount: 0,
);
}
@freezed
class DashboardPaymentMethod with _$DashboardPaymentMethod {
const factory DashboardPaymentMethod({
required String paymentMethodId,
required String paymentMethodName,
required String paymentMethodType,
required int totalAmount,
required int orderCount,
required int paymentCount,
required double percentage,
}) = _DashboardPaymentMethod;
factory DashboardPaymentMethod.empty() => const DashboardPaymentMethod(
paymentMethodId: '',
paymentMethodName: '',
paymentMethodType: '',
totalAmount: 0,
orderCount: 0,
paymentCount: 0,
percentage: 0.0,
);
}
@freezed
class DashboardRecentSale with _$DashboardRecentSale {
const factory DashboardRecentSale({
required String date,
required int sales,
required int orders,
required int items,
required int tax,
required int discount,
required int netSales,
}) = _DashboardRecentSale;
factory DashboardRecentSale.empty() => const DashboardRecentSale(
date: '',
sales: 0,
orders: 0,
items: 0,
tax: 0,
discount: 0,
netSales: 0,
);
}

View File

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

View File

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

View File

@ -0,0 +1,9 @@
import 'package:dartz/dartz.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../common/api/api_failure.dart';
part 'customer.freezed.dart';
part 'entities/customer_entity.dart';
part 'failures/customer_failure.dart';
part 'repositories/i_customer_repository.dart';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,32 @@
part of '../customer.dart';
@freezed
class Customer with _$Customer {
const factory Customer({
required String id,
required String organizationId,
required String name,
required String email,
required String phone,
required String address,
required bool isDefault,
required bool isActive,
required Map<String, dynamic> metadata,
required String createdAt,
required String updatedAt,
}) = _Customer;
factory Customer.empty() => Customer(
id: '',
organizationId: '',
name: '',
email: '',
phone: '',
address: '',
isDefault: false,
isActive: false,
metadata: const {},
createdAt: '',
updatedAt: '',
);
}

View File

@ -0,0 +1,10 @@
part of '../customer.dart';
@freezed
sealed class CustomerFailure with _$CustomerFailure {
const factory CustomerFailure.serverError(ApiFailure failure) = _ServerError;
const factory CustomerFailure.unexpectedError() = _UnexpectedError;
const factory CustomerFailure.empty() = _Empty;
const factory CustomerFailure.dynamicErrorMessage(String erroMessage) =
_DynamicErrorMessage;
}

View File

@ -0,0 +1,9 @@
part of '../customer.dart';
abstract class ICustomerRepository {
Future<Either<CustomerFailure, List<Customer>>> get({
int page = 1,
int limit = 20,
String? search,
});
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -257,3 +257,263 @@ Map<String, dynamic> _$$CategoryAnalyticItemDtoImplToJson(
'product_count': instance.productCount,
'order_count': instance.orderCount,
};
_$InventoryAnalyticDtoImpl _$$InventoryAnalyticDtoImplFromJson(
Map<String, dynamic> json,
) => _$InventoryAnalyticDtoImpl(
summary: json['summary'] == null
? null
: InventorySummaryDto.fromJson(json['summary'] as Map<String, dynamic>),
products: (json['products'] as List<dynamic>?)
?.map((e) => InventoryProductDto.fromJson(e as Map<String, dynamic>))
.toList(),
ingredients: (json['ingredients'] as List<dynamic>?)
?.map((e) => InventoryIngredientDto.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$$InventoryAnalyticDtoImplToJson(
_$InventoryAnalyticDtoImpl instance,
) => <String, dynamic>{
'summary': instance.summary,
'products': instance.products,
'ingredients': instance.ingredients,
};
_$InventorySummaryDtoImpl _$$InventorySummaryDtoImplFromJson(
Map<String, dynamic> json,
) => _$InventorySummaryDtoImpl(
totalProducts: (json['total_products'] as num?)?.toInt(),
totalIngredients: (json['total_ingredients'] as num?)?.toInt(),
totalValue: (json['total_value'] as num?)?.toInt(),
lowStockProducts: (json['low_stock_products'] as num?)?.toInt(),
lowStockIngredients: (json['low_stock_ingredients'] as num?)?.toInt(),
zeroStockProducts: (json['zero_stock_products'] as num?)?.toInt(),
zeroStockIngredients: (json['zero_stock_ingredients'] as num?)?.toInt(),
totalSoldProducts: (json['total_sold_products'] as num?)?.toInt(),
totalSoldIngredients: (json['total_sold_ingredients'] as num?)?.toInt(),
outletId: json['outlet_id'] as String?,
outletName: json['outlet_name'] as String?,
generatedAt: json['generated_at'] as String?,
);
Map<String, dynamic> _$$InventorySummaryDtoImplToJson(
_$InventorySummaryDtoImpl instance,
) => <String, dynamic>{
'total_products': instance.totalProducts,
'total_ingredients': instance.totalIngredients,
'total_value': instance.totalValue,
'low_stock_products': instance.lowStockProducts,
'low_stock_ingredients': instance.lowStockIngredients,
'zero_stock_products': instance.zeroStockProducts,
'zero_stock_ingredients': instance.zeroStockIngredients,
'total_sold_products': instance.totalSoldProducts,
'total_sold_ingredients': instance.totalSoldIngredients,
'outlet_id': instance.outletId,
'outlet_name': instance.outletName,
'generated_at': instance.generatedAt,
};
_$InventoryProductDtoImpl _$$InventoryProductDtoImplFromJson(
Map<String, dynamic> json,
) => _$InventoryProductDtoImpl(
id: json['id'] as String?,
productId: json['product_id'] as String?,
productName: json['product_name'] as String?,
categoryName: json['category_name'] as String?,
quantity: (json['quantity'] as num?)?.toInt(),
reorderLevel: (json['reorder_level'] as num?)?.toInt(),
unitCost: (json['unit_cost'] as num?)?.toInt(),
totalValue: (json['total_value'] as num?)?.toInt(),
totalIn: (json['total_in'] as num?)?.toInt(),
totalOut: (json['total_out'] as num?)?.toInt(),
isLowStock: json['is_low_stock'] as bool?,
isZeroStock: json['is_zero_stock'] as bool?,
updatedAt: json['updated_at'] as String?,
);
Map<String, dynamic> _$$InventoryProductDtoImplToJson(
_$InventoryProductDtoImpl instance,
) => <String, dynamic>{
'id': instance.id,
'product_id': instance.productId,
'product_name': instance.productName,
'category_name': instance.categoryName,
'quantity': instance.quantity,
'reorder_level': instance.reorderLevel,
'unit_cost': instance.unitCost,
'total_value': instance.totalValue,
'total_in': instance.totalIn,
'total_out': instance.totalOut,
'is_low_stock': instance.isLowStock,
'is_zero_stock': instance.isZeroStock,
'updated_at': instance.updatedAt,
};
_$InventoryIngredientDtoImpl _$$InventoryIngredientDtoImplFromJson(
Map<String, dynamic> json,
) => _$InventoryIngredientDtoImpl(
id: json['id'] as String?,
ingredientId: json['ingredient_id'] as String?,
ingredientName: json['ingredient_name'] as String?,
unitName: json['unit_name'] as String?,
quantity: (json['quantity'] as num?)?.toInt(),
reorderLevel: (json['reorder_level'] as num?)?.toInt(),
unitCost: (json['unit_cost'] as num?)?.toInt(),
totalValue: (json['total_value'] as num?)?.toInt(),
totalIn: (json['total_in'] as num?)?.toInt(),
totalOut: (json['total_out'] as num?)?.toInt(),
isLowStock: json['is_low_stock'] as bool?,
isZeroStock: json['is_zero_stock'] as bool?,
updatedAt: json['updated_at'] as String?,
);
Map<String, dynamic> _$$InventoryIngredientDtoImplToJson(
_$InventoryIngredientDtoImpl instance,
) => <String, dynamic>{
'id': instance.id,
'ingredient_id': instance.ingredientId,
'ingredient_name': instance.ingredientName,
'unit_name': instance.unitName,
'quantity': instance.quantity,
'reorder_level': instance.reorderLevel,
'unit_cost': instance.unitCost,
'total_value': instance.totalValue,
'total_in': instance.totalIn,
'total_out': instance.totalOut,
'is_low_stock': instance.isLowStock,
'is_zero_stock': instance.isZeroStock,
'updated_at': instance.updatedAt,
};
_$DashboardAnalyticDtoImpl _$$DashboardAnalyticDtoImplFromJson(
Map<String, dynamic> json,
) => _$DashboardAnalyticDtoImpl(
organizationId: json['organization_id'] as String?,
outletId: json['outlet_id'] as String?,
dateFrom: json['date_from'] as String?,
dateTo: json['date_to'] as String?,
overview: json['overview'] == null
? null
: DashboardOverviewDto.fromJson(json['overview'] as Map<String, dynamic>),
topProducts: (json['top_products'] as List<dynamic>?)
?.map((e) => DashboardTopProductDto.fromJson(e as Map<String, dynamic>))
.toList(),
paymentMethods: (json['payment_methods'] as List<dynamic>?)
?.map(
(e) => DashboardPaymentMethodDto.fromJson(e as Map<String, dynamic>),
)
.toList(),
recentSales: (json['recent_sales'] as List<dynamic>?)
?.map((e) => DashboardRecentSaleDto.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$$DashboardAnalyticDtoImplToJson(
_$DashboardAnalyticDtoImpl instance,
) => <String, dynamic>{
'organization_id': instance.organizationId,
'outlet_id': instance.outletId,
'date_from': instance.dateFrom,
'date_to': instance.dateTo,
'overview': instance.overview,
'top_products': instance.topProducts,
'payment_methods': instance.paymentMethods,
'recent_sales': instance.recentSales,
};
_$DashboardOverviewDtoImpl _$$DashboardOverviewDtoImplFromJson(
Map<String, dynamic> json,
) => _$DashboardOverviewDtoImpl(
totalSales: (json['total_sales'] as num?)?.toInt(),
totalOrders: (json['total_orders'] as num?)?.toInt(),
averageOrderValue: (json['average_order_value'] as num?)?.toDouble(),
totalCustomers: (json['total_customers'] as num?)?.toInt(),
voidedOrders: (json['voided_orders'] as num?)?.toInt(),
refundedOrders: (json['refunded_orders'] as num?)?.toInt(),
);
Map<String, dynamic> _$$DashboardOverviewDtoImplToJson(
_$DashboardOverviewDtoImpl instance,
) => <String, dynamic>{
'total_sales': instance.totalSales,
'total_orders': instance.totalOrders,
'average_order_value': instance.averageOrderValue,
'total_customers': instance.totalCustomers,
'voided_orders': instance.voidedOrders,
'refunded_orders': instance.refundedOrders,
};
_$DashboardTopProductDtoImpl _$$DashboardTopProductDtoImplFromJson(
Map<String, dynamic> json,
) => _$DashboardTopProductDtoImpl(
productId: json['product_id'] as String?,
productName: json['product_name'] as String?,
categoryId: json['category_id'] as String?,
categoryName: json['category_name'] as String?,
quantitySold: (json['quantity_sold'] as num?)?.toInt(),
revenue: (json['revenue'] as num?)?.toInt(),
averagePrice: (json['average_price'] as num?)?.toDouble(),
orderCount: (json['order_count'] as num?)?.toInt(),
);
Map<String, dynamic> _$$DashboardTopProductDtoImplToJson(
_$DashboardTopProductDtoImpl instance,
) => <String, dynamic>{
'product_id': instance.productId,
'product_name': instance.productName,
'category_id': instance.categoryId,
'category_name': instance.categoryName,
'quantity_sold': instance.quantitySold,
'revenue': instance.revenue,
'average_price': instance.averagePrice,
'order_count': instance.orderCount,
};
_$DashboardPaymentMethodDtoImpl _$$DashboardPaymentMethodDtoImplFromJson(
Map<String, dynamic> json,
) => _$DashboardPaymentMethodDtoImpl(
paymentMethodId: json['payment_method_id'] as String?,
paymentMethodName: json['payment_method_name'] as String?,
paymentMethodType: json['payment_method_type'] as String?,
totalAmount: (json['total_amount'] as num?)?.toInt(),
orderCount: (json['order_count'] as num?)?.toInt(),
paymentCount: (json['payment_count'] as num?)?.toInt(),
percentage: (json['percentage'] as num?)?.toDouble(),
);
Map<String, dynamic> _$$DashboardPaymentMethodDtoImplToJson(
_$DashboardPaymentMethodDtoImpl instance,
) => <String, dynamic>{
'payment_method_id': instance.paymentMethodId,
'payment_method_name': instance.paymentMethodName,
'payment_method_type': instance.paymentMethodType,
'total_amount': instance.totalAmount,
'order_count': instance.orderCount,
'payment_count': instance.paymentCount,
'percentage': instance.percentage,
};
_$DashboardRecentSaleDtoImpl _$$DashboardRecentSaleDtoImplFromJson(
Map<String, dynamic> json,
) => _$DashboardRecentSaleDtoImpl(
date: json['date'] as String?,
sales: (json['sales'] as num?)?.toInt(),
orders: (json['orders'] as num?)?.toInt(),
items: (json['items'] as num?)?.toInt(),
tax: (json['tax'] as num?)?.toInt(),
discount: (json['discount'] as num?)?.toInt(),
netSales: (json['net_sales'] as num?)?.toInt(),
);
Map<String, dynamic> _$$DashboardRecentSaleDtoImplToJson(
_$DashboardRecentSaleDtoImpl instance,
) => <String, dynamic>{
'date': instance.date,
'sales': instance.sales,
'orders': instance.orders,
'items': instance.items,
'tax': instance.tax,
'discount': instance.discount,
'net_sales': instance.netSales,
};

View File

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

View File

@ -0,0 +1,145 @@
part of '../analytic_dtos.dart';
@freezed
class DashboardAnalyticDto with _$DashboardAnalyticDto {
const DashboardAnalyticDto._();
const factory DashboardAnalyticDto({
@JsonKey(name: 'organization_id') String? organizationId,
@JsonKey(name: 'outlet_id') String? outletId,
@JsonKey(name: 'date_from') String? dateFrom,
@JsonKey(name: 'date_to') String? dateTo,
@JsonKey(name: 'overview') DashboardOverviewDto? overview,
@JsonKey(name: 'top_products') List<DashboardTopProductDto>? topProducts,
@JsonKey(name: 'payment_methods')
List<DashboardPaymentMethodDto>? paymentMethods,
@JsonKey(name: 'recent_sales') List<DashboardRecentSaleDto>? recentSales,
}) = _DashboardAnalyticDto;
factory DashboardAnalyticDto.fromJson(Map<String, dynamic> json) =>
_$DashboardAnalyticDtoFromJson(json);
DashboardAnalytic toDomain() => DashboardAnalytic(
organizationId: organizationId ?? '',
outletId: outletId ?? '',
dateFrom: dateFrom ?? '',
dateTo: dateTo ?? '',
overview: overview?.toDomain() ?? DashboardOverview.empty(),
topProducts: topProducts?.map((e) => e.toDomain()).toList() ?? [],
paymentMethods: paymentMethods?.map((e) => e.toDomain()).toList() ?? [],
recentSales: recentSales?.map((e) => e.toDomain()).toList() ?? [],
);
}
@freezed
class DashboardOverviewDto with _$DashboardOverviewDto {
const DashboardOverviewDto._();
const factory DashboardOverviewDto({
@JsonKey(name: 'total_sales') int? totalSales,
@JsonKey(name: 'total_orders') int? totalOrders,
@JsonKey(name: 'average_order_value') double? averageOrderValue,
@JsonKey(name: 'total_customers') int? totalCustomers,
@JsonKey(name: 'voided_orders') int? voidedOrders,
@JsonKey(name: 'refunded_orders') int? refundedOrders,
}) = _DashboardOverviewDto;
factory DashboardOverviewDto.fromJson(Map<String, dynamic> json) =>
_$DashboardOverviewDtoFromJson(json);
DashboardOverview toDomain() => DashboardOverview(
totalSales: totalSales ?? 0,
totalOrders: totalOrders ?? 0,
averageOrderValue: averageOrderValue ?? 0.0,
totalCustomers: totalCustomers ?? 0,
voidedOrders: voidedOrders ?? 0,
refundedOrders: refundedOrders ?? 0,
);
}
@freezed
class DashboardTopProductDto with _$DashboardTopProductDto {
const DashboardTopProductDto._();
const factory DashboardTopProductDto({
@JsonKey(name: 'product_id') String? productId,
@JsonKey(name: 'product_name') String? productName,
@JsonKey(name: 'category_id') String? categoryId,
@JsonKey(name: 'category_name') String? categoryName,
@JsonKey(name: 'quantity_sold') int? quantitySold,
@JsonKey(name: 'revenue') int? revenue,
@JsonKey(name: 'average_price') double? averagePrice,
@JsonKey(name: 'order_count') int? orderCount,
}) = _DashboardTopProductDto;
factory DashboardTopProductDto.fromJson(Map<String, dynamic> json) =>
_$DashboardTopProductDtoFromJson(json);
DashboardTopProduct toDomain() => DashboardTopProduct(
productId: productId ?? '',
productName: productName ?? '',
categoryId: categoryId ?? '',
categoryName: categoryName ?? '',
quantitySold: quantitySold ?? 0,
revenue: revenue ?? 0,
averagePrice: averagePrice ?? 0.0,
orderCount: orderCount ?? 0,
);
}
@freezed
class DashboardPaymentMethodDto with _$DashboardPaymentMethodDto {
const DashboardPaymentMethodDto._();
const factory DashboardPaymentMethodDto({
@JsonKey(name: 'payment_method_id') String? paymentMethodId,
@JsonKey(name: 'payment_method_name') String? paymentMethodName,
@JsonKey(name: 'payment_method_type') String? paymentMethodType,
@JsonKey(name: 'total_amount') int? totalAmount,
@JsonKey(name: 'order_count') int? orderCount,
@JsonKey(name: 'payment_count') int? paymentCount,
@JsonKey(name: 'percentage') double? percentage,
}) = _DashboardPaymentMethodDto;
factory DashboardPaymentMethodDto.fromJson(Map<String, dynamic> json) =>
_$DashboardPaymentMethodDtoFromJson(json);
DashboardPaymentMethod toDomain() => DashboardPaymentMethod(
paymentMethodId: paymentMethodId ?? '',
paymentMethodName: paymentMethodName ?? '',
paymentMethodType: paymentMethodType ?? '',
totalAmount: totalAmount ?? 0,
orderCount: orderCount ?? 0,
paymentCount: paymentCount ?? 0,
percentage: percentage ?? 0.0,
);
}
/// ===================== RECENT SALE DTO =====================
@freezed
class DashboardRecentSaleDto with _$DashboardRecentSaleDto {
const DashboardRecentSaleDto._();
const factory DashboardRecentSaleDto({
@JsonKey(name: 'date') String? date,
@JsonKey(name: 'sales') int? sales,
@JsonKey(name: 'orders') int? orders,
@JsonKey(name: 'items') int? items,
@JsonKey(name: 'tax') int? tax,
@JsonKey(name: 'discount') int? discount,
@JsonKey(name: 'net_sales') int? netSales,
}) = _DashboardRecentSaleDto;
factory DashboardRecentSaleDto.fromJson(Map<String, dynamic> json) =>
_$DashboardRecentSaleDtoFromJson(json);
DashboardRecentSale toDomain() => DashboardRecentSale(
date: date ?? '',
sales: sales ?? 0,
orders: orders ?? 0,
items: items ?? 0,
tax: tax ?? 0,
discount: discount ?? 0,
netSales: netSales ?? 0,
);
}

View File

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

View File

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

View File

@ -0,0 +1,8 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/customer/customer.dart';
part 'customer_dtos.freezed.dart';
part 'customer_dtos.g.dart';
part 'dto/customer_dto.dart';

View File

@ -0,0 +1,440 @@
// 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 'customer_dtos.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',
);
CustomerDto _$CustomerDtoFromJson(Map<String, dynamic> json) {
return _CustomerDto.fromJson(json);
}
/// @nodoc
mixin _$CustomerDto {
@JsonKey(name: "id")
String? get id => throw _privateConstructorUsedError;
@JsonKey(name: "organization_id")
String? get organizationId => throw _privateConstructorUsedError;
@JsonKey(name: "name")
String? get name => throw _privateConstructorUsedError;
@JsonKey(name: "email")
String? get email => throw _privateConstructorUsedError;
@JsonKey(name: "phone")
String? get phone => throw _privateConstructorUsedError;
@JsonKey(name: "address")
String? get address => throw _privateConstructorUsedError;
@JsonKey(name: "is_default")
bool? get isDefault => throw _privateConstructorUsedError;
@JsonKey(name: "is_active")
bool? get isActive => throw _privateConstructorUsedError;
@JsonKey(name: "metadata")
Map<String, dynamic>? get metadata => throw _privateConstructorUsedError;
@JsonKey(name: "created_at")
String? get createdAt => throw _privateConstructorUsedError;
@JsonKey(name: "updated_at")
String? get updatedAt => throw _privateConstructorUsedError;
/// Serializes this CustomerDto to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of CustomerDto
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$CustomerDtoCopyWith<CustomerDto> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $CustomerDtoCopyWith<$Res> {
factory $CustomerDtoCopyWith(
CustomerDto value,
$Res Function(CustomerDto) then,
) = _$CustomerDtoCopyWithImpl<$Res, CustomerDto>;
@useResult
$Res call({
@JsonKey(name: "id") String? id,
@JsonKey(name: "organization_id") String? organizationId,
@JsonKey(name: "name") String? name,
@JsonKey(name: "email") String? email,
@JsonKey(name: "phone") String? phone,
@JsonKey(name: "address") String? address,
@JsonKey(name: "is_default") bool? isDefault,
@JsonKey(name: "is_active") bool? isActive,
@JsonKey(name: "metadata") Map<String, dynamic>? metadata,
@JsonKey(name: "created_at") String? createdAt,
@JsonKey(name: "updated_at") String? updatedAt,
});
}
/// @nodoc
class _$CustomerDtoCopyWithImpl<$Res, $Val extends CustomerDto>
implements $CustomerDtoCopyWith<$Res> {
_$CustomerDtoCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of CustomerDto
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = freezed,
Object? organizationId = freezed,
Object? name = freezed,
Object? email = freezed,
Object? phone = freezed,
Object? address = freezed,
Object? isDefault = freezed,
Object? isActive = freezed,
Object? metadata = freezed,
Object? createdAt = freezed,
Object? updatedAt = freezed,
}) {
return _then(
_value.copyWith(
id: freezed == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String?,
organizationId: freezed == organizationId
? _value.organizationId
: organizationId // ignore: cast_nullable_to_non_nullable
as String?,
name: freezed == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String?,
email: freezed == email
? _value.email
: email // ignore: cast_nullable_to_non_nullable
as String?,
phone: freezed == phone
? _value.phone
: phone // ignore: cast_nullable_to_non_nullable
as String?,
address: freezed == address
? _value.address
: address // ignore: cast_nullable_to_non_nullable
as String?,
isDefault: freezed == isDefault
? _value.isDefault
: isDefault // ignore: cast_nullable_to_non_nullable
as bool?,
isActive: freezed == isActive
? _value.isActive
: isActive // ignore: cast_nullable_to_non_nullable
as bool?,
metadata: freezed == metadata
? _value.metadata
: metadata // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,
createdAt: freezed == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as String?,
updatedAt: freezed == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as String?,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$CustomerDtoImplCopyWith<$Res>
implements $CustomerDtoCopyWith<$Res> {
factory _$$CustomerDtoImplCopyWith(
_$CustomerDtoImpl value,
$Res Function(_$CustomerDtoImpl) then,
) = __$$CustomerDtoImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
@JsonKey(name: "id") String? id,
@JsonKey(name: "organization_id") String? organizationId,
@JsonKey(name: "name") String? name,
@JsonKey(name: "email") String? email,
@JsonKey(name: "phone") String? phone,
@JsonKey(name: "address") String? address,
@JsonKey(name: "is_default") bool? isDefault,
@JsonKey(name: "is_active") bool? isActive,
@JsonKey(name: "metadata") Map<String, dynamic>? metadata,
@JsonKey(name: "created_at") String? createdAt,
@JsonKey(name: "updated_at") String? updatedAt,
});
}
/// @nodoc
class __$$CustomerDtoImplCopyWithImpl<$Res>
extends _$CustomerDtoCopyWithImpl<$Res, _$CustomerDtoImpl>
implements _$$CustomerDtoImplCopyWith<$Res> {
__$$CustomerDtoImplCopyWithImpl(
_$CustomerDtoImpl _value,
$Res Function(_$CustomerDtoImpl) _then,
) : super(_value, _then);
/// Create a copy of CustomerDto
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = freezed,
Object? organizationId = freezed,
Object? name = freezed,
Object? email = freezed,
Object? phone = freezed,
Object? address = freezed,
Object? isDefault = freezed,
Object? isActive = freezed,
Object? metadata = freezed,
Object? createdAt = freezed,
Object? updatedAt = freezed,
}) {
return _then(
_$CustomerDtoImpl(
id: freezed == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String?,
organizationId: freezed == organizationId
? _value.organizationId
: organizationId // ignore: cast_nullable_to_non_nullable
as String?,
name: freezed == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String?,
email: freezed == email
? _value.email
: email // ignore: cast_nullable_to_non_nullable
as String?,
phone: freezed == phone
? _value.phone
: phone // ignore: cast_nullable_to_non_nullable
as String?,
address: freezed == address
? _value.address
: address // ignore: cast_nullable_to_non_nullable
as String?,
isDefault: freezed == isDefault
? _value.isDefault
: isDefault // ignore: cast_nullable_to_non_nullable
as bool?,
isActive: freezed == isActive
? _value.isActive
: isActive // ignore: cast_nullable_to_non_nullable
as bool?,
metadata: freezed == metadata
? _value._metadata
: metadata // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,
createdAt: freezed == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as String?,
updatedAt: freezed == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as String?,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$CustomerDtoImpl extends _CustomerDto {
const _$CustomerDtoImpl({
@JsonKey(name: "id") this.id,
@JsonKey(name: "organization_id") this.organizationId,
@JsonKey(name: "name") this.name,
@JsonKey(name: "email") this.email,
@JsonKey(name: "phone") this.phone,
@JsonKey(name: "address") this.address,
@JsonKey(name: "is_default") this.isDefault,
@JsonKey(name: "is_active") this.isActive,
@JsonKey(name: "metadata") final Map<String, dynamic>? metadata,
@JsonKey(name: "created_at") this.createdAt,
@JsonKey(name: "updated_at") this.updatedAt,
}) : _metadata = metadata,
super._();
factory _$CustomerDtoImpl.fromJson(Map<String, dynamic> json) =>
_$$CustomerDtoImplFromJson(json);
@override
@JsonKey(name: "id")
final String? id;
@override
@JsonKey(name: "organization_id")
final String? organizationId;
@override
@JsonKey(name: "name")
final String? name;
@override
@JsonKey(name: "email")
final String? email;
@override
@JsonKey(name: "phone")
final String? phone;
@override
@JsonKey(name: "address")
final String? address;
@override
@JsonKey(name: "is_default")
final bool? isDefault;
@override
@JsonKey(name: "is_active")
final bool? isActive;
final Map<String, dynamic>? _metadata;
@override
@JsonKey(name: "metadata")
Map<String, dynamic>? get metadata {
final value = _metadata;
if (value == null) return null;
if (_metadata is EqualUnmodifiableMapView) return _metadata;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(value);
}
@override
@JsonKey(name: "created_at")
final String? createdAt;
@override
@JsonKey(name: "updated_at")
final String? updatedAt;
@override
String toString() {
return 'CustomerDto(id: $id, organizationId: $organizationId, name: $name, email: $email, phone: $phone, address: $address, isDefault: $isDefault, isActive: $isActive, metadata: $metadata, createdAt: $createdAt, updatedAt: $updatedAt)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$CustomerDtoImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.organizationId, organizationId) ||
other.organizationId == organizationId) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.email, email) || other.email == email) &&
(identical(other.phone, phone) || other.phone == phone) &&
(identical(other.address, address) || other.address == address) &&
(identical(other.isDefault, isDefault) ||
other.isDefault == isDefault) &&
(identical(other.isActive, isActive) ||
other.isActive == isActive) &&
const DeepCollectionEquality().equals(other._metadata, _metadata) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
organizationId,
name,
email,
phone,
address,
isDefault,
isActive,
const DeepCollectionEquality().hash(_metadata),
createdAt,
updatedAt,
);
/// Create a copy of CustomerDto
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$CustomerDtoImplCopyWith<_$CustomerDtoImpl> get copyWith =>
__$$CustomerDtoImplCopyWithImpl<_$CustomerDtoImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$CustomerDtoImplToJson(this);
}
}
abstract class _CustomerDto extends CustomerDto {
const factory _CustomerDto({
@JsonKey(name: "id") final String? id,
@JsonKey(name: "organization_id") final String? organizationId,
@JsonKey(name: "name") final String? name,
@JsonKey(name: "email") final String? email,
@JsonKey(name: "phone") final String? phone,
@JsonKey(name: "address") final String? address,
@JsonKey(name: "is_default") final bool? isDefault,
@JsonKey(name: "is_active") final bool? isActive,
@JsonKey(name: "metadata") final Map<String, dynamic>? metadata,
@JsonKey(name: "created_at") final String? createdAt,
@JsonKey(name: "updated_at") final String? updatedAt,
}) = _$CustomerDtoImpl;
const _CustomerDto._() : super._();
factory _CustomerDto.fromJson(Map<String, dynamic> json) =
_$CustomerDtoImpl.fromJson;
@override
@JsonKey(name: "id")
String? get id;
@override
@JsonKey(name: "organization_id")
String? get organizationId;
@override
@JsonKey(name: "name")
String? get name;
@override
@JsonKey(name: "email")
String? get email;
@override
@JsonKey(name: "phone")
String? get phone;
@override
@JsonKey(name: "address")
String? get address;
@override
@JsonKey(name: "is_default")
bool? get isDefault;
@override
@JsonKey(name: "is_active")
bool? get isActive;
@override
@JsonKey(name: "metadata")
Map<String, dynamic>? get metadata;
@override
@JsonKey(name: "created_at")
String? get createdAt;
@override
@JsonKey(name: "updated_at")
String? get updatedAt;
/// Create a copy of CustomerDto
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$CustomerDtoImplCopyWith<_$CustomerDtoImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,37 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'customer_dtos.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$CustomerDtoImpl _$$CustomerDtoImplFromJson(Map<String, dynamic> json) =>
_$CustomerDtoImpl(
id: json['id'] as String?,
organizationId: json['organization_id'] as String?,
name: json['name'] as String?,
email: json['email'] as String?,
phone: json['phone'] as String?,
address: json['address'] as String?,
isDefault: json['is_default'] as bool?,
isActive: json['is_active'] as bool?,
metadata: json['metadata'] as Map<String, dynamic>?,
createdAt: json['created_at'] as String?,
updatedAt: json['updated_at'] as String?,
);
Map<String, dynamic> _$$CustomerDtoImplToJson(_$CustomerDtoImpl instance) =>
<String, dynamic>{
'id': instance.id,
'organization_id': instance.organizationId,
'name': instance.name,
'email': instance.email,
'phone': instance.phone,
'address': instance.address,
'is_default': instance.isDefault,
'is_active': instance.isActive,
'metadata': instance.metadata,
'created_at': instance.createdAt,
'updated_at': instance.updatedAt,
};

View File

@ -0,0 +1,52 @@
import 'dart:developer';
import 'package:data_channel/data_channel.dart';
import 'package:injectable/injectable.dart';
import '../../../common/api/api_client.dart';
import '../../../common/api/api_failure.dart';
import '../../../common/function/app_function.dart';
import '../../../common/url/api_path.dart';
import '../../../domain/customer/customer.dart';
import '../customer_dtos.dart';
@injectable
class CustomerRemoteDataProvider {
final ApiClient _apiClient;
final String _logName = "CustomerRemoteDataProvider";
CustomerRemoteDataProvider(this._apiClient);
Future<DC<CustomerFailure, List<CustomerDto>>> fetch({
int page = 1,
int limit = 20,
String? search,
}) async {
try {
Map<String, dynamic> params = {'page': page, 'limit': limit};
if (search != null && search.isNotEmpty) {
params['search'] = search;
}
final response = await _apiClient.get(
ApiPath.customer,
params: params,
headers: getAuthorizationHeader(),
);
if (response.data['data'] == null) {
return DC.error(CustomerFailure.empty());
}
final dto = (response.data['data']['data'] as List)
.map((e) => CustomerDto.fromJson(e))
.toList();
return DC.data(dto);
} on ApiFailure catch (e, s) {
log('fetchCustomerError', name: _logName, error: e, stackTrace: s);
return DC.error(CustomerFailure.serverError(e));
}
}
}

View File

@ -0,0 +1,39 @@
part of '../customer_dtos.dart';
@freezed
class CustomerDto with _$CustomerDto {
const CustomerDto._();
const factory CustomerDto({
@JsonKey(name: "id") String? id,
@JsonKey(name: "organization_id") String? organizationId,
@JsonKey(name: "name") String? name,
@JsonKey(name: "email") String? email,
@JsonKey(name: "phone") String? phone,
@JsonKey(name: "address") String? address,
@JsonKey(name: "is_default") bool? isDefault,
@JsonKey(name: "is_active") bool? isActive,
@JsonKey(name: "metadata") Map<String, dynamic>? metadata,
@JsonKey(name: "created_at") String? createdAt,
@JsonKey(name: "updated_at") String? updatedAt,
}) = _CustomerDto;
factory CustomerDto.fromJson(Map<String, dynamic> json) =>
_$CustomerDtoFromJson(json);
Customer toDomain() {
return Customer(
id: id ?? '',
organizationId: organizationId ?? '',
name: name ?? '',
email: email ?? '',
phone: phone ?? '',
address: address ?? '',
isDefault: isDefault ?? false,
isActive: isActive ?? false,
metadata: metadata ?? {},
createdAt: createdAt ?? '',
updatedAt: updatedAt ?? '',
);
}
}

View File

@ -0,0 +1,41 @@
import 'dart:developer';
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../domain/customer/customer.dart';
import '../datasources/remote_data_provider.dart';
@Injectable(as: ICustomerRepository)
class CustomerRepository implements ICustomerRepository {
final CustomerRemoteDataProvider _dataProvider;
final String _logName = 'CustomerRepository';
CustomerRepository(this._dataProvider);
@override
Future<Either<CustomerFailure, List<Customer>>> get({
int page = 1,
int limit = 20,
String? search,
}) async {
try {
final result = await _dataProvider.fetch(
page: page,
limit: limit,
search: search,
);
if (result.hasError) {
return left(result.error!);
}
final auth = result.data!.map((item) => item.toDomain()).toList();
return right(auth);
} catch (e, s) {
log('getCustomerError', name: _logName, error: e, stackTrace: s);
return left(const CustomerFailure.unexpectedError());
}
}
}

View File

@ -11,6 +11,10 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:apskel_owner_flutter/application/analytic/category_analytic_loader/category_analytic_loader_bloc.dart'
as _i1038;
import 'package:apskel_owner_flutter/application/analytic/dashboard_analytic_loader/dashboard_analytic_loader_bloc.dart'
as _i516;
import 'package:apskel_owner_flutter/application/analytic/inventory_analytic_loader/inventory_analytic_loader_bloc.dart'
as _i785;
import 'package:apskel_owner_flutter/application/analytic/profit_loss_loader/profit_loss_loader_bloc.dart'
as _i11;
import 'package:apskel_owner_flutter/application/analytic/sales_loader/sales_loader_bloc.dart'
@ -22,6 +26,8 @@ import 'package:apskel_owner_flutter/application/auth/logout_form/logout_form_bl
as _i574;
import 'package:apskel_owner_flutter/application/category/category_loader/category_loader_bloc.dart'
as _i183;
import 'package:apskel_owner_flutter/application/customer/customer_loader/customer_loader_bloc.dart'
as _i972;
import 'package:apskel_owner_flutter/application/language/language_bloc.dart'
as _i455;
import 'package:apskel_owner_flutter/application/product/product_loader/product_loader_bloc.dart'
@ -39,6 +45,7 @@ import 'package:apskel_owner_flutter/domain/analytic/repositories/i_analytic_rep
as _i477;
import 'package:apskel_owner_flutter/domain/auth/auth.dart' as _i49;
import 'package:apskel_owner_flutter/domain/category/category.dart' as _i1020;
import 'package:apskel_owner_flutter/domain/customer/customer.dart' as _i48;
import 'package:apskel_owner_flutter/domain/product/product.dart' as _i419;
import 'package:apskel_owner_flutter/env.dart' as _i6;
import 'package:apskel_owner_flutter/infrastructure/analytic/datasource/remote_data_provider.dart'
@ -55,6 +62,10 @@ import 'package:apskel_owner_flutter/infrastructure/category/datasource/remote_d
as _i333;
import 'package:apskel_owner_flutter/infrastructure/category/repositories/category_repository.dart'
as _i869;
import 'package:apskel_owner_flutter/infrastructure/customer/datasources/remote_data_provider.dart'
as _i1006;
import 'package:apskel_owner_flutter/infrastructure/customer/repositories/customer_repository.dart'
as _i550;
import 'package:apskel_owner_flutter/infrastructure/product/datasources/remote_data_provider.dart'
as _i823;
import 'package:apskel_owner_flutter/infrastructure/product/repositories/product_repository.dart'
@ -120,8 +131,11 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i866.AnalyticRemoteDataProvider>(
() => _i866.AnalyticRemoteDataProvider(gh<_i115.ApiClient>()),
);
gh.factory<_i477.IAnalyticRepository>(
() => _i393.AnalyticRepository(gh<_i866.AnalyticRemoteDataProvider>()),
gh.factory<_i1006.CustomerRemoteDataProvider>(
() => _i1006.CustomerRemoteDataProvider(gh<_i115.ApiClient>()),
);
gh.factory<_i48.ICustomerRepository>(
() => _i550.CustomerRepository(gh<_i1006.CustomerRemoteDataProvider>()),
);
gh.factory<_i49.IAuthRepository>(
() => _i1035.AuthRepository(
@ -132,6 +146,15 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i419.IProductRepository>(
() => _i121.ProductRepository(gh<_i823.ProductRemoteDataProvider>()),
);
gh.factory<_i972.CustomerLoaderBloc>(
() => _i972.CustomerLoaderBloc(gh<_i48.ICustomerRepository>()),
);
gh.factory<_i477.IAnalyticRepository>(
() => _i393.AnalyticRepository(
gh<_i866.AnalyticRemoteDataProvider>(),
gh<_i991.AuthLocalDataProvider>(),
),
);
gh.factory<_i1020.ICategoryRepository>(
() => _i869.CategoryRepository(gh<_i333.CategoryRemoteDataProvider>()),
);
@ -150,6 +173,12 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i1038.CategoryAnalyticLoaderBloc>(
() => _i1038.CategoryAnalyticLoaderBloc(gh<_i477.IAnalyticRepository>()),
);
gh.factory<_i785.InventoryAnalyticLoaderBloc>(
() => _i785.InventoryAnalyticLoaderBloc(gh<_i477.IAnalyticRepository>()),
);
gh.factory<_i516.DashboardAnalyticLoaderBloc>(
() => _i516.DashboardAnalyticLoaderBloc(gh<_i477.IAnalyticRepository>()),
);
gh.factory<_i775.LoginFormBloc>(
() => _i775.LoginFormBloc(gh<_i49.IAuthRepository>()),
);

View File

@ -1,106 +1,39 @@
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/customer/customer_loader/customer_loader_bloc.dart';
import '../../../common/theme/theme.dart';
import '../../../domain/customer/customer.dart';
import '../../../injection.dart';
import '../../components/appbar/appbar.dart';
import '../../components/button/button.dart';
import 'widgets/customer_card.dart';
import 'widgets/customer_tile.dart';
// Customer Model
class Customer {
final String id;
final String name;
final String email;
final String phone;
final String address;
final double totalPurchases;
final int totalOrders;
final DateTime lastVisit;
final String membershipLevel;
final bool isActive;
Customer({
required this.id,
required this.name,
required this.email,
required this.phone,
required this.address,
required this.totalPurchases,
required this.totalOrders,
required this.lastVisit,
required this.membershipLevel,
required this.isActive,
});
}
@RoutePage()
class CustomerPage extends StatefulWidget {
class CustomerPage extends StatefulWidget implements AutoRouteWrapper {
const CustomerPage({super.key});
@override
State<CustomerPage> createState() => _CustomerPageState();
@override
Widget wrappedRoute(BuildContext context) => BlocProvider(
create: (context) =>
getIt<CustomerLoaderBloc>()
..add(CustomerLoaderEvent.fetched(isRefresh: true)),
child: this,
);
}
class _CustomerPageState extends State<CustomerPage>
with TickerProviderStateMixin {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
ScrollController _scrollController = ScrollController();
bool _isGridView = false;
// Sample customer data
final List<Customer> _customers = [
Customer(
id: '001',
name: 'Ahmad Wijaya',
email: 'ahmad@email.com',
phone: '+62 812-3456-7890',
address: 'Jl. Raya No. 123, Jakarta',
totalPurchases: 2500000,
totalOrders: 15,
lastVisit: DateTime.now().subtract(Duration(days: 2)),
membershipLevel: 'Gold',
isActive: true,
),
Customer(
id: '002',
name: 'Siti Nurhaliza',
email: 'siti@email.com',
phone: '+62 813-4567-8901',
address: 'Jl. Merdeka No. 45, Bandung',
totalPurchases: 1800000,
totalOrders: 12,
lastVisit: DateTime.now().subtract(Duration(days: 5)),
membershipLevel: 'Silver',
isActive: true,
),
Customer(
id: '003',
name: 'Budi Santoso',
email: 'budi@email.com',
phone: '+62 814-5678-9012',
address: 'Jl. Sudirman No. 67, Surabaya',
totalPurchases: 3200000,
totalOrders: 20,
lastVisit: DateTime.now().subtract(Duration(days: 1)),
membershipLevel: 'Platinum',
isActive: true,
),
Customer(
id: '004',
name: 'Maya Sari',
email: 'maya@email.com',
phone: '+62 815-6789-0123',
address: 'Jl. Diponegoro No. 89, Yogyakarta',
totalPurchases: 950000,
totalOrders: 8,
lastVisit: DateTime.now().subtract(Duration(days: 30)),
membershipLevel: 'Bronze',
isActive: false,
),
];
@override
initState() {
super.initState();
@ -112,94 +45,102 @@ class _CustomerPageState extends State<CustomerPage>
super.dispose();
}
List<Customer> get filteredCustomers {
var filtered = _customers.where((customer) {
final matchesSearch =
customer.name.toLowerCase().contains(_searchQuery.toLowerCase()) ||
customer.email.toLowerCase().contains(_searchQuery.toLowerCase()) ||
customer.phone.contains(_searchQuery);
return matchesSearch;
}).toList();
return filtered;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColor.background,
body: CustomScrollView(
slivers: [
// SliverAppBar with gradient
SliverAppBar(
expandedHeight: 120.0,
floating: false,
pinned: true,
backgroundColor: AppColor.primary,
flexibleSpace: CustomAppBar(title: 'Pelanggan'),
actions: [ActionIconButton(onTap: () {}, icon: LineIcons.search)],
),
body: BlocBuilder<CustomerLoaderBloc, CustomerLoaderState>(
builder: (context, state) {
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollEndNotification &&
_scrollController.position.extentAfter == 0) {
context.read<CustomerLoaderBloc>().add(
CustomerLoaderEvent.fetched(),
);
return true;
}
// Search and Filter Section
SliverToBoxAdapter(
child: Container(
color: AppColor.white,
child: Column(
children: [
// View toggle and sort
Padding(
padding: EdgeInsets.only(left: 16, right: 16, bottom: 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
return true;
},
child: CustomScrollView(
controller: _scrollController,
slivers: [
// SliverAppBar with gradient
SliverAppBar(
expandedHeight: 120.0,
floating: false,
pinned: true,
backgroundColor: AppColor.primary,
flexibleSpace: CustomAppBar(title: 'Pelanggan'),
actions: [
ActionIconButton(onTap: () {}, icon: LineIcons.search),
],
),
// Search and Filter Section
SliverToBoxAdapter(
child: Container(
color: AppColor.white,
child: Column(
children: [
Text(
'${filteredCustomers.length} customers found',
style: TextStyle(
color: AppColor.textSecondary,
fontSize: 14,
// View toggle and sort
Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
bottom: 0,
),
),
Row(
children: [
IconButton(
icon: Icon(
_isGridView ? Icons.list : Icons.grid_view,
color: AppColor.primary,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Row(
children: [
IconButton(
icon: Icon(
_isGridView
? Icons.list
: Icons.grid_view,
color: AppColor.primary,
),
onPressed: () {
setState(() {
_isGridView = !_isGridView;
});
},
),
],
),
onPressed: () {
setState(() {
_isGridView = !_isGridView;
});
},
),
],
],
),
),
],
),
),
],
),
),
),
),
// Customer List
_isGridView ? _buildCustomerGrid() : _buildCustomerList(),
],
// Customer List
_isGridView
? _buildCustomerGrid(state.customers)
: _buildCustomerList(state.customers),
],
),
);
},
),
);
}
Widget _buildCustomerList() {
Widget _buildCustomerList(List<Customer> customers) {
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final customer = filteredCustomers[index];
final customer = customers[index];
return CustomerTile(customer: customer);
}, childCount: filteredCustomers.length),
}, childCount: customers.length),
);
}
Widget _buildCustomerGrid() {
Widget _buildCustomerGrid(List<Customer> customers) {
return SliverPadding(
padding: EdgeInsets.all(16),
sliver: SliverGrid(
@ -210,9 +151,9 @@ class _CustomerPageState extends State<CustomerPage>
childAspectRatio: 0.8,
),
delegate: SliverChildBuilderDelegate((context, index) {
final customer = filteredCustomers[index];
final customer = customers[index];
return CustomerCard(customer: customer);
}, childCount: filteredCustomers.length),
}, childCount: customers.length),
),
);
}

View File

@ -1,89 +1,390 @@
import 'package:flutter/material.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/customer/customer.dart';
import '../../../components/spacer/spacer.dart';
import '../customer_page.dart';
class CustomerCard extends StatelessWidget {
final Customer customer;
const CustomerCard({super.key, required this.customer});
final VoidCallback? onTap;
final VoidCallback? onLongPress;
const CustomerCard({
super.key,
required this.customer,
this.onTap,
this.onLongPress,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: Offset(0, 4),
color: Colors.black.withOpacity(0.06),
blurRadius: 20,
offset: const Offset(0, 6),
spreadRadius: -4,
),
],
border: Border.all(
color: customer.isActive
? AppColor.primary.withOpacity(0.1)
: Colors.grey.withOpacity(0.08),
width: 1.5,
),
),
child: InkWell(
onTap: () {},
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
CircleAvatar(
backgroundColor: _getMembershipColor(customer.membershipLevel),
radius: 30,
child: Text(
customer.name[0].toUpperCase(),
style: AppStyle.xxl.copyWith(
color: AppColor.white,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Avatar with status indicator
Stack(
children: [
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
_getAvatarColor(customer.name),
_getAvatarColor(customer.name).withOpacity(0.8),
],
),
boxShadow: [
BoxShadow(
color: _getAvatarColor(
customer.name,
).withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: CircleAvatar(
backgroundColor: Colors.transparent,
radius: 32,
child: Text(
customer.name.isNotEmpty
? customer.name[0].toUpperCase()
: '?',
style: AppStyle.xxl.copyWith(
color: AppColor.white,
fontWeight: FontWeight.bold,
),
),
),
),
// Status indicator
Positioned(
bottom: 2,
right: 2,
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: customer.isActive
? AppColor.success
: AppColor.error,
shape: BoxShape.circle,
border: Border.all(color: AppColor.white, width: 3),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
),
),
// Default badge
if (customer.isDefault)
Positioned(
top: -2,
left: -8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColor.primary,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: AppColor.primary.withOpacity(0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Text(
'',
style: AppStyle.xs.copyWith(
color: AppColor.white,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
const SpaceHeight(16),
// Customer Name
Text(
customer.name.isNotEmpty ? customer.name : 'Unknown Customer',
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SpaceHeight(8),
// Contact Info
if (customer.email.isNotEmpty || customer.phone.isNotEmpty) ...[
Column(
children: [
if (customer.email.isNotEmpty)
_buildContactInfo(Icons.email_outlined, customer.email),
if (customer.email.isNotEmpty &&
customer.phone.isNotEmpty)
const SpaceHeight(4),
if (customer.phone.isNotEmpty)
_buildContactInfo(Icons.phone_outlined, customer.phone),
],
),
const SpaceHeight(12),
],
// Status Badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: customer.isActive
? AppColor.success.withOpacity(0.1)
: AppColor.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: customer.isActive
? AppColor.success.withOpacity(0.3)
: AppColor.error.withOpacity(0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: customer.isActive
? AppColor.success
: AppColor.error,
shape: BoxShape.circle,
),
),
const SpaceWidth(6),
Text(
customer.isActive ? 'Active' : 'Inactive',
style: AppStyle.sm.copyWith(
color: customer.isActive
? AppColor.success
: AppColor.error,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
SpaceHeight(12),
Text(
customer.name,
style: AppStyle.lg.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 8),
Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getMembershipColor(
customer.membershipLevel,
).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
customer.membershipLevel,
style: AppStyle.sm.copyWith(
color: _getMembershipColor(customer.membershipLevel),
fontWeight: FontWeight.bold,
// Additional info if available
if (customer.address.isNotEmpty) ...[
const SpaceHeight(8),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.location_on_outlined,
size: 14,
color: AppColor.textSecondary,
),
const SpaceWidth(4),
Flexible(
child: Text(
customer.address,
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
],
],
// Metadata info
if (customer.metadata.isNotEmpty && _hasRelevantMetadata()) ...[
const SpaceHeight(8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.star_outline,
size: 12,
color: AppColor.primary,
),
const SpaceWidth(4),
Text(
_getMetadataInfo(),
style: AppStyle.xs.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
// Join date
if (customer.createdAt.isNotEmpty) ...[
const SpaceHeight(8),
Text(
'Joined ${_formatDate(customer.createdAt)}',
style: AppStyle.xs.copyWith(color: AppColor.textSecondary),
textAlign: TextAlign.center,
),
],
],
),
),
),
),
);
}
Color _getMembershipColor(String level) {
switch (level) {
case 'Platinum':
return Color(0xFF9C27B0);
case 'Gold':
return Color(0xFFFF9800);
case 'Silver':
return Color(0xFF607D8B);
case 'Bronze':
return Color(0xFF795548);
default:
return AppColor.primary;
Widget _buildContactInfo(IconData icon, String text) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: AppColor.textSecondary),
const SpaceWidth(6),
Flexible(
child: Text(
text,
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
Color _getAvatarColor(String name) {
final colors = [
AppColor.primary,
const Color(0xFF9C27B0), // Purple
const Color(0xFFFF9800), // Orange
const Color(0xFF607D8B), // Blue Grey
const Color(0xFF795548), // Brown
const Color(0xFF4CAF50), // Green
const Color(0xFF2196F3), // Blue
const Color(0xFFE91E63), // Pink
const Color(0xFF00BCD4), // Cyan
const Color(0xFFFF5722), // Deep Orange
];
if (name.isEmpty) return AppColor.primary;
final index = name.hashCode.abs() % colors.length;
return colors[index];
}
String _formatDate(String dateStr) {
try {
final date = DateTime.parse(dateStr);
final now = DateTime.now();
final difference = now.difference(date).inDays;
if (difference == 0) {
return 'today';
} else if (difference == 1) {
return 'yesterday';
} else if (difference < 30) {
return '${difference}d ago';
} else if (difference < 365) {
final months = (difference / 30).floor();
return '${months}mo ago';
} else {
final years = (difference / 365).floor();
return '${years}y ago';
}
} catch (e) {
return dateStr;
}
}
bool _hasRelevantMetadata() {
return customer.metadata.containsKey('notes') ||
customer.metadata.containsKey('tags') ||
customer.metadata.containsKey('source') ||
customer.metadata.containsKey('preferences') ||
customer.metadata.containsKey('vip') ||
customer.metadata.containsKey('tier');
}
String _getMetadataInfo() {
if (customer.metadata.containsKey('vip') &&
customer.metadata['vip'] == true) {
return 'VIP';
}
if (customer.metadata.containsKey('tier')) {
return customer.metadata['tier'].toString();
}
if (customer.metadata.containsKey('tags')) {
final tags = customer.metadata['tags'];
if (tags is List && tags.isNotEmpty) {
return tags.first.toString();
}
}
if (customer.metadata.containsKey('source')) {
return customer.metadata['source'].toString();
}
return 'Special';
}
}

View File

@ -1,127 +1,378 @@
import 'package:flutter/material.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/customer/customer.dart';
import '../../../components/spacer/spacer.dart';
import '../customer_page.dart';
class CustomerTile extends StatelessWidget {
final Customer customer;
const CustomerTile({super.key, required this.customer});
final VoidCallback? onTap;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
const CustomerTile({
super.key,
required this.customer,
this.onTap,
this.onEdit,
this.onDelete,
});
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.symmetric(horizontal: AppValue.margin, vertical: 6),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: Offset(0, 2),
color: Colors.black.withOpacity(0.06),
blurRadius: 16,
offset: const Offset(0, 4),
spreadRadius: -2,
),
],
border: Border.all(
color: customer.isActive
? AppColor.primary.withOpacity(0.15)
: Colors.grey.withOpacity(0.1),
width: 1,
),
),
child: ListTile(
contentPadding: EdgeInsets.all(16),
leading: CircleAvatar(
backgroundColor: _getMembershipColor(customer.membershipLevel),
child: Text(
customer.name[0].toUpperCase(),
style: AppStyle.sm.copyWith(
color: AppColor.white,
fontWeight: FontWeight.bold,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Row
Row(
children: [
// Avatar
Stack(
children: [
CircleAvatar(
radius: 26,
backgroundColor: _getAvatarColor(customer.name),
child: Text(
customer.name.isNotEmpty
? customer.name[0].toUpperCase()
: '?',
style: AppStyle.lg.copyWith(
color: AppColor.white,
fontWeight: FontWeight.bold,
),
),
),
// Status indicator
Positioned(
bottom: 2,
right: 2,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: customer.isActive
? AppColor.success
: AppColor.error,
shape: BoxShape.circle,
border: Border.all(
color: AppColor.white,
width: 2,
),
),
),
),
],
),
const SpaceWidth(16),
// Customer Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Name with badges
Row(
children: [
Expanded(
child: Text(
customer.name.isNotEmpty
? customer.name
: 'Unknown Customer',
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
overflow: TextOverflow.ellipsis,
),
),
if (customer.isDefault)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'DEFAULT',
style: AppStyle.xs.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
),
],
),
const SpaceHeight(4),
// Contact info
if (customer.email.isNotEmpty) ...[
Row(
children: [
Icon(
Icons.email_outlined,
size: 16,
color: AppColor.textSecondary,
),
const SpaceWidth(6),
Expanded(
child: Text(
customer.email,
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SpaceHeight(4),
],
if (customer.phone.isNotEmpty)
Row(
children: [
Icon(
Icons.phone_outlined,
size: 16,
color: AppColor.textSecondary,
),
const SpaceWidth(6),
Text(
customer.phone,
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
),
],
),
],
),
),
],
),
// Address section
if (customer.address.isNotEmpty) ...[
const SpaceHeight(16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColor.background,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.location_on_outlined,
size: 18,
color: AppColor.textSecondary,
),
const SpaceWidth(8),
Expanded(
child: Text(
customer.address,
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
// Footer with status and dates
const SpaceHeight(16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Status badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: customer.isActive
? AppColor.success.withOpacity(0.1)
: AppColor.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: customer.isActive
? AppColor.success
: AppColor.error,
shape: BoxShape.circle,
),
),
const SpaceWidth(6),
Text(
customer.isActive ? 'Active' : 'Inactive',
style: AppStyle.sm.copyWith(
color: customer.isActive
? AppColor.success
: AppColor.error,
fontWeight: FontWeight.w600,
),
),
],
),
),
// Created date
if (customer.createdAt.isNotEmpty)
Text(
'Joined ${_formatDate(customer.createdAt)}',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
),
),
],
),
// Metadata section (if has any relevant data)
if (customer.metadata.isNotEmpty && _hasRelevantMetadata()) ...[
const SpaceHeight(12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColor.primary.withOpacity(0.1),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: AppColor.primary,
),
const SpaceWidth(8),
Expanded(
child: Text(
_getMetadataInfo(),
style: AppStyle.xs.copyWith(
color: AppColor.primary,
),
),
),
],
),
),
],
],
),
),
),
title: Text(
customer.name,
style: AppStyle.lg.copyWith(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SpaceHeight(4),
Text(customer.email),
SpaceHeight(2),
Text(customer.phone),
SpaceHeight(4),
Row(
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getMembershipColor(
customer.membershipLevel,
).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
customer.membershipLevel,
style: AppStyle.sm.copyWith(
color: _getMembershipColor(customer.membershipLevel),
fontWeight: FontWeight.bold,
),
),
),
SpaceWidth(8),
Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: customer.isActive
? AppColor.success.withOpacity(0.1)
: AppColor.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
customer.isActive ? 'Active' : 'Inactive',
style: AppStyle.sm.copyWith(
color: customer.isActive
? AppColor.success
: AppColor.error,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Rp ${customer.totalPurchases.toStringAsFixed(0)}',
style: AppStyle.md.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.primary,
),
),
Text(
'${customer.totalOrders} orders',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
],
),
onTap: () {},
),
);
}
Color _getMembershipColor(String level) {
switch (level) {
case 'Platinum':
return Color(0xFF9C27B0);
case 'Gold':
return Color(0xFFFF9800);
case 'Silver':
return Color(0xFF607D8B);
case 'Bronze':
return Color(0xFF795548);
default:
return AppColor.primary;
Color _getAvatarColor(String name) {
final colors = [
AppColor.primary,
const Color(0xFF9C27B0),
const Color(0xFFFF9800),
const Color(0xFF607D8B),
const Color(0xFF795548),
const Color(0xFF4CAF50),
const Color(0xFF2196F3),
const Color(0xFFE91E63),
];
final index = name.hashCode.abs() % colors.length;
return colors[index];
}
String _formatDate(String dateStr) {
try {
final date = DateTime.parse(dateStr);
final now = DateTime.now();
final difference = now.difference(date).inDays;
if (difference == 0) {
return 'today';
} else if (difference == 1) {
return 'yesterday';
} else if (difference < 30) {
return '${difference}d ago';
} else if (difference < 365) {
final months = (difference / 30).floor();
return '${months}mo ago';
} else {
final years = (difference / 365).floor();
return '${years}y ago';
}
} catch (e) {
return dateStr;
}
}
bool _hasRelevantMetadata() {
return customer.metadata.containsKey('notes') ||
customer.metadata.containsKey('tags') ||
customer.metadata.containsKey('source') ||
customer.metadata.containsKey('preferences');
}
String _getMetadataInfo() {
final info = <String>[];
if (customer.metadata.containsKey('notes')) {
info.add('Has notes');
}
if (customer.metadata.containsKey('tags')) {
final tags = customer.metadata['tags'];
if (tags is List && tags.isNotEmpty) {
info.add('${tags.length} tags');
}
}
if (customer.metadata.containsKey('source')) {
info.add('Source: ${customer.metadata['source']}');
}
return info.join('');
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,24 +1,35 @@
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 'dart:math' as math;
import '../../../application/analytic/dashboard_analytic_loader/dashboard_analytic_loader_bloc.dart';
import '../../../common/theme/theme.dart';
import '../../../injection.dart';
import '../../components/appbar/appbar.dart';
import '../../components/button/button.dart';
import '../../components/spacer/spacer.dart';
import 'widgets/payment_method.dart';
import 'widgets/quick_stats.dart';
import 'widgets/report_action.dart';
import 'widgets/revenue_summary.dart';
import 'widgets/sales.dart';
import 'widgets/top_product.dart';
@RoutePage()
class ReportPage extends StatefulWidget {
class ReportPage extends StatefulWidget implements AutoRouteWrapper {
const ReportPage({super.key});
@override
State<ReportPage> createState() => _ReportPageState();
@override
Widget wrappedRoute(BuildContext context) => BlocProvider(
create: (context) =>
getIt<DashboardAnalyticLoaderBloc>()
..add(DashboardAnalyticLoaderEvent.fetched()),
child: this,
);
}
class _ReportPageState extends State<ReportPage> with TickerProviderStateMixin {
@ -78,54 +89,76 @@ class _ReportPageState extends State<ReportPage> with TickerProviderStateMixin {
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColor.background,
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120,
floating: false,
pinned: true,
backgroundColor: AppColor.primary,
centerTitle: false,
flexibleSpace: CustomAppBar(title: 'Laporan', isBack: false),
actions: [
ActionIconButton(onTap: () {}, icon: LineIcons.download),
ActionIconButton(onTap: () {}, icon: LineIcons.filter),
SpaceWidth(8),
],
),
body:
BlocBuilder<
DashboardAnalyticLoaderBloc,
DashboardAnalyticLoaderState
>(
builder: (context, state) {
return CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120,
floating: false,
pinned: true,
backgroundColor: AppColor.primary,
centerTitle: false,
flexibleSpace: CustomAppBar(
title: 'Laporan',
isBack: false,
),
actions: [
ActionIconButton(onTap: () {}, icon: LineIcons.download),
ActionIconButton(onTap: () {}, icon: LineIcons.filter),
SpaceWidth(8),
],
),
// Content
SliverPadding(
padding: EdgeInsets.all(AppValue.padding),
sliver: SliverList(
delegate: SliverChildListDelegate([
FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: Column(
children: [
ReportRevenueSummary(
rotationAnimation: _rotationAnimation,
// Content
SliverPadding(
padding: EdgeInsets.all(AppValue.padding),
sliver: SliverList(
delegate: SliverChildListDelegate([
FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: Column(
children: [
ReportRevenueSummary(
overview: state.dashboardAnalytic.overview,
rotationAnimation: _rotationAnimation,
),
const SpaceHeight(24),
ReportQuickStats(
overview: state.dashboardAnalytic.overview,
),
const SpaceHeight(24),
ReportSales(
salesData:
state.dashboardAnalytic.recentSales,
),
const SpaceHeight(24),
ReportPaymentMethod(
paymentMethods:
state.dashboardAnalytic.paymentMethods,
),
const SpaceHeight(24),
ReportTopProduct(
products: state.dashboardAnalytic.topProducts,
),
const SpaceHeight(24),
],
),
),
),
const SpaceHeight(24),
ReportQuickStats(),
const SpaceHeight(24),
ReportSales(),
const SpaceHeight(24),
ReportTopProduct(),
const SpaceHeight(24),
ReportAction(),
const SpaceHeight(20),
],
]),
),
),
),
]),
),
],
);
},
),
],
),
);
}
}

View File

@ -0,0 +1,616 @@
import 'package:flutter/material.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/analytic/analytic.dart';
import '../../../components/widgets/empty_widget.dart';
class ReportPaymentMethod extends StatelessWidget {
final List<DashboardPaymentMethod> paymentMethods;
const ReportPaymentMethod({super.key, required this.paymentMethods});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 20,
offset: const Offset(0, 4),
spreadRadius: -4,
),
],
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: AppColor.primaryGradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColor.primary.withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Icon(Icons.payment, color: AppColor.white, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Payment Methods',
style: AppStyle.xl.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
const SizedBox(height: 4),
Text(
'Revenue breakdown by payment method',
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
),
],
),
),
],
),
const SizedBox(height: 24),
// Payment Method List
if (paymentMethods.isEmpty)
_buildEmptyState()
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: paymentMethods.length,
padding: EdgeInsets.zero,
separatorBuilder: (context, index) =>
const SizedBox(height: 12),
itemBuilder: (context, index) {
final method = paymentMethods[index];
return TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: 1),
duration: Duration(milliseconds: 600 + (index * 150)),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Transform.translate(
offset: Offset(30 * (1 - value), 0),
child: Opacity(
opacity: value,
child: _buildPaymentMethodTile(
context,
method,
index,
),
),
);
},
);
},
),
],
),
),
);
}
Widget _buildPaymentMethodTile(
BuildContext context,
DashboardPaymentMethod method,
int index,
) {
final screenWidth = MediaQuery.of(context).size.width;
final isCompact = screenWidth < 400; // For smaller screens
return Container(
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: _getPaymentMethodColor(
method.paymentMethodType,
).withOpacity(0.1),
width: 1.5,
),
boxShadow: [
BoxShadow(
color: _getPaymentMethodColor(
method.paymentMethodType,
).withOpacity(0.08),
blurRadius: 12,
offset: const Offset(0, 4),
spreadRadius: -2,
),
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack(
children: [
// Subtle background gradient
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
_getPaymentMethodColor(
method.paymentMethodType,
).withOpacity(0.03),
_getPaymentMethodColor(
method.paymentMethodType,
).withOpacity(0.01),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
),
// Main content
Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header section with improved responsive layout
if (isCompact)
_buildCompactHeader(method)
else
_buildStandardHeader(context, method),
const SizedBox(height: 16),
// Stats row with better spacing
_buildStatsSection(method),
const SizedBox(height: 16),
// Enhanced progress bar section
_buildProgressSection(method, index),
],
),
),
// Accent line on the left
Positioned(
left: 0,
top: 0,
bottom: 0,
child: Container(
width: 4,
decoration: BoxDecoration(
color: _getPaymentMethodColor(method.paymentMethodType),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
bottomLeft: Radius.circular(16),
),
),
),
),
],
),
),
);
}
Widget _buildStandardHeader(
BuildContext context,
DashboardPaymentMethod method,
) {
return Row(
children: [
// Enhanced icon container
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
_getPaymentMethodColor(method.paymentMethodType),
_getPaymentMethodColor(
method.paymentMethodType,
).withOpacity(0.8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: _getPaymentMethodColor(
method.paymentMethodType,
).withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Icon(
_getPaymentMethodIcon(method.paymentMethodType),
color: AppColor.white,
size: 28,
),
),
const SizedBox(width: 16),
// Payment method info - improved text handling
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
method.paymentMethodName,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: _getPaymentMethodColor(
method.paymentMethodType,
).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _getPaymentMethodColor(
method.paymentMethodType,
).withOpacity(0.2),
width: 1,
),
),
child: Text(
method.paymentMethodType.toUpperCase(),
style: AppStyle.xs.copyWith(
color: _getPaymentMethodColor(method.paymentMethodType),
fontWeight: FontWeight.bold,
letterSpacing: 0.8,
),
),
),
],
),
),
const SizedBox(width: 12),
// Percentage badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _getPaymentMethodColor(method.paymentMethodType),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: _getPaymentMethodColor(
method.paymentMethodType,
).withOpacity(0.3),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: Text(
'${method.percentage.toStringAsFixed(1)}%',
style: AppStyle.sm.copyWith(
color: AppColor.white,
fontWeight: FontWeight.bold,
),
),
),
],
);
}
Widget _buildCompactHeader(DashboardPaymentMethod method) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
_getPaymentMethodColor(method.paymentMethodType),
_getPaymentMethodColor(
method.paymentMethodType,
).withOpacity(0.8),
],
),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
_getPaymentMethodIcon(method.paymentMethodType),
color: AppColor.white,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
method.paymentMethodName,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
method.paymentMethodType.toUpperCase(),
style: AppStyle.xs.copyWith(
color: _getPaymentMethodColor(method.paymentMethodType),
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: _getPaymentMethodColor(method.paymentMethodType),
borderRadius: BorderRadius.circular(16),
),
child: Text(
'${method.percentage.toStringAsFixed(1)}%',
style: AppStyle.xs.copyWith(
color: AppColor.white,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
);
}
Widget _buildStatsSection(DashboardPaymentMethod method) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.background.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.border.withOpacity(0.2), width: 1),
),
child: IntrinsicHeight(
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Total Revenue',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
letterSpacing: 0.5,
),
),
const SizedBox(height: 6),
Text(
method.totalAmount.currencyFormatRp,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Container(
width: 1,
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppColor.border.withOpacity(0.3),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Orders',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
letterSpacing: 0.5,
),
),
const SizedBox(height: 6),
Text(
'${method.orderCount}',
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: _getPaymentMethodColor(method.paymentMethodType),
),
),
],
),
],
),
),
);
}
Widget _buildProgressSection(DashboardPaymentMethod method, int index) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Revenue Share',
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w600,
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getPaymentMethodColor(
method.paymentMethodType,
).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${method.percentage.toStringAsFixed(1)}%',
style: AppStyle.sm.copyWith(
color: _getPaymentMethodColor(method.paymentMethodType),
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 12),
TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: method.percentage / 100),
duration: Duration(milliseconds: 1200 + (index * 200)),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Container(
height: 8,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: AppColor.border.withOpacity(0.3),
),
child: Stack(
children: [
FractionallySizedBox(
widthFactor: value,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
_getPaymentMethodColor(method.paymentMethodType),
_getPaymentMethodColor(
method.paymentMethodType,
).withOpacity(0.8),
],
),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: _getPaymentMethodColor(
method.paymentMethodType,
).withOpacity(0.3),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
),
),
],
),
);
},
),
],
);
}
Widget _buildEmptyState() {
return EmptyWidget(
title: 'No Payment Methods',
message:
'Payment method data will appear here once transactions are made',
);
}
Color _getPaymentMethodColor(String type) {
switch (type.toLowerCase()) {
case 'cash':
return AppColor.success;
case 'card':
case 'credit_card':
case 'debit_card':
return AppColor.info;
case 'bank_transfer':
case 'transfer':
return AppColor.primary;
case 'ewallet':
case 'e_wallet':
case 'digital_wallet':
return AppColor.warning;
case 'qr_code':
case 'qris':
return const Color(0xFF9C27B0); // Purple
default:
return AppColor.textSecondary;
}
}
IconData _getPaymentMethodIcon(String type) {
switch (type.toLowerCase()) {
case 'cash':
return Icons.payments;
case 'card':
case 'credit_card':
case 'debit_card':
return Icons.credit_card;
case 'bank_transfer':
case 'transfer':
return Icons.account_balance;
case 'ewallet':
case 'e_wallet':
case 'digital_wallet':
return Icons.account_balance_wallet;
case 'qr_code':
case 'qris':
return Icons.qr_code;
default:
return Icons.payment;
}
}
}

View File

@ -1,53 +1,109 @@
import 'package:flutter/material.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/analytic/analytic.dart';
import 'stat_tile.dart';
class ReportQuickStats extends StatelessWidget {
const ReportQuickStats({super.key});
final DashboardOverview overview;
const ReportQuickStats({super.key, required this.overview});
@override
Widget build(BuildContext context) {
return Row(
return Column(
children: [
Expanded(
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: 1),
duration: const Duration(milliseconds: 800),
builder: (context, value, child) {
return Transform.scale(
scale: value,
child: ReportStatTile(
title: 'Total Transaksi',
value: '245',
icon: Icons.receipt_long,
color: AppColor.info,
change: '+8.2%',
animatedValue: 245 * value,
),
);
},
),
Row(
children: [
Expanded(
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: 1),
duration: const Duration(milliseconds: 800),
builder: (context, value, child) {
return Transform.scale(
scale: value,
child: ReportStatTile(
title: 'Total Orders',
value: overview.totalOrders.toString(),
icon: Icons.receipt_long,
color: AppColor.info,
animatedValue: overview.totalOrders * value,
),
);
},
),
),
const SizedBox(width: 16),
Expanded(
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: 1),
duration: const Duration(milliseconds: 1000),
builder: (context, value, child) {
return Transform.scale(
scale: value,
child: ReportStatTile(
title: 'Average Order',
value: overview.averageOrderValue
.round()
.currencyFormatRp,
icon: Icons.trending_up,
color: AppColor.warning,
animatedValue: overview.averageOrderValue * value,
),
);
},
),
),
],
),
const SizedBox(width: 16),
Expanded(
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: 1),
duration: const Duration(milliseconds: 1000),
builder: (context, value, child) {
return Transform.scale(
scale: value,
child: ReportStatTile(
title: 'Rata-rata',
value: 'Rp 63.061',
icon: Icons.trending_up,
color: AppColor.warning,
change: '+5.1%',
animatedValue: 63061 * value,
),
);
},
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: 1),
duration: const Duration(milliseconds: 1200),
builder: (context, value, child) {
return Transform.scale(
scale: value,
child: ReportStatTile(
title: 'Customers',
value: overview.totalCustomers.toString(),
icon: Icons.people,
color: AppColor.success,
animatedValue: overview.totalCustomers * value,
),
);
},
),
),
const SizedBox(width: 16),
Expanded(
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: 1),
duration: const Duration(milliseconds: 1400),
builder: (context, value, child) {
return Transform.scale(
scale: value,
child: ReportStatTile(
title: 'Void + Refund',
value: (overview.voidedOrders + overview.refundedOrders)
.toString(),
icon: Icons.cancel,
color: AppColor.error,
animatedValue:
(overview.voidedOrders + overview.refundedOrders) *
value,
),
);
},
),
),
],
),
],
);

View File

@ -1,136 +0,0 @@
import 'package:flutter/material.dart';
import '../../../../common/theme/theme.dart';
import '../../../components/spacer/spacer.dart';
class ReportAction extends StatefulWidget {
const ReportAction({super.key});
@override
State<ReportAction> createState() => _ReportActionState();
}
class _ReportActionState extends State<ReportAction> {
final actions = [
{
'title': 'Laporan Detail Penjualan',
'subtitle': 'Analisis mendalam transaksi harian',
'icon': Icons.assignment,
'color': AppColor.primary,
'gradient': [AppColor.primary, AppColor.primaryLight],
},
{
'title': 'Monitor Stok Produk',
'subtitle': 'Tracking inventory real-time',
'icon': Icons.inventory_2,
'color': AppColor.info,
'gradient': [AppColor.info, const Color(0xFF64B5F6)],
},
{
'title': 'Analisis Keuangan',
'subtitle': 'Profit, loss & cash flow analysis',
'icon': Icons.account_balance_wallet,
'color': AppColor.success,
'gradient': [AppColor.success, AppColor.secondaryLight],
},
];
@override
Widget build(BuildContext context) {
return Column(
children: actions.map((action) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {},
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
(action['color'] as Color).withOpacity(0.1),
(action['color'] as Color).withOpacity(0.05),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: (action['color'] as Color).withOpacity(0.3),
width: 1.5,
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: action['gradient'] as List<Color>,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: (action['color'] as Color).withOpacity(0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Icon(
action['icon'] as IconData,
color: AppColor.white,
size: 28,
),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
action['title'] as String,
style: AppStyle.lg.copyWith(
color: AppColor.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SpaceHeight(4),
Text(
action['subtitle'] as String,
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
fontSize: 13,
),
),
],
),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: (action['color'] as Color).withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.arrow_forward_ios,
color: action['color'] as Color,
size: 16,
),
),
],
),
),
),
),
);
}).toList(),
);
}
}

View File

@ -1,11 +1,18 @@
import 'package:flutter/material.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/analytic/analytic.dart';
import '../../../components/spacer/spacer.dart';
class ReportRevenueSummary extends StatelessWidget {
final DashboardOverview overview;
final Animation<double> rotationAnimation;
const ReportRevenueSummary({super.key, required this.rotationAnimation});
const ReportRevenueSummary({
super.key,
required this.rotationAnimation,
required this.overview,
});
@override
Widget build(BuildContext context) {
@ -106,51 +113,13 @@ class ReportRevenueSummary extends StatelessWidget {
),
const Spacer(),
Text(
'Rp 15.450.000',
overview.totalSales.currencyFormatRp,
style: AppStyle.h1.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.bold,
letterSpacing: -1,
),
),
const SpaceHeight(8),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColor.success.withOpacity(0.9),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.trending_up,
color: AppColor.textWhite,
size: 16,
),
SpaceWidth(4),
Text(
'+12.5%',
style: AppStyle.sm.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w700,
),
),
],
),
),
const SpaceWidth(12),
Text(
'dari periode sebelumnya',
style: AppStyle.sm.copyWith(color: AppColor.textWhite),
),
],
),
],
),
),

View File

@ -1,11 +1,15 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/analytic/analytic.dart';
import '../../../components/spacer/spacer.dart';
import '../../../components/widgets/empty_widget.dart';
class ReportSales extends StatelessWidget {
const ReportSales({super.key});
final List<DashboardRecentSale> salesData;
const ReportSales({super.key, required this.salesData});
@override
Widget build(BuildContext context) {
@ -25,6 +29,7 @@ class ReportSales extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Section
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -32,16 +37,17 @@ class ReportSales extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Grafik Penjualan',
'Sales Chart',
style: AppStyle.xxl.copyWith(
color: AppColor.textPrimary,
fontWeight: FontWeight.bold,
),
),
const SpaceHeight(4),
Text(
'7 hari terakhir',
salesData.isEmpty
? 'No data available'
: '${salesData.length} days overview',
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
),
],
@ -60,233 +66,124 @@ class ReportSales extends StatelessWidget {
),
],
),
const SpaceHeight(20),
// Chart Container
Container(
height: 280,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColor.primary.withOpacity(0.05),
AppColor.backgroundLight,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColor.primary.withOpacity(0.1),
width: 2,
),
),
child: LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawHorizontalLine: true,
drawVerticalLine: false,
horizontalInterval: 500000,
getDrawingHorizontalLine: (value) {
return FlLine(
color: AppColor.border.withOpacity(0.3),
strokeWidth: 1,
dashArray: [5, 5],
);
},
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 60,
getTitlesWidget: (value, meta) {
return Text(
'${(value / 1000000).toStringAsFixed(1)}M',
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 32,
getTitlesWidget: (value, meta) {
const days = [
'Sen',
'Sel',
'Rab',
'Kam',
'Jum',
'Sab',
'Min',
];
if (value.toInt() >= 0 && value.toInt() < days.length) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
days[value.toInt()],
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
),
),
);
}
return const Text('');
},
),
),
rightTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(show: false),
minX: 0,
maxX: 6,
minY: 0,
maxY: 3000000,
lineBarsData: [
// Main sales line
LineChartBarData(
spots: [
const FlSpot(0, 1800000), // Senin
const FlSpot(1, 2200000), // Selasa
const FlSpot(2, 1900000), // Rabu
const FlSpot(3, 2600000), // Kamis
const FlSpot(4, 2300000), // Jumat
const FlSpot(5, 2800000), // Sabtu
const FlSpot(6, 2500000), // Minggu
],
isCurved: true,
curveSmoothness: 0.35,
gradient: LinearGradient(
colors: [AppColor.primary, AppColor.primaryLight],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
barWidth: 4,
isStrokeCapRound: true,
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
AppColor.primary.withOpacity(0.3),
AppColor.primary.withOpacity(0.1),
Colors.transparent,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 6,
color: AppColor.surface,
strokeWidth: 3,
strokeColor: AppColor.primary,
);
},
),
),
// Secondary line for comparison
LineChartBarData(
spots: [
const FlSpot(0, 1500000),
const FlSpot(1, 1800000),
const FlSpot(2, 1600000),
const FlSpot(3, 2100000),
const FlSpot(4, 1900000),
const FlSpot(5, 2300000),
const FlSpot(6, 2100000),
],
isCurved: true,
curveSmoothness: 0.35,
color: AppColor.success.withOpacity(0.7),
barWidth: 3,
isStrokeCapRound: true,
dashArray: [8, 4],
belowBarData: BarAreaData(show: false),
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: AppColor.success,
strokeWidth: 2,
strokeColor: AppColor.surface,
);
},
),
),
],
lineTouchData: LineTouchData(
enabled: true,
touchTooltipData: LineTouchTooltipData(
tooltipPadding: const EdgeInsets.all(12),
getTooltipItems: (List<LineBarSpot> touchedBarSpots) {
return touchedBarSpots.map((barSpot) {
final flSpot = barSpot;
const days = [
'Senin',
'Selasa',
'Rabu',
'Kamis',
'Jumat',
'Sabtu',
'Minggu',
];
// Sales Summary Cards
if (salesData.isNotEmpty) ...[
_buildSalesSummary(),
const SpaceHeight(20),
],
return LineTooltipItem(
'${days[flSpot.x.toInt()]}\n',
const TextStyle(
color: AppColor.textWhite,
fontWeight: FontWeight.bold,
fontSize: 14,
),
children: [
TextSpan(
text:
'Rp ${(flSpot.y / 1000000).toStringAsFixed(1)}M',
style: AppStyle.sm.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w500,
),
),
],
);
}).toList();
},
// Chart Container
salesData.isEmpty
? _buildEmptyChart()
: Container(
height: 300,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColor.primary.withOpacity(0.05),
AppColor.backgroundLight,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColor.primary.withOpacity(0.1),
width: 2,
),
),
touchCallback:
(FlTouchEvent event, LineTouchResponse? touchResponse) {
// Handle touch events here if needed
},
handleBuiltInTouches: true,
child: _buildSalesChart(),
),
),
),
),
const SpaceHeight(16),
// Legend
if (salesData.isNotEmpty)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [_buildLegendItem('Sales Data', AppColor.primary)],
),
],
),
);
}
Widget _buildSalesSummary() {
final totalSales = salesData.fold<int>(0, (sum, item) => sum + item.sales);
final totalOrders = salesData.fold<int>(
0,
(sum, item) => sum + item.orders,
);
final totalItems = salesData.fold<int>(0, (sum, item) => sum + item.items);
final totalNetSales = salesData.fold<int>(
0,
(sum, item) => sum + item.netSales,
);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.backgroundLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColor.border.withOpacity(0.3), width: 1),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLegendItem('Minggu Ini', AppColor.primary),
const SpaceWidth(24),
_buildLegendItem('Minggu Lalu', AppColor.success),
Expanded(
child: _buildSummaryItem(
'Total Sales',
totalSales.currencyFormatRp,
Icons.attach_money,
AppColor.success,
),
),
Container(
width: 1,
height: 40,
color: AppColor.border.withOpacity(0.3),
margin: const EdgeInsets.symmetric(horizontal: 16),
),
Expanded(
child: _buildSummaryItem(
'Net Sales',
totalNetSales.currencyFormatRp,
Icons.trending_up,
AppColor.primary,
),
),
],
),
const SpaceHeight(16),
Row(
children: [
Expanded(
child: _buildSummaryItem(
'Total Orders',
totalOrders.toString(),
Icons.shopping_cart,
AppColor.info,
),
),
Container(
width: 1,
height: 40,
color: AppColor.border.withOpacity(0.3),
margin: const EdgeInsets.symmetric(horizontal: 16),
),
Expanded(
child: _buildSummaryItem(
'Total Items',
totalItems.toString(),
Icons.inventory,
AppColor.warning,
),
),
],
),
],
@ -294,6 +191,251 @@ class ReportSales extends StatelessWidget {
);
}
Widget _buildSummaryItem(
String label,
String value,
IconData icon,
Color color,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 16, color: color),
const SpaceWidth(6),
Text(
label,
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
const SpaceHeight(6),
Text(
value,
style: AppStyle.md.copyWith(
color: color,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
Widget _buildSalesChart() {
final maxValue = _getMaxValue();
final spots = _generateSpots(salesData);
return LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawHorizontalLine: true,
drawVerticalLine: false,
horizontalInterval: maxValue / 5,
getDrawingHorizontalLine: (value) {
return FlLine(
color: AppColor.border.withOpacity(0.3),
strokeWidth: 1,
dashArray: [5, 5],
);
},
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 70,
getTitlesWidget: (value, meta) {
return Text(
_formatCurrency(value),
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 32,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index >= 0 && index < salesData.length) {
final date = DateTime.parse(salesData[index].date);
final dayName = _getDayName(date.weekday);
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
dayName,
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
),
),
);
}
return const Text('');
},
),
),
rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: false),
minX: 0,
maxX: (salesData.length - 1).toDouble(),
minY: 0,
maxY: maxValue,
lineBarsData: [
// Main sales line
LineChartBarData(
spots: spots,
isCurved: true,
curveSmoothness: 0.35,
gradient: LinearGradient(
colors: [AppColor.primary, AppColor.primaryLight],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
barWidth: 4,
isStrokeCapRound: true,
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
AppColor.primary.withOpacity(0.3),
AppColor.primary.withOpacity(0.1),
Colors.transparent,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 6,
color: AppColor.surface,
strokeWidth: 3,
strokeColor: AppColor.primary,
);
},
),
),
],
lineTouchData: LineTouchData(
enabled: true,
touchTooltipData: LineTouchTooltipData(
tooltipPadding: const EdgeInsets.all(12),
getTooltipItems: (List<LineBarSpot> touchedBarSpots) {
return touchedBarSpots
.map((barSpot) {
final index = barSpot.x.toInt();
if (index >= 0 && index < salesData.length) {
final sale = salesData[index];
final date = DateTime.parse(sale.date);
final dayName = _getDayName(date.weekday);
return LineTooltipItem(
'$dayName\n',
const TextStyle(
color: AppColor.textWhite,
fontWeight: FontWeight.bold,
fontSize: 14,
),
children: [
TextSpan(
text: 'Sales: ${sale.sales.currencyFormatRp}\n',
style: AppStyle.sm.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w500,
),
),
TextSpan(
text: 'Orders: ${sale.orders}\n',
style: AppStyle.sm.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w400,
),
),
TextSpan(
text: 'Net: ${sale.netSales.currencyFormatRp}',
style: AppStyle.sm.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w400,
),
),
],
);
}
return null;
})
.where((item) => item != null)
.cast<LineTooltipItem>()
.toList();
},
),
touchCallback:
(FlTouchEvent event, LineTouchResponse? touchResponse) {
// Handle touch events here if needed
},
handleBuiltInTouches: true,
),
),
);
}
Widget _buildEmptyChart() {
return EmptyWidget(
title: 'No Sales Data',
message: 'Sales data will appear here once transactions are recorded',
emptyIcon: Icons.show_chart,
);
}
List<FlSpot> _generateSpots(List<DashboardRecentSale> data) {
return data.asMap().entries.map((entry) {
final index = entry.key;
final sale = entry.value;
return FlSpot(index.toDouble(), sale.sales.toDouble());
}).toList();
}
double _getMaxValue() {
if (salesData.isEmpty) return 1000000;
double maxValue = salesData
.map((e) => e.sales.toDouble())
.reduce((a, b) => a > b ? a : b);
// Add 20% padding to max value
return maxValue * 1.2;
}
String _formatCurrency(double value) {
if (value >= 1000000) {
return '${(value / 1000000).toStringAsFixed(1)}M';
} else if (value >= 1000) {
return '${(value / 1000).toStringAsFixed(0)}K';
}
return value.toStringAsFixed(0);
}
String _getDayName(int weekday) {
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
return days[weekday - 1];
}
Widget _buildLegendItem(String label, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
@ -311,7 +453,6 @@ class ReportSales extends StatelessWidget {
label,
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
),
),

View File

@ -7,7 +7,6 @@ class ReportStatTile extends StatelessWidget {
final String value;
final IconData icon;
final Color color;
final String change;
final double animatedValue;
const ReportStatTile({
super.key,
@ -15,7 +14,6 @@ class ReportStatTile extends StatelessWidget {
required this.value,
required this.icon,
required this.color,
required this.change,
required this.animatedValue,
});
@ -53,20 +51,6 @@ class ReportStatTile extends StatelessWidget {
child: Icon(icon, color: color, size: 24),
),
const Spacer(),
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.sm.copyWith(
color: AppColor.success,
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 16),

View File

@ -1,10 +1,13 @@
import 'package:flutter/material.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/analytic/analytic.dart';
import '../../../components/spacer/spacer.dart';
class ReportTopProduct extends StatelessWidget {
const ReportTopProduct({super.key});
final List<DashboardTopProduct> products;
const ReportTopProduct({super.key, required this.products});
@override
Widget build(BuildContext context) {
@ -59,39 +62,25 @@ class ReportTopProduct extends StatelessWidget {
],
),
const SpaceHeight(20),
_buildEnhancedProductItem(
'Kopi Americano',
'Rp 25.000',
'145 terjual',
1,
),
_buildEnhancedProductItem(
'Nasi Goreng Spesial',
'Rp 35.000',
'98 terjual',
2,
),
_buildEnhancedProductItem(
'Mie Ayam Bakso',
'Rp 28.000',
'87 terjual',
3,
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
return _buildEnhancedProductItem(products[index], index + 1);
},
itemCount: products.length,
),
],
),
);
}
Widget _buildEnhancedProductItem(
String name,
String price,
String sold,
int rank,
) {
Widget _buildEnhancedProductItem(DashboardTopProduct product, int rank) {
final isFirst = rank == 1;
final isTopThree = rank <= 3;
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: isFirst
? LinearGradient(
@ -102,100 +91,478 @@ class ReportTopProduct extends StatelessWidget {
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: isTopThree
? LinearGradient(
colors: [
AppColor.primary.withOpacity(0.08),
AppColor.primary.withOpacity(0.03),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: null,
color: isFirst ? null : AppColor.backgroundLight,
borderRadius: BorderRadius.circular(16),
color: isTopThree ? null : AppColor.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isFirst ? AppColor.warning.withOpacity(0.3) : AppColor.border,
color: isFirst
? AppColor.warning.withOpacity(0.3)
: isTopThree
? AppColor.primary.withOpacity(0.2)
: AppColor.border.withOpacity(0.3),
width: isFirst ? 2 : 1,
),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: isFirst
? const LinearGradient(
colors: [AppColor.warning, Color(0xFFFFB74D)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: LinearGradient(
colors: [
AppColor.primary.withOpacity(0.8),
AppColor.primaryLight.withOpacity(0.6),
],
),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: isFirst
? AppColor.warning.withOpacity(0.3)
: AppColor.primary.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Center(
child: isFirst
? const Icon(
Icons.emoji_events,
color: AppColor.white,
size: 24,
)
: Text(
rank.toString(),
style: AppStyle.xl.copyWith(
color: AppColor.white,
fontWeight: FontWeight.bold,
),
),
),
boxShadow: [
BoxShadow(
color: isFirst
? AppColor.warning.withOpacity(0.15)
: isTopThree
? AppColor.primary.withOpacity(0.1)
: Colors.black.withOpacity(0.04),
blurRadius: isFirst ? 16 : 12,
offset: const Offset(0, 4),
spreadRadius: isFirst ? -2 : -3,
),
const SpaceWidth(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: AppStyle.lg.copyWith(
color: AppColor.textPrimary,
fontWeight: FontWeight.w600,
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Stack(
children: [
// Top accent line for rank 1-3
if (isTopThree)
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
height: 4,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isFirst
? [
AppColor.warning,
AppColor.warning.withOpacity(0.7),
]
: [
AppColor.primary,
AppColor.primary.withOpacity(0.7),
],
),
),
),
const SpaceHeight(4),
Row(
children: [
Icon(
Icons.shopping_cart,
size: 14,
color: AppColor.textSecondary,
),
// Main content
Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with rank and product info
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Rank badge
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
gradient: isFirst
? const LinearGradient(
colors: [AppColor.warning, Color(0xFFFFB74D)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: isTopThree
? LinearGradient(
colors: [
AppColor.primary,
AppColor.primary.withOpacity(0.8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: LinearGradient(
colors: [
AppColor.textSecondary.withOpacity(0.8),
AppColor.textSecondary.withOpacity(0.6),
],
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: isFirst
? AppColor.warning.withOpacity(0.3)
: isTopThree
? AppColor.primary.withOpacity(0.3)
: AppColor.textSecondary.withOpacity(0.2),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Center(
child: isFirst
? const Icon(
Icons.emoji_events,
color: AppColor.white,
size: 28,
)
: rank == 2
? const Icon(
Icons.workspace_premium,
color: AppColor.white,
size: 26,
)
: rank == 3
? const Icon(
Icons.military_tech,
color: AppColor.white,
size: 26,
)
: Text(
rank.toString(),
style: AppStyle.xl.copyWith(
color: AppColor.white,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
),
const SpaceWidth(16),
// Product info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.productName,
style: AppStyle.lg.copyWith(
color: AppColor.textPrimary,
fontWeight: FontWeight.bold,
fontSize: 16,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SpaceHeight(6),
// Category badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: isFirst
? AppColor.warning.withOpacity(0.1)
: AppColor.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isFirst
? AppColor.warning.withOpacity(0.3)
: AppColor.primary.withOpacity(0.3),
width: 1,
),
),
child: Text(
product.categoryName,
style: AppStyle.xs.copyWith(
color: isFirst
? AppColor.warning
: AppColor.primary,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
const SpaceHeight(16),
// Statistics section
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColor.background.withOpacity(0.5),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: AppColor.border.withOpacity(0.2),
width: 1,
),
),
const SpaceWidth(4),
Text(
sold,
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
child: Column(
children: [
// Revenue and Average Price
IntrinsicHeight(
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.attach_money,
size: 14,
color: AppColor.success,
),
const SpaceWidth(3),
Flexible(
child: Text(
'Revenue',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
fontSize: 11,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SpaceHeight(4),
Text(
product.revenue.currencyFormatRp,
style: AppStyle.sm.copyWith(
color: AppColor.success,
fontWeight: FontWeight.bold,
fontSize: 13,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Container(
width: 1,
margin: const EdgeInsets.symmetric(
horizontal: 10,
),
color: AppColor.border.withOpacity(0.3),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
'Avg. Price',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
fontSize: 11,
),
overflow: TextOverflow.ellipsis,
),
),
const SpaceWidth(3),
Icon(
Icons.trending_up,
size: 14,
color: AppColor.primary,
),
],
),
const SpaceHeight(4),
Text(
product.averagePrice
.round()
.currencyFormatRp,
style: AppStyle.sm.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.bold,
fontSize: 13,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
),
],
),
),
],
),
),
const SpaceHeight(10),
// Quantity Sold and Order Count
IntrinsicHeight(
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.inventory,
size: 14,
color: AppColor.warning,
),
const SpaceWidth(3),
Flexible(
child: Text(
'Sold',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
fontSize: 11,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SpaceHeight(4),
Text(
'${product.quantitySold}',
style: AppStyle.sm.copyWith(
color: AppColor.warning,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
),
Container(
width: 1,
margin: const EdgeInsets.symmetric(
horizontal: 10,
),
color: AppColor.border.withOpacity(0.3),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
'Orders',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
fontSize: 11,
),
overflow: TextOverflow.ellipsis,
),
),
const SpaceWidth(3),
Icon(
Icons.shopping_cart,
size: 14,
color: AppColor.info,
),
],
),
const SpaceHeight(4),
Text(
'${product.orderCount}',
style: AppStyle.sm.copyWith(
color: AppColor.info,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
),
],
),
),
],
),
),
// Performance indicator for top 3
if (isTopThree) ...[
const SpaceHeight(10),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isFirst
? [
AppColor.warning.withOpacity(0.2),
AppColor.warning.withOpacity(0.1),
]
: [
AppColor.primary.withOpacity(0.2),
AppColor.primary.withOpacity(0.1),
],
),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isFirst
? AppColor.warning.withOpacity(0.3)
: AppColor.primary.withOpacity(0.3),
width: 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isFirst ? Icons.star : Icons.trending_up,
size: 14,
color: isFirst
? AppColor.warning
: AppColor.primary,
),
const SpaceWidth(5),
Flexible(
child: Text(
isFirst ? 'Best Seller' : 'Top Performer',
style: AppStyle.xs.copyWith(
color: isFirst
? AppColor.warning
: AppColor.primary,
fontWeight: FontWeight.bold,
fontSize: 11,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
],
],
),
),
),
Text(
price,
style: AppStyle.lg.copyWith(
color: isFirst ? AppColor.warning : AppColor.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
],
),
),
);
}

View File

@ -57,7 +57,7 @@ class CustomerRoute extends _i18.PageRouteInfo<void> {
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
return const _i1.CustomerPage();
return _i18.WrappedRoute(child: const _i1.CustomerPage());
},
);
}
@ -199,7 +199,7 @@ class InventoryRoute extends _i18.PageRouteInfo<void> {
static _i18.PageInfo page = _i18.PageInfo(
name,
builder: (data) {
return const _i6.InventoryPage();
return _i18.WrappedRoute(child: const _i6.InventoryPage());
},
);
}