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 createState() => _ExclusiveSummaryPageState(); @override Widget wrappedRoute(BuildContext context) => MultiBlocProvider( providers: [ BlocProvider( create: (context) => getIt() ..add(ExclusiveSummaryLoaderEvent.fetched()), ), BlocProvider( create: (context) => getIt() ..add(DashboardAnalyticLoaderEvent.fetched()), ), ], child: this, ); } class _ExclusiveSummaryPageState extends State with SingleTickerProviderStateMixin { late AnimationController _fadeController; late Animation _fadeAnimation; @override void initState() { super.initState(); _fadeController = AnimationController( duration: const Duration(milliseconds: 1000), vsync: this, ); _fadeAnimation = Tween( 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( listenWhen: (prev, curr) => prev.dateFrom != curr.dateFrom || prev.dateTo != curr.dateTo, listener: (context, state) { context.read().add( ExclusiveSummaryLoaderEvent.fetched(), ); }, child: BlocBuilder< ExclusiveSummaryLoaderBloc, ExclusiveSummaryLoaderState >( builder: (context, state) { return RefreshIndicator( color: AppColor.primary, onRefresh: () async { context.read().add( ExclusiveSummaryLoaderEvent.fetched(), ); await context .read() .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().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}'; } }