Efril 75415cc6ff
Some checks are pending
Build & Deploy iOS to TestFlight / build-and-deploy (push) Waiting to run
feat: update main, exclusive summary and stock
2026-06-24 11:03:28 +07:00

805 lines
26 KiB
Dart

import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:shimmer/shimmer.dart';
import '../../../application/analytic/dashboard_analytic_loader/dashboard_analytic_loader_bloc.dart';
import '../../../application/analytic/exclusive_summary_loader/exclusive_summary_loader_bloc.dart';
import '../../../common/extension/extension.dart';
import '../../../common/theme/theme.dart';
import '../../../domain/analytic/analytic.dart';
import '../../../injection.dart';
import '../../components/field/date_range_picker_field.dart';
import '../../components/spacer/spacer.dart';
import 'widgets/daily_revenue_chart.dart';
@RoutePage()
class ExclusiveSummaryPage extends StatefulWidget implements AutoRouteWrapper {
const ExclusiveSummaryPage({super.key});
@override
State<ExclusiveSummaryPage> createState() => _ExclusiveSummaryPageState();
@override
Widget wrappedRoute(BuildContext context) => MultiBlocProvider(
providers: [
BlocProvider(
create: (context) =>
getIt<ExclusiveSummaryLoaderBloc>()
..add(ExclusiveSummaryLoaderEvent.fetched()),
),
BlocProvider(
create: (context) =>
getIt<DashboardAnalyticLoaderBloc>()
..add(DashboardAnalyticLoaderEvent.fetched()),
),
],
child: this,
);
}
class _ExclusiveSummaryPageState extends State<ExclusiveSummaryPage>
with SingleTickerProviderStateMixin {
late AnimationController _fadeController;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_fadeController = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(parent: _fadeController, curve: Curves.easeIn));
_fadeController.forward();
}
@override
void dispose() {
_fadeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColor.background,
body: BlocListener<ExclusiveSummaryLoaderBloc, ExclusiveSummaryLoaderState>(
listenWhen: (prev, curr) =>
prev.dateFrom != curr.dateFrom || prev.dateTo != curr.dateTo,
listener: (context, state) {
context.read<ExclusiveSummaryLoaderBloc>().add(
ExclusiveSummaryLoaderEvent.fetched(),
);
},
child:
BlocBuilder<
ExclusiveSummaryLoaderBloc,
ExclusiveSummaryLoaderState
>(
builder: (context, state) {
return RefreshIndicator(
color: AppColor.primary,
onRefresh: () async {
context.read<ExclusiveSummaryLoaderBloc>().add(
ExclusiveSummaryLoaderEvent.fetched(),
);
await context
.read<ExclusiveSummaryLoaderBloc>()
.stream
.firstWhere((s) => !s.isFetching);
},
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
// Header with gradient background and summary
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child: _buildHeader(context, state),
),
),
// Date Range Picker
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child: Padding(
padding: const EdgeInsets.all(16),
child: DateRangePickerField(
maxDate: DateTime.now(),
startDate: state.dateFrom,
endDate: state.dateTo,
onChanged: (startDate, endDate) {
context.read<ExclusiveSummaryLoaderBloc>().add(
ExclusiveSummaryLoaderEvent.rangeDateChanged(
startDate!,
endDate!,
),
);
},
),
),
),
),
// Report Table (like ProfitLossReport)
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child: state.isFetching
? _buildShimmer()
: _buildReportTable(
context,
state.exclusiveSummary,
),
),
),
// Omzet Harian Chart (from Dashboard Recent Sales API)
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child:
BlocBuilder<
DashboardAnalyticLoaderBloc,
DashboardAnalyticLoaderState
>(
builder: (context, dashState) {
if (dashState.isFetching) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
child: _shimmerBox(height: 260),
);
}
return Padding(
padding: const EdgeInsets.only(top: 16),
child: DailyRevenueChart(
salesData: dashState
.dashboardAnalytic
.recentSales,
),
);
},
),
),
),
// Reimburse Section
if (!state.isFetching)
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child: _buildReimburseSection(
context,
state.exclusiveSummary.reimburse,
),
),
),
// Bottom spacing
const SliverToBoxAdapter(child: SizedBox(height: 100)),
],
),
);
},
),
),
);
}
// ─── HEADER ─────────────────────────────────────────────────────────────────
Widget _buildHeader(BuildContext context, ExclusiveSummaryLoaderState state) {
final summary = state.exclusiveSummary.summary;
final outletLabel = state.exclusiveSummary.outletName.isNotEmpty
? state.exclusiveSummary.outletName
: context.lang.all_outlets;
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: 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: [
if (context.router.canPop()) ...[
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.report,
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(24),
// Big net profit value
state.isFetching
? _buildHeaderValueShimmer()
: Text(
summary.netProfit.currencyFormatRp,
style: AppStyle.h1.copyWith(
color: summary.netProfit >= 0
? AppColor.textWhite
: AppColor.textWhite.withOpacity(0.7),
fontWeight: FontWeight.w900,
fontSize: 32,
),
),
const SpaceHeight(4),
Text(
context.lang.net_profit_loss,
style: AppStyle.sm.copyWith(
color: AppColor.textWhite.withOpacity(0.75),
fontWeight: FontWeight.w400,
fontSize: 13,
),
),
const SpaceHeight(16),
// Chips row (Sales + Total Biaya)
state.isFetching
? _buildHeaderChipsShimmer()
: Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildChip(
'${context.lang.total_sales} ${summary.sales.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,
),
),
);
}
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),
),
),
),
),
),
);
}
// ─── REPORT TABLE (like ProfitLossReport) ───────────────────────────────────
Widget _buildReportTable(BuildContext context, ExclusiveSummary data) {
final summary = data.summary;
final sales = summary.sales;
final totalCost = summary.totalCost;
// Calculate percentages relative to sales
double pct(int value) {
if (sales == 0) return 0;
return (value / sales) * 100;
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 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.report,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.textPrimary,
),
),
Text(
_formatDateLabel(data.period.dateFrom, data.period.dateTo),
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w400,
),
),
],
),
const SizedBox(height: 20),
// Sales (bold header)
_buildItemRow(
label: context.lang.total_sales,
nominal: sales,
percentage: 100,
isBold: true,
isSubItem: false,
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Divider(height: 1, color: AppColor.borderLight),
),
// HPP
_buildItemRow(
label: context.lang.hpp,
nominal: summary.hpp,
percentage: pct(summary.hpp),
isBold: false,
isSubItem: false,
),
// Gross Profit (bold)
_buildItemRow(
label: context.lang.gross_profit,
nominal: summary.grossProfit,
percentage: pct(summary.grossProfit),
isBold: true,
isSubItem: false,
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Divider(height: 1, color: AppColor.borderLight),
),
// Biaya Operasional (bold header)
_buildItemRow(
label: context.lang.operational_expenses,
nominal: summary.operationalExpensesTotal,
percentage: pct(summary.operationalExpensesTotal),
isBold: true,
isSubItem: false,
),
// Sub-items: Gaji
_buildItemRow(
label: context.lang.salary_total,
nominal: summary.salaryTotal,
percentage: pct(summary.salaryTotal),
isBold: false,
isSubItem: true,
),
// Sub-items: Gaji DW
_buildItemRow(
label: context.lang.salary_dw,
nominal: summary.salaryDw,
percentage: pct(summary.salaryDw),
isBold: false,
isSubItem: true,
),
// Sub-items: Gaji Staff
_buildItemRow(
label: context.lang.salary_staff,
nominal: summary.salaryStaff,
percentage: pct(summary.salaryStaff),
isBold: false,
isSubItem: true,
),
// Sub-items: Gaji Lainnya
_buildItemRow(
label: context.lang.salary_other,
nominal: summary.salaryOther,
percentage: pct(summary.salaryOther),
isBold: false,
isSubItem: true,
),
// Sub-items: Biaya Operasional Lainnya
_buildItemRow(
label: context.lang.other_operational_expenses,
nominal: summary.otherOperationalExpenses,
percentage: pct(summary.otherOperationalExpenses),
isBold: false,
isSubItem: true,
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Divider(height: 1, color: AppColor.borderLight),
),
// Total Biaya
_buildItemRow(
label: context.lang.total_cost,
nominal: totalCost,
percentage: pct(totalCost),
isBold: true,
isSubItem: false,
),
const SizedBox(height: 12),
// Net Profit/Loss footer
_buildNetProfitFooter(context, summary),
],
),
);
}
Widget _buildItemRow({
required String label,
required int nominal,
required double percentage,
required bool isBold,
required bool isSubItem,
}) {
final isNegative = nominal < 0;
final displayNominal = isNegative
? '-${nominal.abs().currencyFormatRp}'
: nominal.currencyFormatRp;
final pctText = '${percentage.round()}%';
Color nominalColor;
if (isBold && isNegative) {
nominalColor = AppColor.error;
} 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,
ExclusiveSummarySummary summary,
) {
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,
),
),
],
),
);
}
// ─── REIMBURSE SECTION ──────────────────────────────────────────────────────
Widget _buildReimburseSection(
BuildContext context,
ExclusiveSummaryReimburse reimburse,
) {
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: [
Text(
context.lang.reimburse_summary,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.textPrimary,
),
),
const SizedBox(height: 16),
_buildReimburseRow(
context.lang.total_cost,
reimburse.totalCost.currencyFormatRp,
),
const Divider(height: 20, color: AppColor.borderLight),
_buildReimburseRow(
context.lang.excluded_salary_staff,
reimburse.excludedSalaryStaff.currencyFormatRp,
),
const Divider(height: 20, color: AppColor.borderLight),
_buildReimburseRow(
context.lang.total_reimburse,
reimburse.totalReimburse.currencyFormatRp,
isHighlighted: true,
),
],
),
);
}
Widget _buildReimburseRow(
String label,
String value, {
bool isHighlighted = false,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: AppStyle.md.copyWith(
color: isHighlighted
? AppColor.textPrimary
: AppColor.textSecondary,
fontWeight: isHighlighted ? FontWeight.bold : FontWeight.normal,
),
),
Text(
value,
style: AppStyle.md.copyWith(
color: isHighlighted ? AppColor.primary : AppColor.textPrimary,
fontWeight: isHighlighted ? FontWeight.bold : FontWeight.w600,
),
),
],
);
}
// ─── SHIMMER ────────────────────────────────────────────────────────────────
Widget _buildShimmer() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
_shimmerBox(height: 300),
const SpaceHeight(16),
_shimmerBox(height: 120),
],
),
);
}
Widget _shimmerBox({required double height}) {
return Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: Container(
height: height,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
),
);
}
// ─── HELPERS ────────────────────────────────────────────────────────────────
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}';
}
}