feat: sales analytic

This commit is contained in:
efrilm 2025-08-17 10:10:31 +07:00
parent e7525238fe
commit f84090c0e6
21 changed files with 3703 additions and 407 deletions

View File

@ -0,0 +1,39 @@
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 'sales_loader_event.dart';
part 'sales_loader_state.dart';
part 'sales_loader_bloc.freezed.dart';
@injectable
class SalesLoaderBloc extends Bloc<SalesLoaderEvent, SalesLoaderState> {
final IAnalyticRepository _analyticRepository;
SalesLoaderBloc(this._analyticRepository)
: super(SalesLoaderState.initial()) {
on<SalesLoaderEvent>(_onSalesLoaderEvent);
}
Future<void> _onSalesLoaderEvent(
SalesLoaderEvent event,
Emitter<SalesLoaderState> emit,
) async {
emit(state.copyWith(isFetching: true, failureOptionSales: none()));
final result = await _analyticRepository.getSales(
dateFrom: DateTime.now().subtract(const Duration(days: 30)),
dateTo: DateTime.now(),
);
var data = result.fold(
(f) => state.copyWith(failureOptionSales: optionOf(f)),
(sales) => state.copyWith(sales: sales),
);
emit(data.copyWith(isFetching: false));
}
}

View File

@ -0,0 +1,376 @@
// 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 'sales_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 _$SalesLoaderEvent {
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function() fectched,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? fectched,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? fectched,
required TResult orElse(),
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(_Fectched value) fectched,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(_Fectched value)? fectched,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(_Fectched value)? fectched,
required TResult orElse(),
}) => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SalesLoaderEventCopyWith<$Res> {
factory $SalesLoaderEventCopyWith(
SalesLoaderEvent value,
$Res Function(SalesLoaderEvent) then,
) = _$SalesLoaderEventCopyWithImpl<$Res, SalesLoaderEvent>;
}
/// @nodoc
class _$SalesLoaderEventCopyWithImpl<$Res, $Val extends SalesLoaderEvent>
implements $SalesLoaderEventCopyWith<$Res> {
_$SalesLoaderEventCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SalesLoaderEvent
/// with the given fields replaced by the non-null parameter values.
}
/// @nodoc
abstract class _$$FectchedImplCopyWith<$Res> {
factory _$$FectchedImplCopyWith(
_$FectchedImpl value,
$Res Function(_$FectchedImpl) then,
) = __$$FectchedImplCopyWithImpl<$Res>;
}
/// @nodoc
class __$$FectchedImplCopyWithImpl<$Res>
extends _$SalesLoaderEventCopyWithImpl<$Res, _$FectchedImpl>
implements _$$FectchedImplCopyWith<$Res> {
__$$FectchedImplCopyWithImpl(
_$FectchedImpl _value,
$Res Function(_$FectchedImpl) _then,
) : super(_value, _then);
/// Create a copy of SalesLoaderEvent
/// with the given fields replaced by the non-null parameter values.
}
/// @nodoc
class _$FectchedImpl implements _Fectched {
const _$FectchedImpl();
@override
String toString() {
return 'SalesLoaderEvent.fectched()';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType && other is _$FectchedImpl);
}
@override
int get hashCode => runtimeType.hashCode;
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function() fectched,
}) {
return fectched();
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? fectched,
}) {
return fectched?.call();
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? fectched,
required TResult orElse(),
}) {
if (fectched != null) {
return fectched();
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(_Fectched value) fectched,
}) {
return fectched(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(_Fectched value)? fectched,
}) {
return fectched?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(_Fectched value)? fectched,
required TResult orElse(),
}) {
if (fectched != null) {
return fectched(this);
}
return orElse();
}
}
abstract class _Fectched implements SalesLoaderEvent {
const factory _Fectched() = _$FectchedImpl;
}
/// @nodoc
mixin _$SalesLoaderState {
SalesAnalytic get sales => throw _privateConstructorUsedError;
Option<AnalyticFailure> get failureOptionSales =>
throw _privateConstructorUsedError;
bool get isFetching => throw _privateConstructorUsedError;
/// Create a copy of SalesLoaderState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SalesLoaderStateCopyWith<SalesLoaderState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SalesLoaderStateCopyWith<$Res> {
factory $SalesLoaderStateCopyWith(
SalesLoaderState value,
$Res Function(SalesLoaderState) then,
) = _$SalesLoaderStateCopyWithImpl<$Res, SalesLoaderState>;
@useResult
$Res call({
SalesAnalytic sales,
Option<AnalyticFailure> failureOptionSales,
bool isFetching,
});
$SalesAnalyticCopyWith<$Res> get sales;
}
/// @nodoc
class _$SalesLoaderStateCopyWithImpl<$Res, $Val extends SalesLoaderState>
implements $SalesLoaderStateCopyWith<$Res> {
_$SalesLoaderStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of SalesLoaderState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? sales = null,
Object? failureOptionSales = null,
Object? isFetching = null,
}) {
return _then(
_value.copyWith(
sales: null == sales
? _value.sales
: sales // ignore: cast_nullable_to_non_nullable
as SalesAnalytic,
failureOptionSales: null == failureOptionSales
? _value.failureOptionSales
: failureOptionSales // 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 SalesLoaderState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SalesAnalyticCopyWith<$Res> get sales {
return $SalesAnalyticCopyWith<$Res>(_value.sales, (value) {
return _then(_value.copyWith(sales: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$SalesLoaderStateImplCopyWith<$Res>
implements $SalesLoaderStateCopyWith<$Res> {
factory _$$SalesLoaderStateImplCopyWith(
_$SalesLoaderStateImpl value,
$Res Function(_$SalesLoaderStateImpl) then,
) = __$$SalesLoaderStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
SalesAnalytic sales,
Option<AnalyticFailure> failureOptionSales,
bool isFetching,
});
@override
$SalesAnalyticCopyWith<$Res> get sales;
}
/// @nodoc
class __$$SalesLoaderStateImplCopyWithImpl<$Res>
extends _$SalesLoaderStateCopyWithImpl<$Res, _$SalesLoaderStateImpl>
implements _$$SalesLoaderStateImplCopyWith<$Res> {
__$$SalesLoaderStateImplCopyWithImpl(
_$SalesLoaderStateImpl _value,
$Res Function(_$SalesLoaderStateImpl) _then,
) : super(_value, _then);
/// Create a copy of SalesLoaderState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? sales = null,
Object? failureOptionSales = null,
Object? isFetching = null,
}) {
return _then(
_$SalesLoaderStateImpl(
sales: null == sales
? _value.sales
: sales // ignore: cast_nullable_to_non_nullable
as SalesAnalytic,
failureOptionSales: null == failureOptionSales
? _value.failureOptionSales
: failureOptionSales // 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 _$SalesLoaderStateImpl implements _SalesLoaderState {
const _$SalesLoaderStateImpl({
required this.sales,
required this.failureOptionSales,
this.isFetching = false,
});
@override
final SalesAnalytic sales;
@override
final Option<AnalyticFailure> failureOptionSales;
@override
@JsonKey()
final bool isFetching;
@override
String toString() {
return 'SalesLoaderState(sales: $sales, failureOptionSales: $failureOptionSales, isFetching: $isFetching)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SalesLoaderStateImpl &&
(identical(other.sales, sales) || other.sales == sales) &&
(identical(other.failureOptionSales, failureOptionSales) ||
other.failureOptionSales == failureOptionSales) &&
(identical(other.isFetching, isFetching) ||
other.isFetching == isFetching));
}
@override
int get hashCode =>
Object.hash(runtimeType, sales, failureOptionSales, isFetching);
/// Create a copy of SalesLoaderState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SalesLoaderStateImplCopyWith<_$SalesLoaderStateImpl> get copyWith =>
__$$SalesLoaderStateImplCopyWithImpl<_$SalesLoaderStateImpl>(
this,
_$identity,
);
}
abstract class _SalesLoaderState implements SalesLoaderState {
const factory _SalesLoaderState({
required final SalesAnalytic sales,
required final Option<AnalyticFailure> failureOptionSales,
final bool isFetching,
}) = _$SalesLoaderStateImpl;
@override
SalesAnalytic get sales;
@override
Option<AnalyticFailure> get failureOptionSales;
@override
bool get isFetching;
/// Create a copy of SalesLoaderState
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SalesLoaderStateImplCopyWith<_$SalesLoaderStateImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,6 @@
part of 'sales_loader_bloc.dart';
@freezed
class SalesLoaderEvent with _$SalesLoaderEvent {
const factory SalesLoaderEvent.fectched() = _Fectched;
}

View File

@ -0,0 +1,15 @@
part of 'sales_loader_bloc.dart';
@freezed
class SalesLoaderState with _$SalesLoaderState {
const factory SalesLoaderState({
required SalesAnalytic sales,
required Option<AnalyticFailure> failureOptionSales,
@Default(false) bool isFetching,
}) = _SalesLoaderState;
factory SalesLoaderState.initial() => SalesLoaderState(
sales: SalesAnalytic.empty(),
failureOptionSales: none(),
);
}

View File

@ -2,4 +2,7 @@ class ApiPath {
// Auth // Auth
static const String login = '/api/v1/auth/login'; static const String login = '/api/v1/auth/login';
static const String logout = '/api/v1/auth/logout'; static const String logout = '/api/v1/auth/logout';
// Analytic
static const String salesAnalytic = '/api/v1/analytics/sales';
} }

View File

@ -0,0 +1,8 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../common/api/api_failure.dart';
part 'analytic.freezed.dart';
part 'entities/sales_analytic_entity.dart';
part 'failures/analytic_failure.dart';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,70 @@
part of '../analytic.dart';
@freezed
class SalesAnalytic with _$SalesAnalytic {
const factory SalesAnalytic({
required String organizationId,
required String outletId,
required DateTime dateFrom,
required DateTime dateTo,
required String groupBy,
required SalesAnalyticSummary summary,
required List<SalesAnalyticData> data,
}) = _SalesAnalytic;
factory SalesAnalytic.empty() => SalesAnalytic(
organizationId: '',
outletId: '',
dateFrom: DateTime.fromMillisecondsSinceEpoch(0),
dateTo: DateTime.fromMillisecondsSinceEpoch(0),
groupBy: '',
summary: SalesAnalyticSummary.empty(),
data: [],
);
}
@freezed
class SalesAnalyticSummary with _$SalesAnalyticSummary {
const factory SalesAnalyticSummary({
required int totalSales,
required int totalOrders,
required int totalItems,
required double averageOrderValue,
required int totalTax,
required int totalDiscount,
required int netSales,
}) = _SalesAnalyticSummary;
factory SalesAnalyticSummary.empty() => const SalesAnalyticSummary(
totalSales: 0,
totalOrders: 0,
totalItems: 0,
averageOrderValue: 0,
totalTax: 0,
totalDiscount: 0,
netSales: 0,
);
}
@freezed
class SalesAnalyticData with _$SalesAnalyticData {
const factory SalesAnalyticData({
required DateTime date,
required int sales,
required int orders,
required int items,
required int tax,
required int discount,
required int netSales,
}) = _SalesAnalyticData;
factory SalesAnalyticData.empty() => SalesAnalyticData(
date: DateTime.fromMillisecondsSinceEpoch(0),
sales: 0,
orders: 0,
items: 0,
tax: 0,
discount: 0,
netSales: 0,
);
}

View File

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

View File

@ -0,0 +1,10 @@
import 'package:dartz/dartz.dart';
import '../analytic.dart';
abstract class IAnalyticRepository {
Future<Either<AnalyticFailure, SalesAnalytic>> getSales({
required DateTime dateFrom,
required DateTime dateTo,
});
}

View File

@ -0,0 +1,8 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/analytic/analytic.dart';
part 'analytic_dtos.freezed.dart';
part 'analytic_dtos.g.dart';
part 'dto/sales_analytic_dto.dart';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,89 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'analytic_dtos.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SalesAnalyticDtoImpl _$$SalesAnalyticDtoImplFromJson(
Map<String, dynamic> json,
) => _$SalesAnalyticDtoImpl(
organizationId: json['organization_id'] as String?,
outletId: json['outlet_id'] as String?,
dateFrom: json['date_from'] == null
? null
: DateTime.parse(json['date_from'] as String),
dateTo: json['date_to'] == null
? null
: DateTime.parse(json['date_to'] as String),
groupBy: json['group_by'] as String?,
summary: json['summary'] == null
? null
: SalesAnalyticSummaryDto.fromJson(
json['summary'] as Map<String, dynamic>,
),
data: (json['data'] as List<dynamic>?)
?.map((e) => SalesAnalyticDataDto.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$$SalesAnalyticDtoImplToJson(
_$SalesAnalyticDtoImpl instance,
) => <String, dynamic>{
'organization_id': instance.organizationId,
'outlet_id': instance.outletId,
'date_from': instance.dateFrom?.toIso8601String(),
'date_to': instance.dateTo?.toIso8601String(),
'group_by': instance.groupBy,
'summary': instance.summary,
'data': instance.data,
};
_$SalesAnalyticSummaryDtoImpl _$$SalesAnalyticSummaryDtoImplFromJson(
Map<String, dynamic> json,
) => _$SalesAnalyticSummaryDtoImpl(
totalSales: json['total_sales'] as num?,
totalOrders: json['total_orders'] as num?,
totalItems: json['total_items'] as num?,
averageOrderValue: json['average_order_value'] as num?,
totalTax: json['total_tax'] as num?,
totalDiscount: json['total_discount'] as num?,
netSales: json['net_sales'] as num?,
);
Map<String, dynamic> _$$SalesAnalyticSummaryDtoImplToJson(
_$SalesAnalyticSummaryDtoImpl instance,
) => <String, dynamic>{
'total_sales': instance.totalSales,
'total_orders': instance.totalOrders,
'total_items': instance.totalItems,
'average_order_value': instance.averageOrderValue,
'total_tax': instance.totalTax,
'total_discount': instance.totalDiscount,
'net_sales': instance.netSales,
};
_$SalesAnalyticDataDtoImpl _$$SalesAnalyticDataDtoImplFromJson(
Map<String, dynamic> json,
) => _$SalesAnalyticDataDtoImpl(
date: json['date'] == null ? null : DateTime.parse(json['date'] as String),
sales: json['sales'] as num?,
orders: json['orders'] as num?,
items: json['items'] as num?,
tax: json['tax'] as num?,
discount: json['discount'] as num?,
netSales: json['net_sales'] as num?,
);
Map<String, dynamic> _$$SalesAnalyticDataDtoImplToJson(
_$SalesAnalyticDataDtoImpl instance,
) => <String, dynamic>{
'date': instance.date?.toIso8601String(),
'sales': instance.sales,
'orders': instance.orders,
'items': instance.items,
'tax': instance.tax,
'discount': instance.discount,
'net_sales': instance.netSales,
};

View File

@ -0,0 +1,45 @@
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/extension/extension.dart';
import '../../../common/url/api_path.dart';
import '../../../domain/analytic/analytic.dart';
import '../analytic_dtos.dart';
@injectable
class AnalyticRemoteDataProvider {
final ApiClient _apiClient;
final String _logName = "AnalyticRemoteDataProvider";
AnalyticRemoteDataProvider(this._apiClient);
Future<DC<AnalyticFailure, SalesAnalyticDto>> fetchSales({
required DateTime dateFrom,
required DateTime dateTo,
}) async {
try {
final response = await _apiClient.get(
ApiPath.salesAnalytic,
params: {
'date_from': dateFrom.toServerDate,
'date_to': dateTo.toServerDate,
},
);
if (response.data['data'] == null) {
return DC.error(AnalyticFailure.empty());
}
final dto = SalesAnalyticDto.fromJson(response.data['data']);
return DC.data(dto);
} on ApiFailure catch (e, s) {
log('fetchSalesError', name: _logName, error: e, stackTrace: s);
return DC.error(AnalyticFailure.serverError(e));
}
}
}

View File

@ -0,0 +1,85 @@
part of '../analytic_dtos.dart';
@freezed
class SalesAnalyticDto with _$SalesAnalyticDto {
const SalesAnalyticDto._();
const factory SalesAnalyticDto({
@JsonKey(name: 'organization_id') String? organizationId,
@JsonKey(name: 'outlet_id') String? outletId,
@JsonKey(name: 'date_from') DateTime? dateFrom,
@JsonKey(name: 'date_to') DateTime? dateTo,
@JsonKey(name: 'group_by') String? groupBy,
@JsonKey(name: 'summary') SalesAnalyticSummaryDto? summary,
@JsonKey(name: 'data') List<SalesAnalyticDataDto>? data,
}) = _SalesAnalyticDto;
factory SalesAnalyticDto.fromJson(Map<String, dynamic> json) =>
_$SalesAnalyticDtoFromJson(json);
SalesAnalytic toDomain() => SalesAnalytic(
organizationId: organizationId ?? '',
outletId: outletId ?? '',
dateFrom: dateFrom ?? DateTime.fromMillisecondsSinceEpoch(0),
dateTo: dateTo ?? DateTime.fromMillisecondsSinceEpoch(0),
groupBy: groupBy ?? '',
summary: summary?.toDomain() ?? SalesAnalyticSummary.empty(),
data: data?.map((e) => e.toDomain()).toList() ?? [],
);
}
@freezed
class SalesAnalyticSummaryDto with _$SalesAnalyticSummaryDto {
const SalesAnalyticSummaryDto._();
const factory SalesAnalyticSummaryDto({
@JsonKey(name: 'total_sales') num? totalSales,
@JsonKey(name: 'total_orders') num? totalOrders,
@JsonKey(name: 'total_items') num? totalItems,
@JsonKey(name: 'average_order_value') num? averageOrderValue,
@JsonKey(name: 'total_tax') num? totalTax,
@JsonKey(name: 'total_discount') num? totalDiscount,
@JsonKey(name: 'net_sales') num? netSales,
}) = _SalesAnalyticSummaryDto;
factory SalesAnalyticSummaryDto.fromJson(Map<String, dynamic> json) =>
_$SalesAnalyticSummaryDtoFromJson(json);
SalesAnalyticSummary toDomain() => SalesAnalyticSummary(
totalSales: totalSales?.toInt() ?? 0,
totalOrders: totalOrders?.toInt() ?? 0,
totalItems: totalItems?.toInt() ?? 0,
averageOrderValue: averageOrderValue?.toDouble() ?? 0,
totalTax: totalTax?.toInt() ?? 0,
totalDiscount: totalDiscount?.toInt() ?? 0,
netSales: netSales?.toInt() ?? 0,
);
}
@freezed
class SalesAnalyticDataDto with _$SalesAnalyticDataDto {
const SalesAnalyticDataDto._();
const factory SalesAnalyticDataDto({
@JsonKey(name: 'date') DateTime? date,
@JsonKey(name: 'sales') num? sales,
@JsonKey(name: 'orders') num? orders,
@JsonKey(name: 'items') num? items,
@JsonKey(name: 'tax') num? tax,
@JsonKey(name: 'discount') num? discount,
@JsonKey(name: 'net_sales') num? netSales,
}) = _SalesAnalyticDataDto;
factory SalesAnalyticDataDto.fromJson(Map<String, dynamic> json) =>
_$SalesAnalyticDataDtoFromJson(json);
SalesAnalyticData toDomain() => SalesAnalyticData(
date: date ?? DateTime.fromMillisecondsSinceEpoch(0),
sales: sales?.toInt() ?? 0,
orders: orders?.toInt() ?? 0,
items: items?.toInt() ?? 0,
tax: tax?.toInt() ?? 0,
discount: discount?.toInt() ?? 0,
netSales: netSales?.toInt() ?? 0,
);
}

View File

@ -0,0 +1,40 @@
import 'dart:developer';
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../domain/analytic/analytic.dart';
import '../../../domain/analytic/repositories/i_analytic_repository.dart';
import '../datasource/remote_data_provider.dart';
@Injectable(as: IAnalyticRepository)
class AnalyticRepository implements IAnalyticRepository {
final AnalyticRemoteDataProvider _dataProvider;
final String _logName = 'AnalyticRepository';
AnalyticRepository(this._dataProvider);
@override
Future<Either<AnalyticFailure, SalesAnalytic>> getSales({
required DateTime dateFrom,
required DateTime dateTo,
}) async {
try {
final result = await _dataProvider.fetchSales(
dateFrom: dateFrom,
dateTo: dateTo,
);
if (result.hasError) {
return left(result.error!);
}
final auth = result.data!.toDomain();
return right(auth);
} catch (e, s) {
log('getSalesError', name: _logName, error: e, stackTrace: s);
return left(const AnalyticFailure.unexpectedError());
}
}
}

View File

@ -16,6 +16,8 @@ import 'package:apskel_owner_flutter/application/auth/logout_form/logout_form_bl
as _i574; as _i574;
import 'package:apskel_owner_flutter/application/language/language_bloc.dart' import 'package:apskel_owner_flutter/application/language/language_bloc.dart'
as _i455; as _i455;
import 'package:apskel_owner_flutter/application/sales/sales_loader/sales_loader_bloc.dart'
as _i882;
import 'package:apskel_owner_flutter/common/api/api_client.dart' as _i115; import 'package:apskel_owner_flutter/common/api/api_client.dart' as _i115;
import 'package:apskel_owner_flutter/common/di/di_auto_route.dart' as _i311; import 'package:apskel_owner_flutter/common/di/di_auto_route.dart' as _i311;
import 'package:apskel_owner_flutter/common/di/di_connectivity.dart' as _i586; import 'package:apskel_owner_flutter/common/di/di_connectivity.dart' as _i586;
@ -25,8 +27,14 @@ import 'package:apskel_owner_flutter/common/di/di_shared_preferences.dart'
as _i402; as _i402;
import 'package:apskel_owner_flutter/common/network/network_client.dart' import 'package:apskel_owner_flutter/common/network/network_client.dart'
as _i543; as _i543;
import 'package:apskel_owner_flutter/domain/analytic/repositories/i_analytic_repository.dart'
as _i477;
import 'package:apskel_owner_flutter/domain/auth/auth.dart' as _i49; import 'package:apskel_owner_flutter/domain/auth/auth.dart' as _i49;
import 'package:apskel_owner_flutter/env.dart' as _i6; import 'package:apskel_owner_flutter/env.dart' as _i6;
import 'package:apskel_owner_flutter/infrastructure/analytic/datasource/remote_data_provider.dart'
as _i866;
import 'package:apskel_owner_flutter/infrastructure/analytic/repositories/analytic_repository.dart'
as _i393;
import 'package:apskel_owner_flutter/infrastructure/auth/datasources/local_data_provider.dart' import 'package:apskel_owner_flutter/infrastructure/auth/datasources/local_data_provider.dart'
as _i991; as _i991;
import 'package:apskel_owner_flutter/infrastructure/auth/datasources/remote_data_provider.dart' import 'package:apskel_owner_flutter/infrastructure/auth/datasources/remote_data_provider.dart'
@ -89,12 +97,21 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i17.AuthRemoteDataProvider>( gh.factory<_i17.AuthRemoteDataProvider>(
() => _i17.AuthRemoteDataProvider(gh<_i115.ApiClient>()), () => _i17.AuthRemoteDataProvider(gh<_i115.ApiClient>()),
); );
gh.factory<_i866.AnalyticRemoteDataProvider>(
() => _i866.AnalyticRemoteDataProvider(gh<_i115.ApiClient>()),
);
gh.factory<_i477.IAnalyticRepository>(
() => _i393.AnalyticRepository(gh<_i866.AnalyticRemoteDataProvider>()),
);
gh.factory<_i49.IAuthRepository>( gh.factory<_i49.IAuthRepository>(
() => _i1035.AuthRepository( () => _i1035.AuthRepository(
gh<_i991.AuthLocalDataProvider>(), gh<_i991.AuthLocalDataProvider>(),
gh<_i17.AuthRemoteDataProvider>(), gh<_i17.AuthRemoteDataProvider>(),
), ),
); );
gh.factory<_i882.SalesLoaderBloc>(
() => _i882.SalesLoaderBloc(gh<_i477.IAnalyticRepository>()),
);
gh.factory<_i775.LoginFormBloc>( gh.factory<_i775.LoginFormBloc>(
() => _i775.LoginFormBloc(gh<_i49.IAuthRepository>()), () => _i775.LoginFormBloc(gh<_i49.IAuthRepository>()),
); );

View File

@ -1,72 +1,29 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../application/sales/sales_loader/sales_loader_bloc.dart';
import '../../../common/extension/extension.dart';
import '../../../common/theme/theme.dart'; import '../../../common/theme/theme.dart';
import '../../../domain/analytic/analytic.dart';
import '../../../injection.dart';
import '../../components/appbar/appbar.dart'; import '../../components/appbar/appbar.dart';
import '../../components/spacer/spacer.dart'; import '../../components/spacer/spacer.dart';
import 'widgets/summary_card.dart'; import 'widgets/summary_card.dart';
// Data Models
class SalesData {
final String dateFrom;
final String dateTo;
final SalesSummary summary;
final List<DailySales> dailySales;
SalesData({
required this.dateFrom,
required this.dateTo,
required this.summary,
required this.dailySales,
});
}
class SalesSummary {
final double totalSales;
final int totalOrders;
final int totalItems;
final double averageOrderValue;
final double totalTax;
final double totalDiscount;
final double netSales;
SalesSummary({
required this.totalSales,
required this.totalOrders,
required this.totalItems,
required this.averageOrderValue,
required this.totalTax,
required this.totalDiscount,
required this.netSales,
});
}
class DailySales {
final DateTime date;
final double sales;
final int orders;
final int items;
final double tax;
final double discount;
final double netSales;
DailySales({
required this.date,
required this.sales,
required this.orders,
required this.items,
required this.tax,
required this.discount,
required this.netSales,
});
}
@RoutePage() @RoutePage()
class SalesPage extends StatefulWidget { class SalesPage extends StatefulWidget implements AutoRouteWrapper {
const SalesPage({super.key}); const SalesPage({super.key});
@override @override
State<SalesPage> createState() => _SalesPageState(); State<SalesPage> createState() => _SalesPageState();
@override
Widget wrappedRoute(BuildContext context) => BlocProvider(
create: (context) =>
getIt<SalesLoaderBloc>()..add(SalesLoaderEvent.fectched()),
child: this,
);
} }
class _SalesPageState extends State<SalesPage> with TickerProviderStateMixin { class _SalesPageState extends State<SalesPage> with TickerProviderStateMixin {
@ -115,50 +72,13 @@ class _SalesPageState extends State<SalesPage> with TickerProviderStateMixin {
super.dispose(); super.dispose();
} }
// Sample data based on your JSON
final SalesData salesData = SalesData(
dateFrom: "2025-08-01T00:00:00+07:00",
dateTo: "2025-08-15T23:59:59.999999999+07:00",
summary: SalesSummary(
totalSales: 4291000,
totalOrders: 62,
totalItems: 62,
averageOrderValue: 69209.67741935483,
totalTax: 0,
totalDiscount: 0,
netSales: 4291000,
),
dailySales: [
DailySales(
date: DateTime.parse("2025-08-13T00:00:00Z"),
sales: 3841000,
orders: 52,
items: 52,
tax: 0,
discount: 0,
netSales: 3841000,
),
DailySales(
date: DateTime.parse("2025-08-14T00:00:00Z"),
sales: 450000,
orders: 10,
items: 10,
tax: 0,
discount: 0,
netSales: 450000,
),
],
);
String formatCurrency(double amount) {
return 'Rp ${amount.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppColor.background, backgroundColor: AppColor.background,
body: CustomScrollView( body: BlocBuilder<SalesLoaderBloc, SalesLoaderState>(
builder: (context, state) {
return CustomScrollView(
slivers: [ slivers: [
// App Bar // App Bar
SliverAppBar( SliverAppBar(
@ -194,7 +114,11 @@ class _SalesPageState extends State<SalesPage> with TickerProviderStateMixin {
), ),
child: Row( child: Row(
children: [ children: [
Icon(Icons.date_range, color: AppColor.primary, size: 20), Icon(
Icons.date_range,
color: AppColor.primary,
size: 20,
),
SpaceWidth(8), SpaceWidth(8),
Text( Text(
'Aug 1 - Aug 15, 2025', 'Aug 1 - Aug 15, 2025',
@ -241,9 +165,11 @@ class _SalesPageState extends State<SalesPage> with TickerProviderStateMixin {
Expanded( Expanded(
child: _buildSummaryCard( child: _buildSummaryCard(
'Total Sales', 'Total Sales',
formatCurrency( state
salesData.summary.totalSales, .sales
), .summary
.totalSales
.currencyFormatRp,
Icons.trending_up, Icons.trending_up,
AppColor.success, AppColor.success,
0, 0,
@ -253,7 +179,8 @@ class _SalesPageState extends State<SalesPage> with TickerProviderStateMixin {
Expanded( Expanded(
child: _buildSummaryCard( child: _buildSummaryCard(
'Total Orders', 'Total Orders',
'${salesData.summary.totalOrders}', state.sales.summary.totalOrders
.toString(),
Icons.shopping_cart, Icons.shopping_cart,
AppColor.info, AppColor.info,
100, 100,
@ -277,9 +204,9 @@ class _SalesPageState extends State<SalesPage> with TickerProviderStateMixin {
Expanded( Expanded(
child: _buildSummaryCard( child: _buildSummaryCard(
'Avg Order Value', 'Avg Order Value',
formatCurrency( state.sales.summary.averageOrderValue
salesData.summary.averageOrderValue, .round()
), .currencyFormatRp,
Icons.attach_money, Icons.attach_money,
AppColor.warning, AppColor.warning,
200, 200,
@ -289,7 +216,8 @@ class _SalesPageState extends State<SalesPage> with TickerProviderStateMixin {
Expanded( Expanded(
child: _buildSummaryCard( child: _buildSummaryCard(
'Total Items', 'Total Items',
'${salesData.summary.totalItems}', state.sales.summary.totalItems
.toString(),
Icons.inventory, Icons.inventory,
AppColor.primary, AppColor.primary,
300, 300,
@ -351,7 +279,9 @@ class _SalesPageState extends State<SalesPage> with TickerProviderStateMixin {
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2), color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(
12,
),
), ),
child: const Icon( child: const Icon(
Icons.account_balance_wallet, Icons.account_balance_wallet,
@ -365,7 +295,8 @@ class _SalesPageState extends State<SalesPage> with TickerProviderStateMixin {
SpaceWidth(16), SpaceWidth(16),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Net Sales', 'Net Sales',
@ -381,7 +312,8 @@ class _SalesPageState extends State<SalesPage> with TickerProviderStateMixin {
TweenAnimationBuilder<double>( TweenAnimationBuilder<double>(
tween: Tween( tween: Tween(
begin: 0.0, begin: 0.0,
end: salesData.summary.netSales, end: state.sales.summary.netSales
.toDouble(),
), ),
duration: const Duration( duration: const Duration(
milliseconds: 2000, milliseconds: 2000,
@ -389,7 +321,11 @@ class _SalesPageState extends State<SalesPage> with TickerProviderStateMixin {
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
builder: (context, countValue, child) { builder: (context, countValue, child) {
return Text( return Text(
formatCurrency(countValue), state
.sales
.summary
.netSales
.currencyFormatRp,
style: const TextStyle( style: const TextStyle(
color: AppColor.textWhite, color: AppColor.textWhite,
fontSize: 24, fontSize: 24,
@ -435,7 +371,6 @@ class _SalesPageState extends State<SalesPage> with TickerProviderStateMixin {
// Daily Sales List // Daily Sales List
SliverList( SliverList(
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
final dailySale = salesData.dailySales[index];
return SlideTransition( return SlideTransition(
position: position:
Tween<Offset>( Tween<Offset>(
@ -478,16 +413,18 @@ class _SalesPageState extends State<SalesPage> with TickerProviderStateMixin {
), ),
], ],
), ),
child: _buildDailySalesItem(dailySale), child: _buildDailySalesItem(state.sales.data[index]),
), ),
), ),
); );
}, childCount: salesData.dailySales.length), }, childCount: state.sales.data.length),
), ),
// Bottom Padding // Bottom Padding
const SliverToBoxAdapter(child: SpaceHeight(32)), const SliverToBoxAdapter(child: SpaceHeight(32)),
], ],
);
},
), ),
); );
} }
@ -509,7 +446,7 @@ class _SalesPageState extends State<SalesPage> with TickerProviderStateMixin {
); );
} }
Widget _buildDailySalesItem(DailySales dailySale) { Widget _buildDailySalesItem(SalesAnalyticData dailySale) {
return ExpansionTile( return ExpansionTile(
leading: Container( leading: Container(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
@ -527,7 +464,7 @@ class _SalesPageState extends State<SalesPage> with TickerProviderStateMixin {
), ),
), ),
subtitle: Text( subtitle: Text(
formatCurrency(dailySale.sales), dailySale.sales.currencyFormatRp,
style: TextStyle( style: TextStyle(
color: AppColor.success, color: AppColor.success,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -564,14 +501,14 @@ class _SalesPageState extends State<SalesPage> with TickerProviderStateMixin {
Expanded( Expanded(
child: _buildDetailItem( child: _buildDetailItem(
'Tax', 'Tax',
formatCurrency(dailySale.tax), dailySale.tax.currencyFormatRp,
Icons.receipt, Icons.receipt,
), ),
), ),
Expanded( Expanded(
child: _buildDetailItem( child: _buildDetailItem(
'Discount', 'Discount',
formatCurrency(dailySale.discount), dailySale.discount.currencyFormatRp,
Icons.local_offer, Icons.local_offer,
), ),
), ),

View File

@ -198,7 +198,7 @@ class ProfileRoute extends _i17.PageRouteInfo<void> {
static _i17.PageInfo page = _i17.PageInfo( static _i17.PageInfo page = _i17.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i10.ProfilePage(); return _i17.WrappedRoute(child: const _i10.ProfilePage());
}, },
); );
} }
@ -246,7 +246,7 @@ class SalesRoute extends _i17.PageRouteInfo<void> {
static _i17.PageInfo page = _i17.PageInfo( static _i17.PageInfo page = _i17.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i13.SalesPage(); return _i17.WrappedRoute(child: const _i13.SalesPage());
}, },
); );
} }

View File

@ -1045,6 +1045,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.0"
shimmer:
dependency: "direct main"
description:
name: shimmer
sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
simple_gesture_detector: simple_gesture_detector:
dependency: transitive dependency: transitive
description: description:

View File

@ -40,6 +40,7 @@ dependencies:
table_calendar: ^3.2.0 table_calendar: ^3.2.0
package_info_plus: ^8.3.1 package_info_plus: ^8.3.1
loader_overlay: ^5.0.0 loader_overlay: ^5.0.0
shimmer: ^3.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: