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 {
const factory ProfitLossAnalytic({
required String organizationId,
required String outletId,
required String outletName,
required String dateFrom,
required String dateTo,
required String groupBy,
required ProfitLossSummary summary,
required List<ProfitLossDailyData> data,
required List<ProfitLossProductData> productData,
required List<ProfitLossMainSummaryItem> mainSummary,
required ProfitLossPurchasing purchasing,
required List<ProfitLossOperationalExpense> operationalExpenses,
required int operationalExpensesTotal,
}) = _ProfitLossAnalytic;
factory ProfitLossAnalytic.empty() => ProfitLossAnalytic(
organizationId: '',
outletId: '',
outletName: '',
dateFrom: '',
dateTo: '',
groupBy: '',
summary: ProfitLossSummary.empty(),
data: [],
productData: [],
mainSummary: [],
purchasing: ProfitLossPurchasing.empty(),
operationalExpenses: [],
operationalExpensesTotal: 0,
);
}
@ -115,3 +127,79 @@ class ProfitLossProductData with _$ProfitLossProductData {
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,
) => _$ProfitLossAnalyticDtoImpl(
organizationId: json['organization_id'] as String?,
outletId: json['outlet_id'] as String?,
outletName: json['outlet_name'] as String?,
dateFrom: json['date_from'] as String?,
dateTo: json['date_to'] as String?,
groupBy: json['group_by'] as String?,
@ -106,18 +108,42 @@ _$ProfitLossAnalyticDtoImpl _$$ProfitLossAnalyticDtoImplFromJson(
productData: (json['product_data'] as List<dynamic>?)
?.map((e) => ProfitLossProductDataDto.fromJson(e as Map<String, dynamic>))
.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(
_$ProfitLossAnalyticDtoImpl instance,
) => <String, dynamic>{
'organization_id': instance.organizationId,
'outlet_id': instance.outletId,
'outlet_name': instance.outletName,
'date_from': instance.dateFrom,
'date_to': instance.dateTo,
'group_by': instance.groupBy,
'summary': instance.summary,
'data': instance.data,
'product_data': instance.productData,
'main_summary': instance.mainSummary,
'purchasing': instance.purchasing,
'operational_expenses': instance.operationalExpenses,
'operational_expenses_total': instance.operationalExpensesTotal,
};
_$ProfitLossSummaryDtoImpl _$$ProfitLossSummaryDtoImplFromJson(
@ -216,6 +242,93 @@ Map<String, dynamic> _$$ProfitLossProductDataDtoImplToJson(
'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(
Map<String, dynamic> json,
) => _$CategoryAnalyticDtoImpl(

View File

@ -6,12 +6,20 @@ class ProfitLossAnalyticDto with _$ProfitLossAnalyticDto {
const factory ProfitLossAnalyticDto({
@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_to') String? dateTo,
@JsonKey(name: 'group_by') String? groupBy,
@JsonKey(name: 'summary') ProfitLossSummaryDto? summary,
@JsonKey(name: 'data') List<ProfitLossDailyDataDto>? data,
@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;
factory ProfitLossAnalyticDto.fromJson(Map<String, dynamic> json) =>
@ -19,12 +27,20 @@ class ProfitLossAnalyticDto with _$ProfitLossAnalyticDto {
ProfitLossAnalytic toDomain() => ProfitLossAnalytic(
organizationId: organizationId ?? '',
outletId: outletId ?? '',
outletName: outletName ?? '',
dateFrom: dateFrom ?? '',
dateTo: dateTo ?? '',
groupBy: groupBy ?? '',
summary: summary?.toDomain() ?? ProfitLossSummary.empty(),
data: (data ?? []).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,
);
}
@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"
}
}
},
"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"
}
}
},
"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:
/// **'{count} portions sold'**
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> {

View File

@ -710,4 +710,23 @@ class AppLocalizationsEn extends AppLocalizations {
String portion_sold(int count) {
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) {
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: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';
import '../../../domain/analytic/analytic.dart';
import '../../../injection.dart';
import '../../components/appbar/appbar.dart';
import '../../components/field/date_range_picker_field.dart';
import 'widgets/cash_flow.dart';
import 'widgets/category.dart';
import 'widgets/product.dart';
import 'widgets/profit_loss.dart';
import 'widgets/summary_card.dart';
import 'widgets/cost_breakdown.dart';
import 'widgets/profit_loss_header.dart';
import 'widgets/profit_loss_report.dart';
@RoutePage()
class FinancePage extends StatefulWidget implements AutoRouteWrapper {
@ -25,80 +17,40 @@ class FinancePage extends StatefulWidget implements AutoRouteWrapper {
State<FinancePage> createState() => _FinancePageState();
@override
Widget wrappedRoute(BuildContext context) => MultiBlocProvider(
providers: [
BlocProvider(
Widget wrappedRoute(BuildContext context) => BlocProvider(
create: (_) =>
getIt<ProfitLossLoaderBloc>()..add(ProfitLossLoaderEvent.fetched()),
),
BlocProvider(
create: (context) =>
getIt<CategoryAnalyticLoaderBloc>()
..add(CategoryAnalyticLoaderEvent.fetched()),
),
],
child: this,
);
}
class _FinancePageState extends State<FinancePage>
with TickerProviderStateMixin {
late AnimationController _slideController;
with SingleTickerProviderStateMixin {
late AnimationController _fadeController;
late AnimationController _scaleController;
late Animation<Offset> _slideAnimation;
late Animation<double> _fadeAnimation;
late Animation<double> _scaleAnimation;
int _selectedTabIndex = 0;
@override
void initState() {
super.initState();
_slideController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_fadeController = AnimationController(
duration: const Duration(milliseconds: 1000),
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>(
begin: 0.0,
end: 1.0,
).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();
Future.delayed(const Duration(milliseconds: 200), () {
_slideController.forward();
});
Future.delayed(const Duration(milliseconds: 400), () {
_scaleController.forward();
});
}
@override
void dispose() {
_slideController.dispose();
_fadeController.dispose();
_scaleController.dispose();
super.dispose();
}
@ -119,88 +71,47 @@ class _FinancePageState extends State<FinancePage>
builder: (context, state) {
return CustomScrollView(
slivers: [
// SliverAppBar with animated background
SliverAppBar(
expandedHeight: 120,
floating: false,
pinned: true,
backgroundColor: AppColor.primary,
elevation: 0,
flexibleSpace: CustomAppBar(title: context.lang.profit_loss),
),
// Header dengan filter periode
// Header with gradient background, tabs, and summary
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: DateRangePickerField(
maxDate: DateTime.now(),
startDate: state.dateFrom,
endDate: state.dateTo,
onChanged: (startDate, endDate) {
context.read<ProfitLossLoaderBloc>().add(
ProfitLossLoaderEvent.rangeDateChanged(
startDate!,
endDate!,
),
);
child: ProfitLossHeader(
state: state,
selectedTabIndex: _selectedTabIndex,
onTabChanged: (index) {
setState(() {
_selectedTabIndex = index;
});
_onTabChanged(context, index);
},
),
),
),
),
// Summary Cards
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
// Profit Loss Report Table
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child: FinanceProfitLoss(data: state.profitLoss.summary),
child: ProfitLossReport(
mainSummary: state.profitLoss.mainSummary,
summary: state.profitLoss.summary,
selectedTabIndex: _selectedTabIndex,
),
),
),
BlocBuilder<
CategoryAnalyticLoaderBloc,
CategoryAnalyticLoaderState
>(
builder: (context, stateCategory) {
return SliverToBoxAdapter(
child: SlideTransition(
position: _slideAnimation,
child: FinanceCategory(
categories: stateCategory.categoryAnalytic.data,
),
),
);
},
),
// Product Analysis Section
// Cost Breakdown
SliverToBoxAdapter(
child: SlideTransition(
position: _slideAnimation,
child: _buildProductAnalysis(state.profitLoss.productData),
child: FadeTransition(
opacity: _fadeAnimation,
child: CostBreakdown(
purchasing: state.profitLoss.purchasing,
selectedTabIndex: _selectedTabIndex,
dateFrom: state.dateFrom,
dateTo: state.dateTo,
),
),
),
// Transaction Categories
// Bottom spacing
const SliverToBoxAdapter(child: SizedBox(height: 100)),
@ -212,125 +123,23 @@ class _FinancePageState extends State<FinancePage>
);
}
Widget _buildSummaryCards(ProfitLossSummary summary) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
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,
),
),
],
),
],
),
);
void _onTabChanged(BuildContext context, int index) {
final now = DateTime.now();
DateTime dateFrom;
DateTime dateTo;
if (index == 0) {
// Today
dateFrom = DateTime(now.year, now.month, now.day);
dateTo = DateTime(now.year, now.month, now.day, 23, 59, 59);
} else {
// MTD (Month-to-Date)
dateFrom = DateTime(now.year, now.month, 1);
dateTo = now;
}
Widget _buildProductAnalysis(List<ProfitLossProductData> products) {
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.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);
},
),
],
),
context.read<ProfitLossLoaderBloc>().add(
ProfitLossLoaderEvent.rangeDateChanged(dateFrom, dateTo),
);
}
}

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:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:line_icons/line_icons.dart';
import 'package:shimmer/shimmer.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/theme/theme.dart';
import '../../../injection.dart';
import '../../components/appbar/appbar.dart';
import '../../components/field/date_range_picker_field.dart';
import '../../components/spacer/spacer.dart';
import 'widgets/ingredient_card.dart';
import 'widgets/outlet_selector_field.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';
@RoutePage()
@ -29,11 +27,13 @@ class PurchasePage extends StatefulWidget implements AutoRouteWrapper {
Widget wrappedRoute(BuildContext context) => MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => getIt<PurchasingAnalyticLoaderBloc>()
create: (context) =>
getIt<PurchasingAnalyticLoaderBloc>()
..add(const PurchasingAnalyticLoaderEvent.fetched()),
),
BlocProvider(
create: (context) => getIt<OutletListLoaderBloc>()
create: (context) =>
getIt<OutletListLoaderBloc>()
..add(const OutletListLoaderEvent.fetched()),
),
],
@ -74,19 +74,20 @@ class _PurchasePageState extends State<PurchasePage>
backgroundColor: AppColor.background,
body: MultiBlocListener(
listeners: [
// Re-fetch when date range changes
BlocListener<PurchasingAnalyticLoaderBloc,
PurchasingAnalyticLoaderState>(
BlocListener<
PurchasingAnalyticLoaderBloc,
PurchasingAnalyticLoaderState
>(
listenWhen: (prev, curr) =>
prev.dateFrom != curr.dateFrom ||
prev.dateTo != curr.dateTo,
prev.dateFrom != curr.dateFrom || prev.dateTo != curr.dateTo,
listener: (context, _) => context
.read<PurchasingAnalyticLoaderBloc>()
.add(const PurchasingAnalyticLoaderEvent.fetched()),
),
// Re-fetch when outlet changes
BlocListener<PurchasingAnalyticLoaderBloc,
PurchasingAnalyticLoaderState>(
BlocListener<
PurchasingAnalyticLoaderBloc,
PurchasingAnalyticLoaderState
>(
listenWhen: (prev, curr) => prev.outletId != curr.outletId,
listener: (context, _) => context
.read<PurchasingAnalyticLoaderBloc>()
@ -95,107 +96,60 @@ class _PurchasePageState extends State<PurchasePage>
],
child: BlocBuilder<OutletListLoaderBloc, OutletListLoaderState>(
builder: (context, outletListState) {
return BlocBuilder<PurchasingAnalyticLoaderBloc,
PurchasingAnalyticLoaderState>(
return BlocBuilder<
PurchasingAnalyticLoaderBloc,
PurchasingAnalyticLoaderState
>(
builder: (context, state) {
return CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
expandedHeight: 120.0,
floating: false,
pinned: true,
elevation: 0,
backgroundColor: AppColor.primary,
flexibleSpace:
CustomAppBar(title: context.lang.purchase),
// Header (same style as Sales)
SliverToBoxAdapter(
child: PurchaseHeader(
state: state,
onDateRangeChanged: (startDate, endDate) {
context.read<PurchasingAnalyticLoaderBloc>().add(
PurchasingAnalyticLoaderEvent.rangeDateChanged(
startDate,
endDate,
),
);
},
),
),
// Date Range + Outlet Picker
// Outlet Selector
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Column(
children: [
DateRangePickerField(
maxDate: DateTime.now(),
startDate: state.dateFrom,
endDate: state.dateTo,
onChanged: (startDate, endDate) {
context
.read<PurchasingAnalyticLoaderBloc>()
.add(
PurchasingAnalyticLoaderEvent
.rangeDateChanged(
startDate!,
endDate!,
),
);
},
),
const SpaceHeight(8),
PurchaseOutletSelectorField(
child: PurchaseOutletSelectorField(
selectedOutletId: state.outletId,
outlets: outletListState.outlets,
isLoading: outletListState.isFetching,
onOutletChanged: (outletId) {
context
.read<PurchasingAnalyticLoaderBloc>()
.add(
PurchasingAnalyticLoaderEvent
.outletChanged(outletId),
context.read<PurchasingAnalyticLoaderBloc>().add(
PurchasingAnalyticLoaderEvent.outletChanged(
outletId,
),
);
},
),
],
),
),
),
),
const SliverToBoxAdapter(child: SpaceHeight(16)),
// Summary Section
// Rincian Pembelian (same style as Sales)
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
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),
],
padding: const EdgeInsets.all(16),
child: PurchaseRincianCard(state: 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
SliverToBoxAdapter(
@ -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
Widget _buildDailyList(PurchasingAnalyticLoaderState state) {
if (state.purchasing.data.isEmpty) {
return SliverToBoxAdapter(
child: _buildEmptyState('Tidak ada data harian'));
child: _buildEmptyState('Tidak ada data harian'),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate(
@ -499,7 +255,8 @@ class _PurchasePageState extends State<PurchasePage>
Widget _buildIngredientList(PurchasingAnalyticLoaderState state) {
if (state.purchasing.ingredientData.isEmpty) {
return SliverToBoxAdapter(
child: _buildEmptyState('Tidak ada data bahan baku'));
child: _buildEmptyState('Tidak ada data bahan baku'),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate(
@ -516,7 +273,8 @@ class _PurchasePageState extends State<PurchasePage>
Widget _buildVendorList(PurchasingAnalyticLoaderState state) {
if (state.purchasing.vendorData.isEmpty) {
return SliverToBoxAdapter(
child: _buildEmptyState('Tidak ada data vendor'));
child: _buildEmptyState('Tidak ada data vendor'),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate(
@ -552,51 +310,6 @@ class _PurchasePageState extends State<PurchasePage>
// 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() {
return SliverList(
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),
),
),
],
),
),
),
],
),
),
);
}
}