feat: update profit loss ui
Some checks are pending
Build & Deploy iOS to TestFlight / build-and-deploy (push) Waiting to run
Some checks are pending
Build & Deploy iOS to TestFlight / build-and-deploy (push) Waiting to run
This commit is contained in:
parent
b07af60778
commit
0917c5132b
File diff suppressed because it is too large
Load Diff
@ -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
@ -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(
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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": {}
|
||||||
|
}
|
||||||
|
|||||||
@ -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": {}
|
||||||
|
}
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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: [
|
|
||||||
BlocProvider(
|
|
||||||
create: (_) =>
|
create: (_) =>
|
||||||
getIt<ProfitLossLoaderBloc>()..add(ProfitLossLoaderEvent.fetched()),
|
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,88 +71,47 @@ 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(
|
if (index == 0) {
|
||||||
children: [
|
// Today
|
||||||
Expanded(
|
dateFrom = DateTime(now.year, now.month, now.day);
|
||||||
child: FinanceSummaryCard(
|
dateTo = DateTime(now.year, now.month, now.day, 23, 59, 59);
|
||||||
title: context.lang.total_revenue,
|
} else {
|
||||||
amount: summary.totalRevenue.currencyFormatRp,
|
// MTD (Month-to-Date)
|
||||||
icon: LineIcons.arrowUp,
|
dateFrom = DateTime(now.year, now.month, 1);
|
||||||
color: AppColor.success,
|
dateTo = now;
|
||||||
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) {
|
context.read<ProfitLossLoaderBloc>().add(
|
||||||
return Container(
|
ProfitLossLoaderEvent.rangeDateChanged(dateFrom, dateTo),
|
||||||
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);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
143
lib/presentation/pages/finance/widgets/cost_breakdown.dart
Normal file
143
lib/presentation/pages/finance/widgets/cost_breakdown.dart
Normal 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}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
389
lib/presentation/pages/finance/widgets/profit_loss_header.dart
Normal file
389
lib/presentation/pages/finance/widgets/profit_loss_header.dart
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
238
lib/presentation/pages/finance/widgets/profit_loss_report.dart
Normal file
238
lib/presentation/pages/finance/widgets/profit_loss_report.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,11 +27,13 @@ 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) =>
|
||||||
|
getIt<PurchasingAnalyticLoaderBloc>()
|
||||||
..add(const PurchasingAnalyticLoaderEvent.fetched()),
|
..add(const PurchasingAnalyticLoaderEvent.fetched()),
|
||||||
),
|
),
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
create: (context) => getIt<OutletListLoaderBloc>()
|
create: (context) =>
|
||||||
|
getIt<OutletListLoaderBloc>()
|
||||||
..add(const OutletListLoaderEvent.fetched()),
|
..add(const OutletListLoaderEvent.fetched()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -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,107 +96,60 @@ 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: [
|
|
||||||
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(
|
|
||||||
selectedOutletId: state.outletId,
|
selectedOutletId: state.outletId,
|
||||||
outlets: outletListState.outlets,
|
outlets: outletListState.outlets,
|
||||||
isLoading: outletListState.isFetching,
|
isLoading: outletListState.isFetching,
|
||||||
onOutletChanged: (outletId) {
|
onOutletChanged: (outletId) {
|
||||||
context
|
context.read<PurchasingAnalyticLoaderBloc>().add(
|
||||||
.read<PurchasingAnalyticLoaderBloc>()
|
PurchasingAnalyticLoaderEvent.outletChanged(
|
||||||
.add(
|
outletId,
|
||||||
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(
|
||||||
@ -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(
|
||||||
|
|||||||
318
lib/presentation/pages/purchase/widgets/purchase_header.dart
Normal file
318
lib/presentation/pages/purchase/widgets/purchase_header.dart
Normal 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}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user