diff --git a/lib/application/analytic/category_analytic_loader/category_analytic_loader_bloc.dart b/lib/application/analytic/category_analytic_loader/category_analytic_loader_bloc.dart new file mode 100644 index 0000000..eb99bfd --- /dev/null +++ b/lib/application/analytic/category_analytic_loader/category_analytic_loader_bloc.dart @@ -0,0 +1,51 @@ +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 'category_analytic_loader_event.dart'; +part 'category_analytic_loader_state.dart'; +part 'category_analytic_loader_bloc.freezed.dart'; + +@injectable +class CategoryAnalyticLoaderBloc + extends Bloc { + final IAnalyticRepository _repository; + + CategoryAnalyticLoaderBloc(this._repository) + : super(CategoryAnalyticLoaderState.initial()) { + on(_onCategoryAnalyticLoaderEvent); + } + + Future _onCategoryAnalyticLoaderEvent( + CategoryAnalyticLoaderEvent event, + Emitter emit, + ) { + return event.map( + fetched: (e) async { + emit( + state.copyWith( + isFetching: true, + failureOptionCategoryAnalytic: none(), + ), + ); + + final result = await _repository.getCategory( + dateFrom: DateTime.now().subtract(const Duration(days: 30)), + dateTo: DateTime.now(), + ); + + var data = result.fold( + (f) => state.copyWith(failureOptionCategoryAnalytic: optionOf(f)), + (categoryAnalytic) => + state.copyWith(categoryAnalytic: categoryAnalytic), + ); + + emit(data.copyWith(isFetching: false)); + }, + ); + } +} diff --git a/lib/application/analytic/category_analytic_loader/category_analytic_loader_bloc.freezed.dart b/lib/application/analytic/category_analytic_loader/category_analytic_loader_bloc.freezed.dart new file mode 100644 index 0000000..41a5c79 --- /dev/null +++ b/lib/application/analytic/category_analytic_loader/category_analytic_loader_bloc.freezed.dart @@ -0,0 +1,401 @@ +// 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 'category_analytic_loader_bloc.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +/// @nodoc +mixin _$CategoryAnalyticLoaderEvent { + @optionalTypeArgs + TResult when({ + required TResult Function() fetched, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? fetched, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? fetched, + required TResult orElse(), + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(_Fetched value) fetched, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_Fetched value)? fetched, + }) => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Fetched value)? fetched, + required TResult orElse(), + }) => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $CategoryAnalyticLoaderEventCopyWith<$Res> { + factory $CategoryAnalyticLoaderEventCopyWith( + CategoryAnalyticLoaderEvent value, + $Res Function(CategoryAnalyticLoaderEvent) then, + ) = + _$CategoryAnalyticLoaderEventCopyWithImpl< + $Res, + CategoryAnalyticLoaderEvent + >; +} + +/// @nodoc +class _$CategoryAnalyticLoaderEventCopyWithImpl< + $Res, + $Val extends CategoryAnalyticLoaderEvent +> + implements $CategoryAnalyticLoaderEventCopyWith<$Res> { + _$CategoryAnalyticLoaderEventCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of CategoryAnalyticLoaderEvent + /// 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 _$CategoryAnalyticLoaderEventCopyWithImpl<$Res, _$FetchedImpl> + implements _$$FetchedImplCopyWith<$Res> { + __$$FetchedImplCopyWithImpl( + _$FetchedImpl _value, + $Res Function(_$FetchedImpl) _then, + ) : super(_value, _then); + + /// Create a copy of CategoryAnalyticLoaderEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$FetchedImpl implements _Fetched { + const _$FetchedImpl(); + + @override + String toString() { + return 'CategoryAnalyticLoaderEvent.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({required TResult Function() fetched}) { + return fetched(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({TResult? Function()? fetched}) { + return fetched?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? fetched, + required TResult orElse(), + }) { + if (fetched != null) { + return fetched(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_Fetched value) fetched, + }) { + return fetched(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_Fetched value)? fetched, + }) { + return fetched?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Fetched value)? fetched, + required TResult orElse(), + }) { + if (fetched != null) { + return fetched(this); + } + return orElse(); + } +} + +abstract class _Fetched implements CategoryAnalyticLoaderEvent { + const factory _Fetched() = _$FetchedImpl; +} + +/// @nodoc +mixin _$CategoryAnalyticLoaderState { + CategoryAnalytic get categoryAnalytic => throw _privateConstructorUsedError; + Option get failureOptionCategoryAnalytic => + throw _privateConstructorUsedError; + bool get isFetching => throw _privateConstructorUsedError; + + /// Create a copy of CategoryAnalyticLoaderState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $CategoryAnalyticLoaderStateCopyWith + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $CategoryAnalyticLoaderStateCopyWith<$Res> { + factory $CategoryAnalyticLoaderStateCopyWith( + CategoryAnalyticLoaderState value, + $Res Function(CategoryAnalyticLoaderState) then, + ) = + _$CategoryAnalyticLoaderStateCopyWithImpl< + $Res, + CategoryAnalyticLoaderState + >; + @useResult + $Res call({ + CategoryAnalytic categoryAnalytic, + Option failureOptionCategoryAnalytic, + bool isFetching, + }); + + $CategoryAnalyticCopyWith<$Res> get categoryAnalytic; +} + +/// @nodoc +class _$CategoryAnalyticLoaderStateCopyWithImpl< + $Res, + $Val extends CategoryAnalyticLoaderState +> + implements $CategoryAnalyticLoaderStateCopyWith<$Res> { + _$CategoryAnalyticLoaderStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of CategoryAnalyticLoaderState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? categoryAnalytic = null, + Object? failureOptionCategoryAnalytic = null, + Object? isFetching = null, + }) { + return _then( + _value.copyWith( + categoryAnalytic: null == categoryAnalytic + ? _value.categoryAnalytic + : categoryAnalytic // ignore: cast_nullable_to_non_nullable + as CategoryAnalytic, + failureOptionCategoryAnalytic: null == failureOptionCategoryAnalytic + ? _value.failureOptionCategoryAnalytic + : failureOptionCategoryAnalytic // ignore: cast_nullable_to_non_nullable + as Option, + isFetching: null == isFetching + ? _value.isFetching + : isFetching // ignore: cast_nullable_to_non_nullable + as bool, + ) + as $Val, + ); + } + + /// Create a copy of CategoryAnalyticLoaderState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $CategoryAnalyticCopyWith<$Res> get categoryAnalytic { + return $CategoryAnalyticCopyWith<$Res>(_value.categoryAnalytic, (value) { + return _then(_value.copyWith(categoryAnalytic: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$CategoryAnalyticLoaderStateImplCopyWith<$Res> + implements $CategoryAnalyticLoaderStateCopyWith<$Res> { + factory _$$CategoryAnalyticLoaderStateImplCopyWith( + _$CategoryAnalyticLoaderStateImpl value, + $Res Function(_$CategoryAnalyticLoaderStateImpl) then, + ) = __$$CategoryAnalyticLoaderStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({ + CategoryAnalytic categoryAnalytic, + Option failureOptionCategoryAnalytic, + bool isFetching, + }); + + @override + $CategoryAnalyticCopyWith<$Res> get categoryAnalytic; +} + +/// @nodoc +class __$$CategoryAnalyticLoaderStateImplCopyWithImpl<$Res> + extends + _$CategoryAnalyticLoaderStateCopyWithImpl< + $Res, + _$CategoryAnalyticLoaderStateImpl + > + implements _$$CategoryAnalyticLoaderStateImplCopyWith<$Res> { + __$$CategoryAnalyticLoaderStateImplCopyWithImpl( + _$CategoryAnalyticLoaderStateImpl _value, + $Res Function(_$CategoryAnalyticLoaderStateImpl) _then, + ) : super(_value, _then); + + /// Create a copy of CategoryAnalyticLoaderState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? categoryAnalytic = null, + Object? failureOptionCategoryAnalytic = null, + Object? isFetching = null, + }) { + return _then( + _$CategoryAnalyticLoaderStateImpl( + categoryAnalytic: null == categoryAnalytic + ? _value.categoryAnalytic + : categoryAnalytic // ignore: cast_nullable_to_non_nullable + as CategoryAnalytic, + failureOptionCategoryAnalytic: null == failureOptionCategoryAnalytic + ? _value.failureOptionCategoryAnalytic + : failureOptionCategoryAnalytic // ignore: cast_nullable_to_non_nullable + as Option, + isFetching: null == isFetching + ? _value.isFetching + : isFetching // ignore: cast_nullable_to_non_nullable + as bool, + ), + ); + } +} + +/// @nodoc + +class _$CategoryAnalyticLoaderStateImpl + implements _CategoryAnalyticLoaderState { + const _$CategoryAnalyticLoaderStateImpl({ + required this.categoryAnalytic, + required this.failureOptionCategoryAnalytic, + this.isFetching = false, + }); + + @override + final CategoryAnalytic categoryAnalytic; + @override + final Option failureOptionCategoryAnalytic; + @override + @JsonKey() + final bool isFetching; + + @override + String toString() { + return 'CategoryAnalyticLoaderState(categoryAnalytic: $categoryAnalytic, failureOptionCategoryAnalytic: $failureOptionCategoryAnalytic, isFetching: $isFetching)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$CategoryAnalyticLoaderStateImpl && + (identical(other.categoryAnalytic, categoryAnalytic) || + other.categoryAnalytic == categoryAnalytic) && + (identical( + other.failureOptionCategoryAnalytic, + failureOptionCategoryAnalytic, + ) || + other.failureOptionCategoryAnalytic == + failureOptionCategoryAnalytic) && + (identical(other.isFetching, isFetching) || + other.isFetching == isFetching)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + categoryAnalytic, + failureOptionCategoryAnalytic, + isFetching, + ); + + /// Create a copy of CategoryAnalyticLoaderState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$CategoryAnalyticLoaderStateImplCopyWith<_$CategoryAnalyticLoaderStateImpl> + get copyWith => + __$$CategoryAnalyticLoaderStateImplCopyWithImpl< + _$CategoryAnalyticLoaderStateImpl + >(this, _$identity); +} + +abstract class _CategoryAnalyticLoaderState + implements CategoryAnalyticLoaderState { + const factory _CategoryAnalyticLoaderState({ + required final CategoryAnalytic categoryAnalytic, + required final Option failureOptionCategoryAnalytic, + final bool isFetching, + }) = _$CategoryAnalyticLoaderStateImpl; + + @override + CategoryAnalytic get categoryAnalytic; + @override + Option get failureOptionCategoryAnalytic; + @override + bool get isFetching; + + /// Create a copy of CategoryAnalyticLoaderState + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$CategoryAnalyticLoaderStateImplCopyWith<_$CategoryAnalyticLoaderStateImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/application/analytic/category_analytic_loader/category_analytic_loader_event.dart b/lib/application/analytic/category_analytic_loader/category_analytic_loader_event.dart new file mode 100644 index 0000000..87c2994 --- /dev/null +++ b/lib/application/analytic/category_analytic_loader/category_analytic_loader_event.dart @@ -0,0 +1,6 @@ +part of 'category_analytic_loader_bloc.dart'; + +@freezed +class CategoryAnalyticLoaderEvent with _$CategoryAnalyticLoaderEvent { + const factory CategoryAnalyticLoaderEvent.fetched() = _Fetched; +} diff --git a/lib/application/analytic/category_analytic_loader/category_analytic_loader_state.dart b/lib/application/analytic/category_analytic_loader/category_analytic_loader_state.dart new file mode 100644 index 0000000..0754233 --- /dev/null +++ b/lib/application/analytic/category_analytic_loader/category_analytic_loader_state.dart @@ -0,0 +1,15 @@ +part of 'category_analytic_loader_bloc.dart'; + +@freezed +class CategoryAnalyticLoaderState with _$CategoryAnalyticLoaderState { + const factory CategoryAnalyticLoaderState({ + required CategoryAnalytic categoryAnalytic, + required Option failureOptionCategoryAnalytic, + @Default(false) bool isFetching, + }) = _CategoryAnalyticLoaderState; + + factory CategoryAnalyticLoaderState.initial() => CategoryAnalyticLoaderState( + categoryAnalytic: CategoryAnalytic.empty(), + failureOptionCategoryAnalytic: none(), + ); +} diff --git a/lib/infrastructure/analytic/datasource/remote_data_provider.dart b/lib/infrastructure/analytic/datasource/remote_data_provider.dart index ec9253b..9fd53eb 100644 --- a/lib/infrastructure/analytic/datasource/remote_data_provider.dart +++ b/lib/infrastructure/analytic/datasource/remote_data_provider.dart @@ -78,7 +78,7 @@ class AnalyticRemoteDataProvider { }) async { try { final response = await _apiClient.get( - ApiPath.category, + ApiPath.categoryAnalytic, params: { 'date_from': dateFrom.toServerDate, 'date_to': dateTo.toServerDate, diff --git a/lib/injection.config.dart b/lib/injection.config.dart index 043f9a2..e2ca7f3 100644 --- a/lib/injection.config.dart +++ b/lib/injection.config.dart @@ -9,6 +9,8 @@ // coverage:ignore-file // 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/profit_loss_loader/profit_loss_loader_bloc.dart' as _i11; import 'package:apskel_owner_flutter/application/analytic/sales_loader/sales_loader_bloc.dart' @@ -145,6 +147,9 @@ extension GetItInjectableX on _i174.GetIt { gh.factory<_i11.ProfitLossLoaderBloc>( () => _i11.ProfitLossLoaderBloc(gh<_i477.IAnalyticRepository>()), ); + gh.factory<_i1038.CategoryAnalyticLoaderBloc>( + () => _i1038.CategoryAnalyticLoaderBloc(gh<_i477.IAnalyticRepository>()), + ); gh.factory<_i775.LoginFormBloc>( () => _i775.LoginFormBloc(gh<_i49.IAuthRepository>()), ); diff --git a/lib/presentation/pages/finance/finance_page.dart b/lib/presentation/pages/finance/finance_page.dart index 9389098..b165172 100644 --- a/lib/presentation/pages/finance/finance_page.dart +++ b/lib/presentation/pages/finance/finance_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:line_icons/line_icons.dart'; +import '../../../application/analytic/category_analytic_loader/category_analytic_loader_bloc.dart'; import '../../../application/analytic/profit_loss_loader/profit_loss_loader_bloc.dart'; import '../../../common/extension/extension.dart'; import '../../../common/theme/theme.dart'; @@ -23,9 +24,18 @@ class FinancePage extends StatefulWidget implements AutoRouteWrapper { State createState() => _FinancePageState(); @override - Widget wrappedRoute(BuildContext context) => BlocProvider( - create: (_) => - getIt()..add(ProfitLossLoaderEvent.fetched()), + Widget wrappedRoute(BuildContext context) => MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => + getIt()..add(ProfitLossLoaderEvent.fetched()), + ), + BlocProvider( + create: (context) => + getIt() + ..add(CategoryAnalyticLoaderEvent.fetched()), + ), + ], child: this, ); } @@ -149,11 +159,20 @@ class _FinancePageState extends State ), ), - SliverToBoxAdapter( - child: SlideTransition( - position: _slideAnimation, - child: FinanceCategory(), - ), + BlocBuilder< + CategoryAnalyticLoaderBloc, + CategoryAnalyticLoaderState + >( + builder: (context, stateCategory) { + return SliverToBoxAdapter( + child: SlideTransition( + position: _slideAnimation, + child: FinanceCategory( + categories: stateCategory.categoryAnalytic.data, + ), + ), + ); + }, ), // Product Analysis Section diff --git a/lib/presentation/pages/finance/widgets/category.dart b/lib/presentation/pages/finance/widgets/category.dart index 7c18f38..747ffba 100644 --- a/lib/presentation/pages/finance/widgets/category.dart +++ b/lib/presentation/pages/finance/widgets/category.dart @@ -1,33 +1,21 @@ 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/widgets/empty_widget.dart'; class FinanceCategory extends StatelessWidget { - const FinanceCategory({super.key}); + final List categories; + + const FinanceCategory({super.key, required this.categories}); @override Widget build(BuildContext context) { - final categories = [ - { - 'name': 'Makanan & Minuman', - 'amount': 'Rp 18.5M', - 'percentage': 72, - 'color': AppColor.primary, - }, - { - 'name': 'Produk Retail', - 'amount': 'Rp 4.2M', - 'percentage': 16, - 'color': AppColor.secondary, - }, - { - 'name': 'Jasa & Lainnya', - 'amount': 'Rp 3.1M', - 'percentage': 12, - 'color': AppColor.info, - }, - ]; + final totalRevenue = _calculateTotalRevenue(); + final sortedCategories = _sortCategoriesByRevenue(); return Container( margin: const EdgeInsets.all(16), @@ -70,25 +58,25 @@ class FinanceCategory extends StatelessWidget { ), const SizedBox(height: 20), - ...categories - .map( - (category) => _buildCategoryItem( - category['name'] as String, - category['amount'] as String, - category['percentage'] as int, - category['color'] as Color, - ), - ) - .toList(), + // Show empty state if no categories + if (categories.isEmpty) + _buildEmptyState() + else + ...sortedCategories.asMap().entries.map( + (entry) => _buildCategoryItem( + entry.value, + _calculatePercentage(entry.value.totalRevenue, totalRevenue), + _getCategoryColor(entry.key), + ), + ), ], ), ); } Widget _buildCategoryItem( - String name, - String amount, - int percentage, + CategoryAnalyticItem category, + double percentage, Color color, ) { return Container( @@ -98,30 +86,59 @@ class FinanceCategory extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( + Expanded( + child: Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + category.categoryName, + style: AppStyle.md.copyWith( + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + '${category.productCount} produk • ${category.orderCount} pesanan', + style: AppStyle.xs.copyWith( + color: AppColor.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.end, children: [ - Container( - width: 12, - height: 12, - decoration: BoxDecoration( + Text( + category.totalRevenue.currencyFormatRp, + style: AppStyle.md.copyWith( + fontWeight: FontWeight.bold, color: color, - borderRadius: BorderRadius.circular(6), ), ), - const SizedBox(width: 12), Text( - name, - style: AppStyle.md.copyWith(fontWeight: FontWeight.w600), + '${NumberFormat('#,###', 'id_ID').format(category.totalQuantity)} unit', + style: AppStyle.xs.copyWith(color: AppColor.textSecondary), ), ], ), - Text( - amount, - style: AppStyle.md.copyWith( - fontWeight: FontWeight.bold, - color: color, - ), - ), ], ), const SizedBox(height: 8), @@ -135,7 +152,7 @@ class FinanceCategory extends StatelessWidget { Align( alignment: Alignment.centerRight, child: Text( - '$percentage%', + '${percentage.toStringAsFixed(1)}%', style: AppStyle.xs.copyWith(color: AppColor.textSecondary), ), ), @@ -143,4 +160,48 @@ class FinanceCategory extends StatelessWidget { ), ); } + + Widget _buildEmptyState() { + return EmptyWidget( + title: 'Belum ada data kategori', + message: 'Data kategori penjualan akan muncul di sini', + ); + } + + // Helper methods + int _calculateTotalRevenue() { + return categories.fold(0, (sum, category) => sum + category.totalRevenue); + } + + List _sortCategoriesByRevenue() { + final sorted = List.from(categories); + sorted.sort((a, b) => b.totalRevenue.compareTo(a.totalRevenue)); + return sorted; + } + + double _calculatePercentage(int categoryRevenue, int totalRevenue) { + if (totalRevenue == 0) return 0; + return (categoryRevenue / totalRevenue) * 100; + } + + Color _getCategoryColor(int index) { + // Predefined color palette for categories + const colors = [ + AppColor.primary, + AppColor.secondary, + AppColor.success, + AppColor.warning, + AppColor.error, + AppColor.info, + ]; + + // Generate additional colors if needed + if (index < colors.length) { + return colors[index]; + } else { + // Generate colors based on index for unlimited categories + final hue = (index * 137.5) % 360; // Golden angle approximation + return HSLColor.fromAHSL(1.0, hue, 0.7, 0.5).toColor(); + } + } }