feat: update profit loss ui
Some checks are pending
Build & Deploy iOS to TestFlight / build-and-deploy (push) Waiting to run

This commit is contained in:
Efril 2026-06-24 10:14:37 +07:00
parent b07af60778
commit 0917c5132b
22 changed files with 4332 additions and 1685 deletions

File diff suppressed because it is too large Load Diff

View File

@ -4,22 +4,34 @@ part of '../analytic.dart';
class ProfitLossAnalytic with _$ProfitLossAnalytic { class ProfitLossAnalytic with _$ProfitLossAnalytic {
const factory ProfitLossAnalytic({ const factory ProfitLossAnalytic({
required String organizationId, required String organizationId,
required String outletId,
required String outletName,
required String dateFrom, required String dateFrom,
required String dateTo, required String dateTo,
required String groupBy, required String groupBy,
required ProfitLossSummary summary, required ProfitLossSummary summary,
required List<ProfitLossDailyData> data, required List<ProfitLossDailyData> data,
required List<ProfitLossProductData> productData, required List<ProfitLossProductData> productData,
required List<ProfitLossMainSummaryItem> mainSummary,
required ProfitLossPurchasing purchasing,
required List<ProfitLossOperationalExpense> operationalExpenses,
required int operationalExpensesTotal,
}) = _ProfitLossAnalytic; }) = _ProfitLossAnalytic;
factory ProfitLossAnalytic.empty() => ProfitLossAnalytic( factory ProfitLossAnalytic.empty() => ProfitLossAnalytic(
organizationId: '', organizationId: '',
outletId: '',
outletName: '',
dateFrom: '', dateFrom: '',
dateTo: '', dateTo: '',
groupBy: '', groupBy: '',
summary: ProfitLossSummary.empty(), summary: ProfitLossSummary.empty(),
data: [], data: [],
productData: [], productData: [],
mainSummary: [],
purchasing: ProfitLossPurchasing.empty(),
operationalExpenses: [],
operationalExpensesTotal: 0,
); );
} }
@ -115,3 +127,79 @@ class ProfitLossProductData with _$ProfitLossProductData {
profitPerUnit: 0, profitPerUnit: 0,
); );
} }
@freezed
class ProfitLossMainSummaryItem with _$ProfitLossMainSummaryItem {
const factory ProfitLossMainSummaryItem({
required String id,
required String label,
required bool isBold,
required int todayNominal,
required double todayPct,
required int mtdNominal,
required double mtdPct,
@Default([]) List<ProfitLossMainSummaryItem> subItems,
}) = _ProfitLossMainSummaryItem;
factory ProfitLossMainSummaryItem.empty() => const ProfitLossMainSummaryItem(
id: '',
label: '',
isBold: false,
todayNominal: 0,
todayPct: 0,
mtdNominal: 0,
mtdPct: 0,
subItems: [],
);
}
@freezed
class ProfitLossPurchasing with _$ProfitLossPurchasing {
const factory ProfitLossPurchasing({
required int todayTotal,
required int mtdTotal,
required int todayRawMaterial,
required int mtdRawMaterial,
required int todayExpense,
required int mtdExpense,
@Default([]) List<ProfitLossPurchasingItem> items,
}) = _ProfitLossPurchasing;
factory ProfitLossPurchasing.empty() => const ProfitLossPurchasing(
todayTotal: 0,
mtdTotal: 0,
todayRawMaterial: 0,
mtdRawMaterial: 0,
todayExpense: 0,
mtdExpense: 0,
items: [],
);
}
@freezed
class ProfitLossPurchasingItem with _$ProfitLossPurchasingItem {
const factory ProfitLossPurchasingItem({
required String date,
required String item,
required int quantity,
required int nominal,
}) = _ProfitLossPurchasingItem;
factory ProfitLossPurchasingItem.empty() => const ProfitLossPurchasingItem(
date: '',
item: '',
quantity: 0,
nominal: 0,
);
}
@freezed
class ProfitLossOperationalExpense with _$ProfitLossOperationalExpense {
const factory ProfitLossOperationalExpense({
required String item,
required int nominal,
}) = _ProfitLossOperationalExpense;
factory ProfitLossOperationalExpense.empty() =>
const ProfitLossOperationalExpense(item: '', nominal: 0);
}

File diff suppressed because it is too large Load Diff

View File

@ -94,6 +94,8 @@ _$ProfitLossAnalyticDtoImpl _$$ProfitLossAnalyticDtoImplFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => _$ProfitLossAnalyticDtoImpl( ) => _$ProfitLossAnalyticDtoImpl(
organizationId: json['organization_id'] as String?, organizationId: json['organization_id'] as String?,
outletId: json['outlet_id'] as String?,
outletName: json['outlet_name'] as String?,
dateFrom: json['date_from'] as String?, dateFrom: json['date_from'] as String?,
dateTo: json['date_to'] as String?, dateTo: json['date_to'] as String?,
groupBy: json['group_by'] as String?, groupBy: json['group_by'] as String?,
@ -106,18 +108,42 @@ _$ProfitLossAnalyticDtoImpl _$$ProfitLossAnalyticDtoImplFromJson(
productData: (json['product_data'] as List<dynamic>?) productData: (json['product_data'] as List<dynamic>?)
?.map((e) => ProfitLossProductDataDto.fromJson(e as Map<String, dynamic>)) ?.map((e) => ProfitLossProductDataDto.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
mainSummary: (json['main_summary'] as List<dynamic>?)
?.map(
(e) => ProfitLossMainSummaryItemDto.fromJson(e as Map<String, dynamic>),
)
.toList(),
purchasing: json['purchasing'] == null
? null
: ProfitLossPurchasingDto.fromJson(
json['purchasing'] as Map<String, dynamic>,
),
operationalExpenses: (json['operational_expenses'] as List<dynamic>?)
?.map(
(e) =>
ProfitLossOperationalExpenseDto.fromJson(e as Map<String, dynamic>),
)
.toList(),
operationalExpensesTotal: (json['operational_expenses_total'] as num?)
?.toInt(),
); );
Map<String, dynamic> _$$ProfitLossAnalyticDtoImplToJson( Map<String, dynamic> _$$ProfitLossAnalyticDtoImplToJson(
_$ProfitLossAnalyticDtoImpl instance, _$ProfitLossAnalyticDtoImpl instance,
) => <String, dynamic>{ ) => <String, dynamic>{
'organization_id': instance.organizationId, 'organization_id': instance.organizationId,
'outlet_id': instance.outletId,
'outlet_name': instance.outletName,
'date_from': instance.dateFrom, 'date_from': instance.dateFrom,
'date_to': instance.dateTo, 'date_to': instance.dateTo,
'group_by': instance.groupBy, 'group_by': instance.groupBy,
'summary': instance.summary, 'summary': instance.summary,
'data': instance.data, 'data': instance.data,
'product_data': instance.productData, 'product_data': instance.productData,
'main_summary': instance.mainSummary,
'purchasing': instance.purchasing,
'operational_expenses': instance.operationalExpenses,
'operational_expenses_total': instance.operationalExpensesTotal,
}; };
_$ProfitLossSummaryDtoImpl _$$ProfitLossSummaryDtoImplFromJson( _$ProfitLossSummaryDtoImpl _$$ProfitLossSummaryDtoImplFromJson(
@ -216,6 +242,93 @@ Map<String, dynamic> _$$ProfitLossProductDataDtoImplToJson(
'profit_per_unit': instance.profitPerUnit, 'profit_per_unit': instance.profitPerUnit,
}; };
_$ProfitLossMainSummaryItemDtoImpl _$$ProfitLossMainSummaryItemDtoImplFromJson(
Map<String, dynamic> json,
) => _$ProfitLossMainSummaryItemDtoImpl(
id: json['id'] as String?,
label: json['label'] as String?,
isBold: json['is_bold'] as bool?,
todayNominal: (json['today_nominal'] as num?)?.toInt(),
todayPct: (json['today_pct'] as num?)?.toDouble(),
mtdNominal: (json['mtd_nominal'] as num?)?.toInt(),
mtdPct: (json['mtd_pct'] as num?)?.toDouble(),
subItems: (json['sub_items'] as List<dynamic>?)
?.map(
(e) => ProfitLossMainSummaryItemDto.fromJson(e as Map<String, dynamic>),
)
.toList(),
);
Map<String, dynamic> _$$ProfitLossMainSummaryItemDtoImplToJson(
_$ProfitLossMainSummaryItemDtoImpl instance,
) => <String, dynamic>{
'id': instance.id,
'label': instance.label,
'is_bold': instance.isBold,
'today_nominal': instance.todayNominal,
'today_pct': instance.todayPct,
'mtd_nominal': instance.mtdNominal,
'mtd_pct': instance.mtdPct,
'sub_items': instance.subItems,
};
_$ProfitLossPurchasingDtoImpl _$$ProfitLossPurchasingDtoImplFromJson(
Map<String, dynamic> json,
) => _$ProfitLossPurchasingDtoImpl(
todayTotal: (json['today_total'] as num?)?.toInt(),
mtdTotal: (json['mtd_total'] as num?)?.toInt(),
todayRawMaterial: (json['today_raw_material'] as num?)?.toInt(),
mtdRawMaterial: (json['mtd_raw_material'] as num?)?.toInt(),
todayExpense: (json['today_expense'] as num?)?.toInt(),
mtdExpense: (json['mtd_expense'] as num?)?.toInt(),
items: (json['items'] as List<dynamic>?)
?.map(
(e) => ProfitLossPurchasingItemDto.fromJson(e as Map<String, dynamic>),
)
.toList(),
);
Map<String, dynamic> _$$ProfitLossPurchasingDtoImplToJson(
_$ProfitLossPurchasingDtoImpl instance,
) => <String, dynamic>{
'today_total': instance.todayTotal,
'mtd_total': instance.mtdTotal,
'today_raw_material': instance.todayRawMaterial,
'mtd_raw_material': instance.mtdRawMaterial,
'today_expense': instance.todayExpense,
'mtd_expense': instance.mtdExpense,
'items': instance.items,
};
_$ProfitLossPurchasingItemDtoImpl _$$ProfitLossPurchasingItemDtoImplFromJson(
Map<String, dynamic> json,
) => _$ProfitLossPurchasingItemDtoImpl(
date: json['date'] as String?,
item: json['item'] as String?,
quantity: (json['quantity'] as num?)?.toInt(),
nominal: (json['nominal'] as num?)?.toInt(),
);
Map<String, dynamic> _$$ProfitLossPurchasingItemDtoImplToJson(
_$ProfitLossPurchasingItemDtoImpl instance,
) => <String, dynamic>{
'date': instance.date,
'item': instance.item,
'quantity': instance.quantity,
'nominal': instance.nominal,
};
_$ProfitLossOperationalExpenseDtoImpl
_$$ProfitLossOperationalExpenseDtoImplFromJson(Map<String, dynamic> json) =>
_$ProfitLossOperationalExpenseDtoImpl(
item: json['item'] as String?,
nominal: (json['nominal'] as num?)?.toInt(),
);
Map<String, dynamic> _$$ProfitLossOperationalExpenseDtoImplToJson(
_$ProfitLossOperationalExpenseDtoImpl instance,
) => <String, dynamic>{'item': instance.item, 'nominal': instance.nominal};
_$CategoryAnalyticDtoImpl _$$CategoryAnalyticDtoImplFromJson( _$CategoryAnalyticDtoImpl _$$CategoryAnalyticDtoImplFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => _$CategoryAnalyticDtoImpl( ) => _$CategoryAnalyticDtoImpl(

View File

@ -6,12 +6,20 @@ class ProfitLossAnalyticDto with _$ProfitLossAnalyticDto {
const factory ProfitLossAnalyticDto({ const factory ProfitLossAnalyticDto({
@JsonKey(name: 'organization_id') String? organizationId, @JsonKey(name: 'organization_id') String? organizationId,
@JsonKey(name: 'outlet_id') String? outletId,
@JsonKey(name: 'outlet_name') String? outletName,
@JsonKey(name: 'date_from') String? dateFrom, @JsonKey(name: 'date_from') String? dateFrom,
@JsonKey(name: 'date_to') String? dateTo, @JsonKey(name: 'date_to') String? dateTo,
@JsonKey(name: 'group_by') String? groupBy, @JsonKey(name: 'group_by') String? groupBy,
@JsonKey(name: 'summary') ProfitLossSummaryDto? summary, @JsonKey(name: 'summary') ProfitLossSummaryDto? summary,
@JsonKey(name: 'data') List<ProfitLossDailyDataDto>? data, @JsonKey(name: 'data') List<ProfitLossDailyDataDto>? data,
@JsonKey(name: 'product_data') List<ProfitLossProductDataDto>? productData, @JsonKey(name: 'product_data') List<ProfitLossProductDataDto>? productData,
@JsonKey(name: 'main_summary')
List<ProfitLossMainSummaryItemDto>? mainSummary,
@JsonKey(name: 'purchasing') ProfitLossPurchasingDto? purchasing,
@JsonKey(name: 'operational_expenses')
List<ProfitLossOperationalExpenseDto>? operationalExpenses,
@JsonKey(name: 'operational_expenses_total') int? operationalExpensesTotal,
}) = _ProfitLossAnalyticDto; }) = _ProfitLossAnalyticDto;
factory ProfitLossAnalyticDto.fromJson(Map<String, dynamic> json) => factory ProfitLossAnalyticDto.fromJson(Map<String, dynamic> json) =>
@ -19,12 +27,20 @@ class ProfitLossAnalyticDto with _$ProfitLossAnalyticDto {
ProfitLossAnalytic toDomain() => ProfitLossAnalytic( ProfitLossAnalytic toDomain() => ProfitLossAnalytic(
organizationId: organizationId ?? '', organizationId: organizationId ?? '',
outletId: outletId ?? '',
outletName: outletName ?? '',
dateFrom: dateFrom ?? '', dateFrom: dateFrom ?? '',
dateTo: dateTo ?? '', dateTo: dateTo ?? '',
groupBy: groupBy ?? '', groupBy: groupBy ?? '',
summary: summary?.toDomain() ?? ProfitLossSummary.empty(), summary: summary?.toDomain() ?? ProfitLossSummary.empty(),
data: (data ?? []).map((e) => e.toDomain()).toList(), data: (data ?? []).map((e) => e.toDomain()).toList(),
productData: (productData ?? []).map((e) => e.toDomain()).toList(), productData: (productData ?? []).map((e) => e.toDomain()).toList(),
mainSummary: (mainSummary ?? []).map((e) => e.toDomain()).toList(),
purchasing: purchasing?.toDomain() ?? ProfitLossPurchasing.empty(),
operationalExpenses: (operationalExpenses ?? [])
.map((e) => e.toDomain())
.toList(),
operationalExpensesTotal: operationalExpensesTotal ?? 0,
); );
} }
@ -135,3 +151,99 @@ class ProfitLossProductDataDto with _$ProfitLossProductDataDto {
profitPerUnit: profitPerUnit ?? 0, profitPerUnit: profitPerUnit ?? 0,
); );
} }
@freezed
class ProfitLossMainSummaryItemDto with _$ProfitLossMainSummaryItemDto {
const ProfitLossMainSummaryItemDto._();
const factory ProfitLossMainSummaryItemDto({
@JsonKey(name: 'id') String? id,
@JsonKey(name: 'label') String? label,
@JsonKey(name: 'is_bold') bool? isBold,
@JsonKey(name: 'today_nominal') int? todayNominal,
@JsonKey(name: 'today_pct') double? todayPct,
@JsonKey(name: 'mtd_nominal') int? mtdNominal,
@JsonKey(name: 'mtd_pct') double? mtdPct,
@JsonKey(name: 'sub_items') List<ProfitLossMainSummaryItemDto>? subItems,
}) = _ProfitLossMainSummaryItemDto;
factory ProfitLossMainSummaryItemDto.fromJson(Map<String, dynamic> json) =>
_$ProfitLossMainSummaryItemDtoFromJson(json);
ProfitLossMainSummaryItem toDomain() => ProfitLossMainSummaryItem(
id: id ?? '',
label: label ?? '',
isBold: isBold ?? false,
todayNominal: todayNominal ?? 0,
todayPct: todayPct ?? 0.0,
mtdNominal: mtdNominal ?? 0,
mtdPct: mtdPct ?? 0.0,
subItems: (subItems ?? []).map((e) => e.toDomain()).toList(),
);
}
@freezed
class ProfitLossPurchasingDto with _$ProfitLossPurchasingDto {
const ProfitLossPurchasingDto._();
const factory ProfitLossPurchasingDto({
@JsonKey(name: 'today_total') int? todayTotal,
@JsonKey(name: 'mtd_total') int? mtdTotal,
@JsonKey(name: 'today_raw_material') int? todayRawMaterial,
@JsonKey(name: 'mtd_raw_material') int? mtdRawMaterial,
@JsonKey(name: 'today_expense') int? todayExpense,
@JsonKey(name: 'mtd_expense') int? mtdExpense,
@JsonKey(name: 'items') List<ProfitLossPurchasingItemDto>? items,
}) = _ProfitLossPurchasingDto;
factory ProfitLossPurchasingDto.fromJson(Map<String, dynamic> json) =>
_$ProfitLossPurchasingDtoFromJson(json);
ProfitLossPurchasing toDomain() => ProfitLossPurchasing(
todayTotal: todayTotal ?? 0,
mtdTotal: mtdTotal ?? 0,
todayRawMaterial: todayRawMaterial ?? 0,
mtdRawMaterial: mtdRawMaterial ?? 0,
todayExpense: todayExpense ?? 0,
mtdExpense: mtdExpense ?? 0,
items: (items ?? []).map((e) => e.toDomain()).toList(),
);
}
@freezed
class ProfitLossPurchasingItemDto with _$ProfitLossPurchasingItemDto {
const ProfitLossPurchasingItemDto._();
const factory ProfitLossPurchasingItemDto({
@JsonKey(name: 'date') String? date,
@JsonKey(name: 'item') String? item,
@JsonKey(name: 'quantity') int? quantity,
@JsonKey(name: 'nominal') int? nominal,
}) = _ProfitLossPurchasingItemDto;
factory ProfitLossPurchasingItemDto.fromJson(Map<String, dynamic> json) =>
_$ProfitLossPurchasingItemDtoFromJson(json);
ProfitLossPurchasingItem toDomain() => ProfitLossPurchasingItem(
date: date ?? '',
item: item ?? '',
quantity: quantity ?? 0,
nominal: nominal ?? 0,
);
}
@freezed
class ProfitLossOperationalExpenseDto with _$ProfitLossOperationalExpenseDto {
const ProfitLossOperationalExpenseDto._();
const factory ProfitLossOperationalExpenseDto({
@JsonKey(name: 'item') String? item,
@JsonKey(name: 'nominal') int? nominal,
}) = _ProfitLossOperationalExpenseDto;
factory ProfitLossOperationalExpenseDto.fromJson(Map<String, dynamic> json) =>
_$ProfitLossOperationalExpenseDtoFromJson(json);
ProfitLossOperationalExpense toDomain() =>
ProfitLossOperationalExpense(item: item ?? '', nominal: nominal ?? 0);
}

View File

@ -496,5 +496,29 @@
"example": "48" "example": "48"
} }
} }
} },
"mtd_month": "MTD ({month})",
"@mtd_month": {
"placeholders": {
"month": {
"type": "String",
"example": "June"
}
}
},
"profit_loss_date": "Profit / Loss · {date}",
"@profit_loss_date": {
"placeholders": {
"date": {
"type": "String",
"example": "22 Jun 2026"
}
}
},
"profit_loss_report": "Profit & Loss Report",
"@profit_loss_report": {},
"net_profit_loss": "Net Profit/Loss",
"@net_profit_loss": {},
"cost_breakdown": "Cost Breakdown",
"@cost_breakdown": {}
} }

View File

@ -496,5 +496,29 @@
"example": "48" "example": "48"
} }
} }
} },
"mtd_month": "MTD ({month})",
"@mtd_month": {
"placeholders": {
"month": {
"type": "String",
"example": "Juni"
}
}
},
"profit_loss_date": "Laba / Rugi · {date}",
"@profit_loss_date": {
"placeholders": {
"date": {
"type": "String",
"example": "22 Jun 2026"
}
}
},
"profit_loss_report": "Laporan Laba Rugi",
"@profit_loss_report": {},
"net_profit_loss": "Laba/Rugi Bersih",
"@net_profit_loss": {},
"cost_breakdown": "Rincian Biaya",
"@cost_breakdown": {}
} }

View File

@ -1480,6 +1480,36 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'{count} portions sold'** /// **'{count} portions sold'**
String portion_sold(int count); String portion_sold(int count);
/// No description provided for @mtd_month.
///
/// In en, this message translates to:
/// **'MTD ({month})'**
String mtd_month(String month);
/// No description provided for @profit_loss_date.
///
/// In en, this message translates to:
/// **'Profit / Loss · {date}'**
String profit_loss_date(String date);
/// No description provided for @profit_loss_report.
///
/// In en, this message translates to:
/// **'Profit & Loss Report'**
String get profit_loss_report;
/// No description provided for @net_profit_loss.
///
/// In en, this message translates to:
/// **'Net Profit/Loss'**
String get net_profit_loss;
/// No description provided for @cost_breakdown.
///
/// In en, this message translates to:
/// **'Cost Breakdown'**
String get cost_breakdown;
} }
class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> { class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {

View File

@ -710,4 +710,23 @@ class AppLocalizationsEn extends AppLocalizations {
String portion_sold(int count) { String portion_sold(int count) {
return '$count portions sold'; return '$count portions sold';
} }
@override
String mtd_month(String month) {
return 'MTD ($month)';
}
@override
String profit_loss_date(String date) {
return 'Profit / Loss · $date';
}
@override
String get profit_loss_report => 'Profit & Loss Report';
@override
String get net_profit_loss => 'Net Profit/Loss';
@override
String get cost_breakdown => 'Cost Breakdown';
} }

View File

@ -710,4 +710,23 @@ class AppLocalizationsId extends AppLocalizations {
String portion_sold(int count) { String portion_sold(int count) {
return '$count porsi terjual'; return '$count porsi terjual';
} }
@override
String mtd_month(String month) {
return 'MTD ($month)';
}
@override
String profit_loss_date(String date) {
return 'Laba / Rugi · $date';
}
@override
String get profit_loss_report => 'Laporan Laba Rugi';
@override
String get net_profit_loss => 'Laba/Rugi Bersih';
@override
String get cost_breakdown => 'Rincian Biaya';
} }

View File

@ -1,21 +1,13 @@
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 '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 '../../../application/analytic/profit_loss_loader/profit_loss_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 '../../../injection.dart';
import '../../components/appbar/appbar.dart'; import 'widgets/cost_breakdown.dart';
import '../../components/field/date_range_picker_field.dart'; import 'widgets/profit_loss_header.dart';
import 'widgets/cash_flow.dart'; import 'widgets/profit_loss_report.dart';
import 'widgets/category.dart';
import 'widgets/product.dart';
import 'widgets/profit_loss.dart';
import 'widgets/summary_card.dart';
@RoutePage() @RoutePage()
class FinancePage extends StatefulWidget implements AutoRouteWrapper { class FinancePage extends StatefulWidget implements AutoRouteWrapper {
@ -25,80 +17,40 @@ class FinancePage extends StatefulWidget implements AutoRouteWrapper {
State<FinancePage> createState() => _FinancePageState(); State<FinancePage> createState() => _FinancePageState();
@override @override
Widget wrappedRoute(BuildContext context) => MultiBlocProvider( Widget wrappedRoute(BuildContext context) => BlocProvider(
providers: [ create: (_) =>
BlocProvider( getIt<ProfitLossLoaderBloc>()..add(ProfitLossLoaderEvent.fetched()),
create: (_) =>
getIt<ProfitLossLoaderBloc>()..add(ProfitLossLoaderEvent.fetched()),
),
BlocProvider(
create: (context) =>
getIt<CategoryAnalyticLoaderBloc>()
..add(CategoryAnalyticLoaderEvent.fetched()),
),
],
child: this, child: this,
); );
} }
class _FinancePageState extends State<FinancePage> class _FinancePageState extends State<FinancePage>
with TickerProviderStateMixin { with SingleTickerProviderStateMixin {
late AnimationController _slideController;
late AnimationController _fadeController; late AnimationController _fadeController;
late AnimationController _scaleController;
late Animation<Offset> _slideAnimation;
late Animation<double> _fadeAnimation; late Animation<double> _fadeAnimation;
late Animation<double> _scaleAnimation;
int _selectedTabIndex = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_slideController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_fadeController = AnimationController( _fadeController = AnimationController(
duration: const Duration(milliseconds: 1000), duration: const Duration(milliseconds: 1000),
vsync: this, vsync: this,
); );
_scaleController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_slideAnimation =
Tween<Offset>(begin: const Offset(0, 0.3), end: Offset.zero).animate(
CurvedAnimation(parent: _slideController, curve: Curves.easeOutCubic),
);
_fadeAnimation = Tween<double>( _fadeAnimation = Tween<double>(
begin: 0.0, begin: 0.0,
end: 1.0, end: 1.0,
).animate(CurvedAnimation(parent: _fadeController, curve: Curves.easeIn)); ).animate(CurvedAnimation(parent: _fadeController, curve: Curves.easeIn));
_scaleAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(parent: _scaleController, curve: Curves.elasticOut),
);
// Start animations
_fadeController.forward(); _fadeController.forward();
Future.delayed(const Duration(milliseconds: 200), () {
_slideController.forward();
});
Future.delayed(const Duration(milliseconds: 400), () {
_scaleController.forward();
});
} }
@override @override
void dispose() { void dispose() {
_slideController.dispose();
_fadeController.dispose(); _fadeController.dispose();
_scaleController.dispose();
super.dispose(); super.dispose();
} }
@ -119,89 +71,48 @@ class _FinancePageState extends State<FinancePage>
builder: (context, state) { builder: (context, state) {
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
// SliverAppBar with animated background // Header with gradient background, tabs, and summary
SliverAppBar(
expandedHeight: 120,
floating: false,
pinned: true,
backgroundColor: AppColor.primary,
elevation: 0,
flexibleSpace: CustomAppBar(title: context.lang.profit_loss),
),
// Header dengan filter periode
SliverToBoxAdapter( SliverToBoxAdapter(
child: FadeTransition( child: FadeTransition(
opacity: _fadeAnimation, opacity: _fadeAnimation,
child: Padding( child: ProfitLossHeader(
padding: const EdgeInsets.all(16.0), state: state,
child: DateRangePickerField( selectedTabIndex: _selectedTabIndex,
maxDate: DateTime.now(), onTabChanged: (index) {
startDate: state.dateFrom, setState(() {
endDate: state.dateTo, _selectedTabIndex = index;
onChanged: (startDate, endDate) { });
context.read<ProfitLossLoaderBloc>().add( _onTabChanged(context, index);
ProfitLossLoaderEvent.rangeDateChanged( },
startDate!,
endDate!,
),
);
},
),
), ),
), ),
), ),
// Summary Cards // Profit Loss Report Table
SliverToBoxAdapter(
child: SlideTransition(
position: _slideAnimation,
child: _buildSummaryCards(state.profitLoss.summary),
),
),
// Cash Flow Analysis
SliverToBoxAdapter(
child: ScaleTransition(
scale: _scaleAnimation,
child: FinanceCashFlow(dailyData: state.profitLoss.data),
),
),
// Profit Loss Detail
SliverToBoxAdapter( SliverToBoxAdapter(
child: FadeTransition( child: FadeTransition(
opacity: _fadeAnimation, opacity: _fadeAnimation,
child: FinanceProfitLoss(data: state.profitLoss.summary), child: ProfitLossReport(
mainSummary: state.profitLoss.mainSummary,
summary: state.profitLoss.summary,
selectedTabIndex: _selectedTabIndex,
),
), ),
), ),
BlocBuilder< // Cost Breakdown
CategoryAnalyticLoaderBloc,
CategoryAnalyticLoaderState
>(
builder: (context, stateCategory) {
return SliverToBoxAdapter(
child: SlideTransition(
position: _slideAnimation,
child: FinanceCategory(
categories: stateCategory.categoryAnalytic.data,
),
),
);
},
),
// Product Analysis Section
SliverToBoxAdapter( SliverToBoxAdapter(
child: SlideTransition( child: FadeTransition(
position: _slideAnimation, opacity: _fadeAnimation,
child: _buildProductAnalysis(state.profitLoss.productData), child: CostBreakdown(
purchasing: state.profitLoss.purchasing,
selectedTabIndex: _selectedTabIndex,
dateFrom: state.dateFrom,
dateTo: state.dateTo,
),
), ),
), ),
// Transaction Categories
// Bottom spacing // Bottom spacing
const SliverToBoxAdapter(child: SizedBox(height: 100)), const SliverToBoxAdapter(child: SizedBox(height: 100)),
], ],
@ -212,125 +123,23 @@ class _FinancePageState extends State<FinancePage>
); );
} }
Widget _buildSummaryCards(ProfitLossSummary summary) { void _onTabChanged(BuildContext context, int index) {
return Padding( final now = DateTime.now();
padding: const EdgeInsets.symmetric(horizontal: 16), DateTime dateFrom;
child: Column( DateTime dateTo;
children: [
Row(
children: [
Expanded(
child: FinanceSummaryCard(
title: context.lang.total_revenue,
amount: summary.totalRevenue.currencyFormatRp,
icon: LineIcons.arrowUp,
color: AppColor.success,
isPositive: true,
),
),
const SizedBox(width: 12),
Expanded(
child: FinanceSummaryCard(
title: context.lang.total_expenditures,
amount: summary.totalCost.currencyFormatRp,
icon: LineIcons.arrowDown,
color: AppColor.error,
isPositive: false,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: FinanceSummaryCard(
title: context.lang.net_profit,
amount: summary.netProfit.currencyFormatRp,
icon: LineIcons.lineChart,
color: AppColor.info,
isPositive: true,
),
),
const SizedBox(width: 12),
Expanded(
child: FinanceSummaryCard(
title: context.lang.margin_profit,
amount: '${summary.profitabilityRatio.round()}%',
icon: LineIcons.percent,
color: AppColor.warning,
isPositive: true,
),
),
],
),
],
),
);
}
Widget _buildProductAnalysis(List<ProfitLossProductData> products) { if (index == 0) {
return Container( // Today
margin: const EdgeInsets.all(16), dateFrom = DateTime(now.year, now.month, now.day);
padding: const EdgeInsets.all(20), dateTo = DateTime(now.year, now.month, now.day, 23, 59, 59);
decoration: BoxDecoration( } else {
color: AppColor.white, // MTD (Month-to-Date)
borderRadius: BorderRadius.circular(16), dateFrom = DateTime(now.year, now.month, 1);
boxShadow: [ dateTo = now;
BoxShadow( }
color: AppColor.textLight.withOpacity(0.1),
spreadRadius: 1, context.read<ProfitLossLoaderBloc>().add(
blurRadius: 8, ProfitLossLoaderEvent.rangeDateChanged(dateFrom, dateTo),
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColor.info.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
LineIcons.shoppingBag,
color: AppColor.info,
size: 20,
),
),
const SizedBox(width: 12),
Text(
context.lang.product_analytic,
style: AppStyle.lg.copyWith(fontWeight: FontWeight.bold),
),
const Spacer(),
TextButton(
onPressed: () {},
child: Text(
context.lang.view_all,
style: AppStyle.sm.copyWith(color: AppColor.primary),
),
),
],
),
// Product list
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.only(top: 12),
itemCount: products.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final product = products[index];
return ProfitLossProduct(product: product);
},
),
],
),
); );
} }
} }

View File

@ -1,476 +0,0 @@
import 'package:fl_chart/fl_chart.dart';
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';
class FinanceCashFlow extends StatelessWidget {
final List<ProfitLossDailyData> dailyData;
const FinanceCashFlow({super.key, required this.dailyData});
@override
Widget build(BuildContext context) {
// Calculate totals from daily data
final totalCashIn = _calculateTotalCashIn();
final totalCashOut = _calculateTotalCashOut();
final netFlow = totalCashIn - totalCashOut;
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColor.textLight.withOpacity(0.1),
spreadRadius: 1,
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: AppColor.primaryGradient,
),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
LineIcons.areaChart,
color: AppColor.white,
size: 20,
),
),
const SizedBox(width: 12),
Text(
context.lang.cash_flow_analysis,
style: AppStyle.lg.copyWith(fontWeight: FontWeight.bold),
),
],
),
IconButton(
onPressed: () {},
icon: const Icon(
LineIcons.alternateExternalLink,
color: AppColor.primary,
),
),
],
),
const SizedBox(height: 20),
// Cash Flow Indicators
Row(
children: [
Expanded(
child: _buildCashFlowIndicator(
context.lang.cash_in,
_formatCurrency(totalCashIn),
LineIcons.arrowUp,
AppColor.success,
),
),
const SizedBox(width: 16),
Expanded(
child: _buildCashFlowIndicator(
context.lang.cash_out,
_formatCurrency(totalCashOut),
LineIcons.arrowDown,
AppColor.error,
),
),
const SizedBox(width: 16),
Expanded(
child: _buildCashFlowIndicator(
context.lang.net_flow,
_formatCurrency(netFlow),
LineIcons.equals,
AppColor.info,
),
),
],
),
const SizedBox(height: 20),
// FL Chart Implementation
Container(
height: 200,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.background,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.borderLight),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.lang.cash_flow_chart(dailyData.length),
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Expanded(
child: dailyData.isEmpty
? _buildEmptyChart()
: LineChart(_buildLineChartData()),
),
const SizedBox(height: 12),
// Legend
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildChartLegend(context.lang.cash_in, AppColor.success),
const SizedBox(width: 20),
_buildChartLegend(context.lang.cash_out, AppColor.error),
const SizedBox(width: 20),
_buildChartLegend(context.lang.net_flow, AppColor.info),
],
),
],
),
),
],
),
);
}
LineChartData _buildLineChartData() {
final maxValue = _getMaxChartValue();
final minValue = _getMinChartValue();
return LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: (maxValue / 5).roundToDouble(),
getDrawingHorizontalLine: (value) {
return FlLine(color: AppColor.borderLight, strokeWidth: 1);
},
),
titlesData: FlTitlesData(
show: true,
rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: 1,
getTitlesWidget: (double value, TitleMeta meta) {
final index = value.toInt();
if (index >= 0 && index < dailyData.length) {
final date = DateTime.parse(dailyData[index].date);
final dayName = _getDayName(date.weekday);
return SideTitleWidget(
meta: meta,
child: Text(
dayName,
style: const TextStyle(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
fontSize: 10,
),
),
);
}
return SideTitleWidget(meta: meta, child: Text(''));
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: (maxValue / 3).roundToDouble(),
reservedSize: 42,
getTitlesWidget: (double value, TitleMeta meta) {
return Text(
_formatChartValue(value),
style: const TextStyle(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
fontSize: 10,
),
textAlign: TextAlign.left,
);
},
),
),
),
borderData: FlBorderData(
show: true,
border: Border.all(color: AppColor.borderLight),
),
minX: 0,
maxX: (dailyData.length - 1).toDouble(),
minY: minValue,
maxY: maxValue,
lineBarsData: [
// Cash In Line (Revenue)
LineChartBarData(
spots: _buildCashInSpots(),
isCurved: true,
gradient: LinearGradient(
colors: [AppColor.success.withOpacity(0.8), AppColor.success],
),
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: AppColor.success,
strokeWidth: 2,
strokeColor: AppColor.white,
);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
AppColor.success.withOpacity(0.1),
AppColor.success.withOpacity(0.0),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
// Cash Out Line (Total Cost)
LineChartBarData(
spots: _buildCashOutSpots(),
isCurved: true,
gradient: LinearGradient(
colors: [AppColor.error.withOpacity(0.8), AppColor.error],
),
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: AppColor.error,
strokeWidth: 2,
strokeColor: AppColor.white,
);
},
),
),
// Net Flow Line (Net Profit)
LineChartBarData(
spots: _buildNetFlowSpots(),
isCurved: true,
gradient: LinearGradient(
colors: [AppColor.info.withOpacity(0.8), AppColor.info],
),
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: AppColor.info,
strokeWidth: 2,
strokeColor: AppColor.white,
);
},
),
),
],
);
}
Widget _buildEmptyChart() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LineIcons.lineChart,
size: 48,
color: AppColor.textSecondary.withOpacity(0.3),
),
const SizedBox(height: 12),
Text(
'Tidak ada data untuk ditampilkan',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
],
),
);
}
// Helper methods for calculating data
int _calculateTotalCashIn() {
return dailyData.fold(0, (sum, data) => sum + data.revenue);
}
int _calculateTotalCashOut() {
return dailyData.fold(
0,
(sum, data) => sum + data.cost + data.tax + data.discount,
);
}
double _getMaxChartValue() {
if (dailyData.isEmpty) return 30000000;
final maxRevenue = dailyData
.map((e) => e.revenue)
.reduce((a, b) => a > b ? a : b);
final maxCost = dailyData
.map((e) => e.cost + e.tax + e.discount)
.reduce((a, b) => a > b ? a : b);
final maxValue = maxRevenue > maxCost ? maxRevenue : maxCost;
return (maxValue * 1.2).toDouble(); // Add 20% padding
}
double _getMinChartValue() {
if (dailyData.isEmpty) return -5000000;
final minNetProfit = dailyData
.map((e) => e.netProfit)
.reduce((a, b) => a < b ? a : b);
return minNetProfit < 0 ? (minNetProfit * 1.2).toDouble() : 0;
}
List<FlSpot> _buildCashInSpots() {
return dailyData.asMap().entries.map((entry) {
return FlSpot(entry.key.toDouble(), entry.value.revenue.toDouble());
}).toList();
}
List<FlSpot> _buildCashOutSpots() {
return dailyData.asMap().entries.map((entry) {
final totalCost =
entry.value.cost + entry.value.tax + entry.value.discount;
return FlSpot(entry.key.toDouble(), totalCost.toDouble());
}).toList();
}
List<FlSpot> _buildNetFlowSpots() {
return dailyData.asMap().entries.map((entry) {
return FlSpot(entry.key.toDouble(), entry.value.netProfit.toDouble());
}).toList();
}
String _getDayName(int weekday) {
switch (weekday) {
case 1:
return 'Sen';
case 2:
return 'Sel';
case 3:
return 'Rab';
case 4:
return 'Kam';
case 5:
return 'Jum';
case 6:
return 'Sab';
case 7:
return 'Min';
default:
return '';
}
}
String _formatChartValue(double value) {
if (value.abs() >= 1000000) {
return '${(value / 1000000).toStringAsFixed(0)}M';
} else if (value.abs() >= 1000) {
return '${(value / 1000).toStringAsFixed(0)}K';
} else {
return value.toStringAsFixed(0);
}
}
String _formatCurrency(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(1)}K';
} else {
return 'Rp ${NumberFormat('#,###', 'id_ID').format(amount)}';
}
}
Widget _buildChartLegend(String label, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(
label,
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
);
}
Widget _buildCashFlowIndicator(
String label,
String amount,
IconData icon,
Color color,
) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.2)),
),
child: Column(
children: [
Icon(icon, color: color, size: 20),
const SizedBox(height: 8),
Text(
label,
style: AppStyle.xs.copyWith(color: AppColor.textSecondary),
),
const SizedBox(height: 4),
Text(
amount,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}
}

View File

@ -1,209 +0,0 @@
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 {
final List<CategoryAnalyticItem> categories;
const FinanceCategory({super.key, required this.categories});
@override
Widget build(BuildContext context) {
final totalRevenue = _calculateTotalRevenue();
final sortedCategories = _sortCategoriesByRevenue();
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColor.textLight.withOpacity(0.1),
spreadRadius: 1,
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColor.secondary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
LineIcons.pieChart,
color: AppColor.secondary,
size: 20,
),
),
const SizedBox(width: 12),
Text(
context.lang.sales_category,
style: AppStyle.lg.copyWith(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 20),
// Show empty state if no categories
if (categories.isEmpty)
_buildEmptyState(context)
else
...sortedCategories.asMap().entries.map(
(entry) => _buildCategoryItem(
context,
entry.value,
_calculatePercentage(entry.value.totalRevenue, totalRevenue),
_getCategoryColor(entry.key),
),
),
],
),
);
}
Widget _buildCategoryItem(
BuildContext context,
CategoryAnalyticItem category,
double percentage,
Color color,
) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
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} ${context.lang.product}${category.orderCount} ${context.lang.orders}',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
),
),
],
),
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
category.totalRevenue.currencyFormatRp,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
'${NumberFormat('#,###', 'id_ID').format(category.totalQuantity)} ${context.lang.unit}',
style: AppStyle.xs.copyWith(color: AppColor.textSecondary),
),
],
),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: percentage / 100,
backgroundColor: AppColor.borderLight,
valueColor: AlwaysStoppedAnimation<Color>(color),
minHeight: 6,
),
const SizedBox(height: 4),
Align(
alignment: Alignment.centerRight,
child: Text(
'${percentage.toStringAsFixed(1)}%',
style: AppStyle.xs.copyWith(color: AppColor.textSecondary),
),
),
],
),
);
}
Widget _buildEmptyState(BuildContext context) {
return EmptyWidget(
title: context.lang.category_no_data,
message: context.lang.category_no_data_desc,
);
}
// Helper methods
int _calculateTotalRevenue() {
return categories.fold(0, (sum, category) => sum + category.totalRevenue);
}
List<CategoryAnalyticItem> _sortCategoriesByRevenue() {
final sorted = List<CategoryAnalyticItem>.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();
}
}
}

View File

@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/analytic/analytic.dart';
class CostBreakdown extends StatelessWidget {
final ProfitLossPurchasing purchasing;
final int selectedTabIndex;
final DateTime dateFrom;
final DateTime dateTo;
const CostBreakdown({
super.key,
required this.purchasing,
required this.selectedTabIndex,
required this.dateFrom,
required this.dateTo,
});
@override
Widget build(BuildContext context) {
final isToday = selectedTabIndex == 0;
final total = isToday ? purchasing.todayTotal : purchasing.mtdTotal;
return Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColor.textLight.withOpacity(0.08),
spreadRadius: 1,
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.lang.cost_breakdown,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.textPrimary,
),
),
Text(
_formatDateLabel(dateFrom, dateTo),
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w400,
),
),
],
),
const SizedBox(height: 16),
// Item list
...purchasing.items.map((item) => _buildItemRow(item)),
// Total row
const Divider(height: 24, color: AppColor.borderLight),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.lang.total_cost,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.textPrimary,
),
),
Text(
total.currencyFormatRp,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.textPrimary,
),
),
],
),
],
),
);
}
Widget _buildItemRow(ProfitLossPurchasingItem item) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
Expanded(
child: Text(
item.item,
style: AppStyle.md.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w400,
),
),
),
Text(
item.nominal.currencyFormatRp,
style: AppStyle.md.copyWith(
color: AppColor.textPrimary,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
String _formatDateLabel(DateTime from, DateTime to) {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'Mei',
'Jun',
'Jul',
'Agu',
'Sep',
'Okt',
'Nov',
'Des',
];
if (from.year == to.year && from.month == to.month && from.day == to.day) {
return '${from.day} ${months[from.month - 1]} ${from.year}';
}
return '${from.day} ${months[from.month - 1]} - ${to.day} ${months[to.month - 1]} ${to.year}';
}
}

View File

@ -1,148 +0,0 @@
import 'package:flutter/material.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/analytic/analytic.dart';
class ProfitLossProduct extends StatelessWidget {
final ProfitLossProductData product;
const ProfitLossProduct({super.key, required this.product});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.background,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.border.withOpacity(0.5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Product header
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.productName,
style: AppStyle.md.copyWith(fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
product.categoryName,
style: AppStyle.xs.copyWith(color: AppColor.primary),
),
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${product.quantitySold} terjual',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
Text(
'${product.grossProfitMargin.toStringAsFixed(1)}%',
style: AppStyle.md.copyWith(
fontWeight: FontWeight.bold,
color: product.grossProfitMargin > 25
? AppColor.success
: product.grossProfitMargin > 15
? AppColor.warning
: AppColor.error,
),
),
],
),
],
),
const SizedBox(height: 16),
// Financial metrics
Row(
children: [
Expanded(
child: _buildMetricColumn(
context.lang.revenue,
product.revenue.currencyFormatRp,
AppColor.success,
),
),
Expanded(
child: _buildMetricColumn(
context.lang.cost,
product.cost.currencyFormatRp,
AppColor.error,
),
),
Expanded(
child: _buildMetricColumn(
context.lang.gross_profit,
product.grossProfit.currencyFormatRp,
AppColor.info,
),
),
],
),
const SizedBox(height: 12),
// Average metrics
Row(
children: [
Expanded(
child: _buildMetricColumn(
context.lang.average_price,
product.averagePrice.currencyFormatRp,
AppColor.textSecondary,
),
),
Expanded(
child: _buildMetricColumn(
context.lang.profit_per_unit,
product.profitPerUnit.currencyFormatRp,
AppColor.primary,
),
),
const Expanded(child: SizedBox()),
],
),
],
),
);
}
Widget _buildMetricColumn(String label, String value, Color color) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: AppStyle.xs.copyWith(color: AppColor.textSecondary)),
const SizedBox(height: 2),
Text(
value,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.w600,
color: color,
),
),
],
);
}
}

View File

@ -1,192 +0,0 @@
import 'package:flutter/material.dart';
import 'package:line_icons/line_icons.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/analytic/analytic.dart';
class FinanceProfitLoss extends StatelessWidget {
final ProfitLossSummary data;
const FinanceProfitLoss({super.key, required this.data});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColor.textLight.withOpacity(0.1),
spreadRadius: 1,
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColor.info.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
LineIcons.calculator,
color: AppColor.info,
size: 20,
),
),
const SizedBox(width: 12),
Text(
context.lang.profit_loss_detail,
style: AppStyle.lg.copyWith(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 20),
// Total Revenue (Penjualan Kotor)
_buildPLItem(
context.lang.gross_sales,
data.totalRevenue.currencyFormatRp,
AppColor.success,
true,
),
// Discount (Diskon & Retur)
_buildPLItem(
'${context.lang.discount} & ${context.lang.return_text}',
'- ${data.totalDiscount.currencyFormatRp}',
AppColor.error,
false,
),
const Divider(height: 24),
// Net Sales (Penjualan Bersih = Total Revenue - Discount)
_buildPLItem(
context.lang.net_sales,
(data.totalRevenue - data.totalDiscount).currencyFormatRp,
AppColor.textPrimary,
true,
isHeader: true,
),
const SizedBox(height: 12),
// Cost of Goods Sold (HPP)
_buildPLItem(
'${context.lang.cogs} (${context.lang.cost_of_goods_sold})',
'- ${data.totalCost.currencyFormatRp}',
AppColor.error,
false,
),
const Divider(height: 24),
// Gross Profit (Laba Kotor)
_buildPLItem(
context.lang.gross_profit,
data.grossProfit.currencyFormatRp,
AppColor.success,
true,
isHeader: true,
showPercentage: true,
percentage: '${data.grossProfitMargin.toStringAsFixed(1)}%',
),
const SizedBox(height: 12),
// Operational Cost (Biaya Operasional) - calculated as difference
_buildPLItem(
context.lang.operating_costs,
'- ${_calculateOperationalCost().currencyFormatRp}',
AppColor.error,
false,
),
const Divider(height: 24),
// Net Profit (Laba Bersih)
_buildPLItem(
context.lang.net_profit,
data.netProfit.currencyFormatRp,
AppColor.primary,
true,
isHeader: true,
showPercentage: true,
percentage: '${data.netProfitMargin.round()}%',
),
],
),
);
}
Widget _buildPLItem(
String title,
String amount,
Color color,
bool isPositive, {
bool isHeader = false,
bool showPercentage = false,
String? percentage,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
title,
style: isHeader
? AppStyle.md.copyWith(
fontWeight: FontWeight.bold,
color: color,
)
: AppStyle.md.copyWith(color: AppColor.textSecondary),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
amount,
style: isHeader
? AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: color,
)
: AppStyle.md.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
if (showPercentage && percentage != null)
Text(
percentage,
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontStyle: FontStyle.italic,
),
),
],
),
],
),
);
}
// Calculate operational cost as the difference between gross profit and net profit
int _calculateOperationalCost() {
return data.grossProfit - data.netProfit - data.totalTax;
}
}

View File

@ -0,0 +1,389 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import '../../../../application/analytic/profit_loss_loader/profit_loss_loader_bloc.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/painter/wave_painter.dart';
import '../../../../common/theme/theme.dart';
import '../../../components/spacer/spacer.dart';
class ProfitLossHeader extends StatelessWidget {
final ProfitLossLoaderState state;
final int selectedTabIndex;
final ValueChanged<int> onTabChanged;
const ProfitLossHeader({
super.key,
required this.state,
required this.selectedTabIndex,
required this.onTabChanged,
});
@override
Widget build(BuildContext context) {
final outletLabel = state.profitLoss.outletName.isNotEmpty
? state.profitLoss.outletName
: 'Semua Outlet';
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: AppColor.primaryGradient,
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(24),
bottomRight: Radius.circular(24),
),
),
child: Stack(
children: [
// Decorative circles
Positioned(
top: -20,
right: -30,
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColor.textWhite.withOpacity(0.08),
),
),
),
Positioned(
top: 30,
right: 20,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColor.textWhite.withOpacity(0.05),
),
),
),
Positioned(
top: 10,
left: -20,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColor.textWhite.withOpacity(0.04),
),
),
),
// Wave pattern
Positioned.fill(
child: CustomPaint(
painter: WavePainter(
animation: 0.0,
color: AppColor.textWhite.withOpacity(0.1),
),
),
),
// Content
SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Back button + Title row
Row(
children: [
GestureDetector(
onTap: () => context.router.maybePop(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColor.textWhite.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.chevron_left_rounded,
color: AppColor.textWhite,
size: 24,
),
),
),
const SpaceWidth(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.lang.profit_loss,
style: AppStyle.xl.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w700,
fontSize: 20,
),
),
const SizedBox(height: 2),
Text(
outletLabel,
style: AppStyle.sm.copyWith(
color: AppColor.textWhite.withOpacity(0.75),
fontWeight: FontWeight.w400,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
const SpaceHeight(20),
// Tab selector (Today / MTD)
_buildTabSelector(context),
const SpaceHeight(24),
// Profit / Loss label with date
Text(
context.lang.profit_loss_date(
_formatDateLabel(state.dateFrom, state.dateTo),
),
style: AppStyle.sm.copyWith(
color: AppColor.textWhite.withOpacity(0.75),
fontWeight: FontWeight.w400,
fontSize: 13,
),
),
const SpaceHeight(4),
// Big profit/loss value
state.isFetching
? _buildHeaderValueShimmer()
: Text(
state.profitLoss.summary.netProfit.currencyFormatRp,
style: AppStyle.h1.copyWith(
color: state.profitLoss.summary.netProfit >= 0
? AppColor.textWhite
: AppColor.textWhite.withOpacity(0.7),
fontWeight: FontWeight.w900,
fontSize: 32,
),
),
const SpaceHeight(16),
// Chips row (Omset + Total Biaya)
state.isFetching
? _buildHeaderChipsShimmer()
: _buildHeaderChips(context),
],
),
),
),
],
),
);
}
Widget _buildTabSelector(BuildContext context) {
final todayLabel = _formatTodayTabLabel(DateTime.now());
final mtdLabel = context.lang.mtd_month(
_getMonthName(DateTime.now().month),
);
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColor.textWhite.withOpacity(0.15),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: AppColor.textWhite.withOpacity(0.2)),
),
child: Row(
children: [
Expanded(
child: _buildTab(
label: todayLabel,
isSelected: selectedTabIndex == 0,
onTap: () => onTabChanged(0),
),
),
Expanded(
child: _buildTab(
label: mtdLabel,
isSelected: selectedTabIndex == 1,
onTap: () => onTabChanged(1),
),
),
],
),
);
}
Widget _buildTab({
required String label,
required bool isSelected,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected ? AppColor.white : Colors.transparent,
borderRadius: BorderRadius.circular(26),
),
child: Center(
child: Text(
label,
style: AppStyle.md.copyWith(
color: isSelected ? AppColor.textPrimary : AppColor.textWhite,
fontWeight: FontWeight.w600,
),
),
),
),
);
}
Widget _buildHeaderValueShimmer() {
return Shimmer.fromColors(
baseColor: AppColor.textWhite.withOpacity(0.3),
highlightColor: AppColor.textWhite.withOpacity(0.6),
child: Container(
width: 200,
height: 36,
decoration: BoxDecoration(
color: AppColor.textWhite.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
),
),
);
}
Widget _buildHeaderChipsShimmer() {
return Row(
children: List.generate(
2,
(index) => Padding(
padding: const EdgeInsets.only(right: 8),
child: Shimmer.fromColors(
baseColor: AppColor.textWhite.withOpacity(0.15),
highlightColor: AppColor.textWhite.withOpacity(0.3),
child: Container(
width: 130,
height: 32,
decoration: BoxDecoration(
color: AppColor.textWhite.withOpacity(0.15),
borderRadius: BorderRadius.circular(20),
),
),
),
),
),
);
}
Widget _buildHeaderChips(BuildContext context) {
final summary = state.profitLoss.summary;
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildChip(
'${context.lang.sales} ${summary.totalRevenue.currencyFormatRp}',
),
_buildChip(
'${context.lang.total_cost} ${summary.totalCost.currencyFormatRp}',
),
],
);
}
Widget _buildChip(String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: AppColor.textWhite.withOpacity(0.15),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColor.textWhite.withOpacity(0.25)),
),
child: Text(
label,
style: AppStyle.sm.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
);
}
String _formatTodayTabLabel(DateTime date) {
const months = [
'Januari',
'Februari',
'Maret',
'April',
'Mei',
'Juni',
'Juli',
'Agustus',
'September',
'Oktober',
'November',
'Desember',
];
return '${date.day} ${months[date.month - 1]}';
}
String _formatDateLabel(DateTime from, DateTime to) {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'Mei',
'Jun',
'Jul',
'Agu',
'Sep',
'Okt',
'Nov',
'Des',
];
if (from.year == to.year && from.month == to.month && from.day == to.day) {
return '${from.day} ${months[from.month - 1]} ${from.year}';
}
return '${from.day} ${months[from.month - 1]} - ${to.day} ${months[to.month - 1]} ${to.year}';
}
String _getMonthName(int month) {
const months = [
'Januari',
'Februari',
'Maret',
'April',
'Mei',
'Juni',
'Juli',
'Agustus',
'September',
'Oktober',
'November',
'Desember',
];
return months[month - 1];
}
}

View File

@ -0,0 +1,238 @@
import 'package:flutter/material.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/analytic/analytic.dart';
class ProfitLossReport extends StatelessWidget {
final List<ProfitLossMainSummaryItem> mainSummary;
final ProfitLossSummary summary;
final int selectedTabIndex;
const ProfitLossReport({
super.key,
required this.mainSummary,
required this.summary,
required this.selectedTabIndex,
});
@override
Widget build(BuildContext context) {
final isToday = selectedTabIndex == 0;
final marginPct = isToday
? summary.netProfitMargin.round()
: summary.netProfitMargin.round();
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColor.textLight.withOpacity(0.08),
spreadRadius: 1,
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.lang.profit_loss_report,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.textPrimary,
),
),
Text(
'margin $marginPct%',
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w400,
),
),
],
),
const SizedBox(height: 20),
// Main summary items
...mainSummary.map((item) => _buildSummarySection(item, isToday)),
const SizedBox(height: 12),
// Net Profit/Loss footer
_buildNetProfitFooter(context, isToday),
],
),
);
}
Widget _buildSummarySection(ProfitLossMainSummaryItem item, bool isToday) {
final nominal = isToday ? item.todayNominal : item.mtdNominal;
final pct = isToday ? item.todayPct : item.mtdPct;
return Column(
children: [
// Main item row
_buildItemRow(
label: item.label,
nominal: nominal,
pct: pct,
isBold: item.isBold,
isSubItem: false,
),
// Sub items
...item.subItems.map((subItem) {
final subNominal = isToday
? subItem.todayNominal
: subItem.mtdNominal;
final subPct = isToday ? subItem.todayPct : subItem.mtdPct;
return _buildItemRow(
label: subItem.label,
nominal: subNominal,
pct: subPct,
isBold: subItem.isBold,
isSubItem: true,
);
}),
// Divider after section (except for sub-items only sections)
if (item.isBold)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Divider(height: 1, color: AppColor.borderLight),
),
],
);
}
Widget _buildItemRow({
required String label,
required int nominal,
required double pct,
required bool isBold,
required bool isSubItem,
}) {
final isNegative = nominal < 0;
final displayNominal = isNegative
? '-${nominal.abs().currencyFormatRp}'
: nominal.currencyFormatRp;
final pctText = '${pct.round()}%';
// Determine color based on context
Color nominalColor;
if (isBold && isNegative) {
nominalColor = AppColor.error;
} else if (isBold) {
nominalColor = AppColor.textPrimary;
} else {
nominalColor = AppColor.textPrimary;
}
return Padding(
padding: EdgeInsets.only(left: isSubItem ? 16 : 0, top: 6, bottom: 6),
child: Row(
children: [
// Label
Expanded(
flex: 5,
child: Text(
label,
style: isBold
? AppStyle.md.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.textPrimary,
)
: AppStyle.md.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w400,
),
),
),
// Nominal
Expanded(
flex: 3,
child: Text(
displayNominal,
textAlign: TextAlign.right,
style: isBold
? AppStyle.md.copyWith(
fontWeight: FontWeight.w700,
color: nominalColor,
)
: AppStyle.md.copyWith(
color: AppColor.textPrimary,
fontWeight: FontWeight.w500,
),
),
),
// Percentage
SizedBox(
width: 48,
child: Text(
pctText,
textAlign: TextAlign.right,
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w400,
),
),
),
],
),
);
}
Widget _buildNetProfitFooter(BuildContext context, bool isToday) {
final netProfit = summary.netProfit;
final isNegative = netProfit < 0;
final displayValue = isNegative
? '-${netProfit.abs().currencyFormatRp}'
: netProfit.currencyFormatRp;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: isNegative
? AppColor.error.withOpacity(0.08)
: AppColor.success.withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Flexible(
child: Text(
context.lang.net_profit_loss,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w700,
color: isNegative ? AppColor.error : AppColor.success,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
Text(
displayValue,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w900,
color: isNegative ? AppColor.error : AppColor.success,
fontSize: 20,
),
),
],
),
);
}
}

View File

@ -1,70 +0,0 @@
import 'package:flutter/material.dart';
import '../../../../common/theme/theme.dart';
class FinanceSummaryCard extends StatelessWidget {
const FinanceSummaryCard({
super.key,
required this.title,
required this.amount,
required this.icon,
required this.color,
required this.isPositive,
});
final String title;
final String amount;
final IconData icon;
final Color color;
final bool isPositive;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColor.textLight.withOpacity(0.1),
spreadRadius: 1,
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 20),
),
],
),
const SizedBox(height: 12),
Text(
title,
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
const SizedBox(height: 4),
Text(
amount,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
],
),
);
}
}

View File

@ -1,7 +1,6 @@
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 'package:flutter_bloc/flutter_bloc.dart';
import 'package:line_icons/line_icons.dart';
import 'package:shimmer/shimmer.dart'; import 'package:shimmer/shimmer.dart';
import '../../../application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_bloc.dart'; import '../../../application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_bloc.dart';
@ -9,13 +8,12 @@ import '../../../application/outlet/outlet_list_loader/outlet_list_loader_bloc.d
import '../../../common/extension/extension.dart'; import '../../../common/extension/extension.dart';
import '../../../common/theme/theme.dart'; import '../../../common/theme/theme.dart';
import '../../../injection.dart'; import '../../../injection.dart';
import '../../components/appbar/appbar.dart';
import '../../components/field/date_range_picker_field.dart';
import '../../components/spacer/spacer.dart'; import '../../components/spacer/spacer.dart';
import 'widgets/ingredient_card.dart'; import 'widgets/ingredient_card.dart';
import 'widgets/outlet_selector_field.dart'; import 'widgets/outlet_selector_field.dart';
import 'widgets/purchase_daily_tile.dart'; import 'widgets/purchase_daily_tile.dart';
import 'widgets/stat_card.dart'; import 'widgets/purchase_header.dart';
import 'widgets/purchase_rincian_card.dart';
import 'widgets/vendor_card.dart'; import 'widgets/vendor_card.dart';
@RoutePage() @RoutePage()
@ -29,12 +27,14 @@ class PurchasePage extends StatefulWidget implements AutoRouteWrapper {
Widget wrappedRoute(BuildContext context) => MultiBlocProvider( Widget wrappedRoute(BuildContext context) => MultiBlocProvider(
providers: [ providers: [
BlocProvider( BlocProvider(
create: (context) => getIt<PurchasingAnalyticLoaderBloc>() create: (context) =>
..add(const PurchasingAnalyticLoaderEvent.fetched()), getIt<PurchasingAnalyticLoaderBloc>()
..add(const PurchasingAnalyticLoaderEvent.fetched()),
), ),
BlocProvider( BlocProvider(
create: (context) => getIt<OutletListLoaderBloc>() create: (context) =>
..add(const OutletListLoaderEvent.fetched()), getIt<OutletListLoaderBloc>()
..add(const OutletListLoaderEvent.fetched()),
), ),
], ],
child: this, child: this,
@ -74,19 +74,20 @@ class _PurchasePageState extends State<PurchasePage>
backgroundColor: AppColor.background, backgroundColor: AppColor.background,
body: MultiBlocListener( body: MultiBlocListener(
listeners: [ listeners: [
// Re-fetch when date range changes BlocListener<
BlocListener<PurchasingAnalyticLoaderBloc, PurchasingAnalyticLoaderBloc,
PurchasingAnalyticLoaderState>( PurchasingAnalyticLoaderState
>(
listenWhen: (prev, curr) => listenWhen: (prev, curr) =>
prev.dateFrom != curr.dateFrom || prev.dateFrom != curr.dateFrom || prev.dateTo != curr.dateTo,
prev.dateTo != curr.dateTo,
listener: (context, _) => context listener: (context, _) => context
.read<PurchasingAnalyticLoaderBloc>() .read<PurchasingAnalyticLoaderBloc>()
.add(const PurchasingAnalyticLoaderEvent.fetched()), .add(const PurchasingAnalyticLoaderEvent.fetched()),
), ),
// Re-fetch when outlet changes BlocListener<
BlocListener<PurchasingAnalyticLoaderBloc, PurchasingAnalyticLoaderBloc,
PurchasingAnalyticLoaderState>( PurchasingAnalyticLoaderState
>(
listenWhen: (prev, curr) => prev.outletId != curr.outletId, listenWhen: (prev, curr) => prev.outletId != curr.outletId,
listener: (context, _) => context listener: (context, _) => context
.read<PurchasingAnalyticLoaderBloc>() .read<PurchasingAnalyticLoaderBloc>()
@ -95,108 +96,61 @@ class _PurchasePageState extends State<PurchasePage>
], ],
child: BlocBuilder<OutletListLoaderBloc, OutletListLoaderState>( child: BlocBuilder<OutletListLoaderBloc, OutletListLoaderState>(
builder: (context, outletListState) { builder: (context, outletListState) {
return BlocBuilder<PurchasingAnalyticLoaderBloc, return BlocBuilder<
PurchasingAnalyticLoaderState>( PurchasingAnalyticLoaderBloc,
PurchasingAnalyticLoaderState
>(
builder: (context, state) { builder: (context, state) {
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
// App Bar // Header (same style as Sales)
SliverAppBar( SliverToBoxAdapter(
expandedHeight: 120.0, child: PurchaseHeader(
floating: false, state: state,
pinned: true, onDateRangeChanged: (startDate, endDate) {
elevation: 0, context.read<PurchasingAnalyticLoaderBloc>().add(
backgroundColor: AppColor.primary, PurchasingAnalyticLoaderEvent.rangeDateChanged(
flexibleSpace: startDate,
CustomAppBar(title: context.lang.purchase), endDate,
),
);
},
),
), ),
// Date Range + Outlet Picker // Outlet Selector
SliverToBoxAdapter( SliverToBoxAdapter(
child: FadeTransition( child: FadeTransition(
opacity: _fadeAnimation, opacity: _fadeAnimation,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Column( child: PurchaseOutletSelectorField(
children: [ selectedOutletId: state.outletId,
DateRangePickerField( outlets: outletListState.outlets,
maxDate: DateTime.now(), isLoading: outletListState.isFetching,
startDate: state.dateFrom, onOutletChanged: (outletId) {
endDate: state.dateTo, context.read<PurchasingAnalyticLoaderBloc>().add(
onChanged: (startDate, endDate) { PurchasingAnalyticLoaderEvent.outletChanged(
context outletId,
.read<PurchasingAnalyticLoaderBloc>() ),
.add( );
PurchasingAnalyticLoaderEvent },
.rangeDateChanged(
startDate!,
endDate!,
),
);
},
),
const SpaceHeight(8),
PurchaseOutletSelectorField(
selectedOutletId: state.outletId,
outlets: outletListState.outlets,
isLoading: outletListState.isFetching,
onOutletChanged: (outletId) {
context
.read<PurchasingAnalyticLoaderBloc>()
.add(
PurchasingAnalyticLoaderEvent
.outletChanged(outletId),
);
},
),
],
), ),
), ),
), ),
), ),
const SliverToBoxAdapter(child: SpaceHeight(16)), // Rincian Pembelian (same style as Sales)
// Summary Section
SliverToBoxAdapter( SliverToBoxAdapter(
child: FadeTransition( child: FadeTransition(
opacity: _fadeAnimation, opacity: _fadeAnimation,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.all(16),
child: Column( child: PurchaseRincianCard(state: state),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.lang.summary,
style: AppStyle.xxl.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
const SpaceHeight(16),
state.isFetching
? _buildSummaryShimmer()
: _buildSummaryCards(state),
],
),
), ),
), ),
), ),
const SliverToBoxAdapter(child: SpaceHeight(24)),
// Total Purchases Highlight Card
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child: state.isFetching
? _buildHighlightShimmer()
: _buildTotalPurchasesCard(state),
),
),
const SliverToBoxAdapter(child: SpaceHeight(24)),
// Daily Breakdown Header // Daily Breakdown Header
SliverToBoxAdapter( SliverToBoxAdapter(
child: FadeTransition( child: FadeTransition(
@ -278,211 +232,13 @@ class _PurchasePageState extends State<PurchasePage>
); );
} }
// Summary Cards
Widget _buildSummaryCards(PurchasingAnalyticLoaderState state) {
final s = state.purchasing.summary;
return Column(
children: [
Row(
children: [
Expanded(
child: PurchaseStatCard(
title: 'Total Pembelian',
value: s.totalPurchases.currencyFormatRp,
icon: LineIcons.shoppingCart,
iconColor: AppColor.primary,
cardAnimation: _fadeAnimation,
),
),
const SpaceWidth(12),
Expanded(
child: PurchaseStatCard(
title: 'Total PO',
value: '${s.totalPurchaseOrders} PO',
icon: LineIcons.fileAlt,
iconColor: AppColor.info,
cardAnimation: _fadeAnimation,
),
),
],
),
const SpaceHeight(12),
Row(
children: [
Expanded(
child: PurchaseStatCard(
title: 'Bahan Baku',
value: s.rawMaterialPurchases.currencyFormatRp,
icon: LineIcons.leaf,
iconColor: AppColor.secondary,
cardAnimation: _fadeAnimation,
),
),
const SpaceWidth(12),
Expanded(
child: PurchaseStatCard(
title: 'Pengeluaran',
value: s.expensePurchases.currencyFormatRp,
icon: LineIcons.receipt,
iconColor: AppColor.warning,
cardAnimation: _fadeAnimation,
),
),
],
),
const SpaceHeight(12),
Row(
children: [
Expanded(
child: PurchaseStatCard(
title: 'Total Qty',
value: '${s.totalQuantity} pcs',
icon: LineIcons.boxes,
iconColor: AppColor.warning,
cardAnimation: _fadeAnimation,
),
),
const SpaceWidth(12),
Expanded(
child: PurchaseStatCard(
title: 'Rata-rata PO',
value: s.averagePurchaseOrderValue.round().currencyFormatRp,
icon: LineIcons.dollarSign,
iconColor: AppColor.secondaryDark,
cardAnimation: _fadeAnimation,
),
),
],
),
const SpaceHeight(12),
Row(
children: [
Expanded(
child: PurchaseStatCard(
title: 'Item Bahan Baku',
value: '${s.totalIngredients} item',
icon: LineIcons.leaf,
iconColor: AppColor.secondaryDark,
cardAnimation: _fadeAnimation,
),
),
const SpaceWidth(12),
Expanded(
child: PurchaseStatCard(
title: 'Vendor',
value: '${s.totalVendors} vendor',
icon: LineIcons.truck,
iconColor: AppColor.primaryDark,
cardAnimation: _fadeAnimation,
),
),
],
),
],
);
}
Widget _buildTotalPurchasesCard(PurchasingAnalyticLoaderState state) {
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 900),
curve: Curves.bounceOut,
builder: (context, value, _) {
return Transform.scale(
scale: value,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: AppColor.primaryGradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColor.primary.withOpacity(0.35),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
LineIcons.shoppingBag,
color: AppColor.textWhite,
size: 28,
),
),
const SpaceWidth(16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.lang.total_purchase,
style: TextStyle(
color: AppColor.textWhite.withOpacity(0.9),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SpaceHeight(4),
Text(
state.purchasing.summary.totalPurchases
.currencyFormatRp,
style: const TextStyle(
color: AppColor.textWhite,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${state.purchasing.summary.totalPurchaseOrders} PO',
style: const TextStyle(
color: AppColor.textWhite,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
'purchase order',
style: TextStyle(
color: AppColor.textWhite.withOpacity(0.8),
fontSize: 12,
),
),
],
),
],
),
),
);
},
);
}
// Lists // Lists
Widget _buildDailyList(PurchasingAnalyticLoaderState state) { Widget _buildDailyList(PurchasingAnalyticLoaderState state) {
if (state.purchasing.data.isEmpty) { if (state.purchasing.data.isEmpty) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: _buildEmptyState('Tidak ada data harian')); child: _buildEmptyState('Tidak ada data harian'),
);
} }
return SliverList( return SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
@ -499,7 +255,8 @@ class _PurchasePageState extends State<PurchasePage>
Widget _buildIngredientList(PurchasingAnalyticLoaderState state) { Widget _buildIngredientList(PurchasingAnalyticLoaderState state) {
if (state.purchasing.ingredientData.isEmpty) { if (state.purchasing.ingredientData.isEmpty) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: _buildEmptyState('Tidak ada data bahan baku')); child: _buildEmptyState('Tidak ada data bahan baku'),
);
} }
return SliverList( return SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
@ -516,7 +273,8 @@ class _PurchasePageState extends State<PurchasePage>
Widget _buildVendorList(PurchasingAnalyticLoaderState state) { Widget _buildVendorList(PurchasingAnalyticLoaderState state) {
if (state.purchasing.vendorData.isEmpty) { if (state.purchasing.vendorData.isEmpty) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: _buildEmptyState('Tidak ada data vendor')); child: _buildEmptyState('Tidak ada data vendor'),
);
} }
return SliverList( return SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
@ -552,51 +310,6 @@ class _PurchasePageState extends State<PurchasePage>
// Shimmer Loaders // Shimmer Loaders
Widget _buildSummaryShimmer() {
return Column(
children: [
Row(
children: [
Expanded(child: _shimmerCard(height: 100)),
const SpaceWidth(12),
Expanded(child: _shimmerCard(height: 100)),
],
),
const SpaceHeight(12),
Row(
children: [
Expanded(child: _shimmerCard(height: 100)),
const SpaceWidth(12),
Expanded(child: _shimmerCard(height: 100)),
],
),
const SpaceHeight(12),
Row(
children: [
Expanded(child: _shimmerCard(height: 100)),
const SpaceWidth(12),
Expanded(child: _shimmerCard(height: 100)),
],
),
const SpaceHeight(12),
Row(
children: [
Expanded(child: _shimmerCard(height: 100)),
const SpaceWidth(12),
Expanded(child: _shimmerCard(height: 100)),
],
),
],
);
}
Widget _buildHighlightShimmer() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: _shimmerCard(height: 88),
);
}
Widget _buildListShimmer() { Widget _buildListShimmer() {
return SliverList( return SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(

View File

@ -0,0 +1,318 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import '../../../../application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_bloc.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/painter/wave_painter.dart';
import '../../../../common/theme/theme.dart';
import '../../../components/bottom_sheet/date_range_bottom_sheet.dart';
import '../../../components/spacer/spacer.dart';
class PurchaseHeader extends StatelessWidget {
final PurchasingAnalyticLoaderState state;
final void Function(DateTime startDate, DateTime endDate)? onDateRangeChanged;
const PurchaseHeader({
super.key,
required this.state,
this.onDateRangeChanged,
});
@override
Widget build(BuildContext context) {
final dateLabel = _formatDateRange(state.dateFrom, state.dateTo, context);
final outletLabel = state.purchasing.outletName.isNotEmpty
? state.purchasing.outletName
: 'Semua Outlet';
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: AppColor.primaryGradient,
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(24),
bottomRight: Radius.circular(24),
),
),
child: Stack(
children: [
// Decorative circles
Positioned(
top: -20,
right: -30,
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColor.textWhite.withOpacity(0.08),
),
),
),
Positioned(
top: 30,
right: 20,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColor.textWhite.withOpacity(0.05),
),
),
),
Positioned(
top: 10,
left: -20,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColor.textWhite.withOpacity(0.04),
),
),
),
// Wave pattern
Positioned.fill(
child: CustomPaint(
painter: WavePainter(
animation: 0.0,
color: AppColor.textWhite.withOpacity(0.1),
),
),
),
// Content
SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Back button + Title row + Calendar button
Row(
children: [
GestureDetector(
onTap: () => context.router.maybePop(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColor.textWhite.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.chevron_left_rounded,
color: AppColor.textWhite,
size: 24,
),
),
),
const SpaceWidth(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.lang.purchase,
style: AppStyle.xl.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w700,
fontSize: 20,
),
),
const SizedBox(height: 2),
Text(
'$dateLabel · $outletLabel',
style: AppStyle.sm.copyWith(
color: AppColor.textWhite.withOpacity(0.75),
fontWeight: FontWeight.w400,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SpaceWidth(8),
// Date filter button
GestureDetector(
onTap: () => _showDatePicker(context),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColor.textWhite.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.calendar_month_rounded,
color: AppColor.textWhite,
size: 20,
),
),
),
],
),
const SpaceHeight(24),
// Total Pembelian label
Text(
context.lang.total_purchase,
style: AppStyle.sm.copyWith(
color: AppColor.textWhite.withOpacity(0.75),
fontWeight: FontWeight.w400,
fontSize: 13,
),
),
const SpaceHeight(4),
// Big value
state.isFetching
? _buildHeaderValueShimmer()
: Text(
state
.purchasing
.summary
.totalPurchases
.currencyFormatRp,
style: AppStyle.h1.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w900,
fontSize: 32,
),
),
const SpaceHeight(16),
// Chips row
state.isFetching
? _buildHeaderChipsShimmer()
: _buildHeaderChips(context),
],
),
),
),
],
),
);
}
void _showDatePicker(BuildContext context) {
DateRangePickerBottomSheet.show(
context: context,
primaryColor: AppColor.primary,
initialStartDate: state.dateFrom,
initialEndDate: state.dateTo,
maxDate: DateTime.now(),
onChanged: (startDate, endDate) {
if (startDate != null && endDate != null) {
onDateRangeChanged?.call(startDate, endDate);
}
},
);
}
Widget _buildHeaderValueShimmer() {
return Shimmer.fromColors(
baseColor: AppColor.textWhite.withOpacity(0.3),
highlightColor: AppColor.textWhite.withOpacity(0.6),
child: Container(
width: 200,
height: 36,
decoration: BoxDecoration(
color: AppColor.textWhite.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
),
),
);
}
Widget _buildHeaderChipsShimmer() {
return Row(
children: List.generate(
3,
(index) => Padding(
padding: const EdgeInsets.only(right: 8),
child: Shimmer.fromColors(
baseColor: AppColor.textWhite.withOpacity(0.15),
highlightColor: AppColor.textWhite.withOpacity(0.3),
child: Container(
width: 90,
height: 32,
decoration: BoxDecoration(
color: AppColor.textWhite.withOpacity(0.15),
borderRadius: BorderRadius.circular(20),
),
),
),
),
),
);
}
Widget _buildHeaderChips(BuildContext context) {
final summary = state.purchasing.summary;
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildChip('${summary.totalPurchaseOrders} PO'),
_buildChip('${summary.totalQuantity} pcs'),
_buildChip('${summary.totalVendors} vendor'),
],
);
}
Widget _buildChip(String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: AppColor.textWhite.withOpacity(0.15),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColor.textWhite.withOpacity(0.25)),
),
child: Text(
label,
style: AppStyle.sm.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
);
}
String _formatDateRange(DateTime from, DateTime to, BuildContext context) {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'Mei',
'Jun',
'Jul',
'Agu',
'Sep',
'Okt',
'Nov',
'Des',
];
if (from.year == to.year && from.month == to.month && from.day == to.day) {
return '${context.lang.report} ${from.day} ${months[from.month - 1]} ${from.year}';
}
return '${context.lang.report} ${from.day} ${months[from.month - 1]} - ${to.day} ${months[to.month - 1]} ${to.year}';
}
}

View File

@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import '../../../../application/analytic/purchasing_analytic_loader/purchasing_analytic_loader_bloc.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../components/spacer/spacer.dart';
class PurchaseRincianCard extends StatelessWidget {
final PurchasingAnalyticLoaderState state;
const PurchaseRincianCard({super.key, required this.state});
@override
Widget build(BuildContext context) {
if (state.isFetching) return _buildShimmer();
final summary = state.purchasing.summary;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.lang.daily_breakdown,
style: AppStyle.xl.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
fontSize: 18,
),
),
const SpaceHeight(16),
_buildRow('Total Pembelian', summary.totalPurchases.currencyFormatRp),
_buildDivider(),
_buildRow(
'Bahan Baku',
summary.rawMaterialPurchases.currencyFormatRp,
),
_buildDivider(),
_buildRow('Pengeluaran', summary.expensePurchases.currencyFormatRp),
_buildDivider(),
_buildRow('Total PO', '${summary.totalPurchaseOrders} PO'),
_buildDivider(),
_buildRow('Total Qty', '${summary.totalQuantity} pcs'),
_buildDivider(),
_buildRow(
'Rata-rata PO',
summary.averagePurchaseOrderValue.round().currencyFormatRp,
),
_buildDivider(),
_buildRow('Item Bahan Baku', '${summary.totalIngredients} item'),
_buildDivider(),
_buildRow('Vendor', '${summary.totalVendors} vendor'),
],
),
);
}
Widget _buildRow(String label, String value, {bool isBold = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: AppStyle.md.copyWith(
color: isBold ? AppColor.textPrimary : AppColor.textSecondary,
fontWeight: isBold ? FontWeight.w700 : FontWeight.w500,
fontSize: 14,
),
),
Text(
value,
style: AppStyle.md.copyWith(
color: AppColor.textPrimary,
fontWeight: isBold ? FontWeight.w700 : FontWeight.w600,
fontSize: 14,
),
),
],
),
);
}
Widget _buildDivider() {
return Divider(
height: 1,
thickness: 1,
color: AppColor.border.withOpacity(0.5),
);
}
Widget _buildShimmer() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColor.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 140,
height: 20,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SpaceHeight(20),
...List.generate(
8,
(index) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 100,
height: 14,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
Container(
width: 80,
height: 14,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
),
],
),
),
);
}
}