feat: profit sharing
Build & Deploy iOS to TestFlight / build-and-deploy (push) Canceled after 0s

This commit is contained in:
efrilm
2026-08-21 22:31:54 +07:00
parent f9bfb69254
commit 2e4c77888e
48 changed files with 12553 additions and 162 deletions
@@ -0,0 +1,238 @@
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/profit_sharing_detail_loader/profit_sharing_detail_loader_bloc.dart';
import '../../../common/extension/extension.dart';
import '../../../common/theme/theme.dart';
import '../../../domain/analytic/analytic.dart';
import '../../../injection.dart';
import '../../components/spacer/spacer.dart';
import 'widgets/profit_sharing_allocation.dart';
import 'widgets/profit_sharing_detail_header.dart';
import 'widgets/profit_sharing_status.dart';
import 'widgets/profit_sharing_subcategories.dart';
@RoutePage()
class ProfitSharingDetailPage extends StatelessWidget
implements AutoRouteWrapper {
final String parentCategoryId;
final String parentCategoryName;
final DateTime dateFrom;
final DateTime dateTo;
const ProfitSharingDetailPage({
super.key,
required this.parentCategoryId,
required this.parentCategoryName,
required this.dateFrom,
required this.dateTo,
});
@override
Widget wrappedRoute(BuildContext context) => BlocProvider(
create: (_) => getIt<ProfitSharingDetailLoaderBloc>()..add(_fetchEvent()),
child: this,
);
ProfitSharingDetailLoaderEvent _fetchEvent() =>
ProfitSharingDetailLoaderEvent.fetched(
parentCategoryId: parentCategoryId,
dateFrom: dateFrom,
dateTo: dateTo,
);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColor.background,
body:
BlocBuilder<
ProfitSharingDetailLoaderBloc,
ProfitSharingDetailLoaderState
>(
builder: (context, state) {
return RefreshIndicator(
backgroundColor: AppColor.white,
color: AppColor.primary,
onRefresh: () async {
context.read<ProfitSharingDetailLoaderBloc>().add(
_fetchEvent(),
);
await context
.read<ProfitSharingDetailLoaderBloc>()
.stream
.firstWhere((s) => !s.isFetching);
},
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: ProfitSharingDetailHeader(
state: state,
fallbackTitle: parentCategoryName,
),
),
const SliverToBoxAdapter(child: SpaceHeight(16)),
if (state.isFetching)
SliverToBoxAdapter(child: _buildShimmer())
else ...[
SliverToBoxAdapter(
child: _SummaryCard(summary: state.detail.summary),
),
const SliverToBoxAdapter(child: SpaceHeight(16)),
SliverToBoxAdapter(
child: ProfitSharingAllocation(
budget: state.detail.budget,
),
),
const SliverToBoxAdapter(child: SpaceHeight(16)),
SliverToBoxAdapter(
child: ProfitSharingSubCategories(
categories: state.detail.categories,
expandedCategoryId: state.expandedCategoryId,
onToggle: (categoryId) =>
context.read<ProfitSharingDetailLoaderBloc>().add(
ProfitSharingDetailLoaderEvent.expandedCategoryChanged(
categoryId,
),
),
),
),
],
const SliverToBoxAdapter(child: SizedBox(height: 100)),
],
),
);
},
),
);
}
Widget _buildShimmer() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
_shimmerBox(height: 160),
const SpaceHeight(16),
_shimmerBox(height: 240),
],
),
);
}
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),
),
),
);
}
}
class _SummaryCard extends StatelessWidget {
final ProfitSharingCategory summary;
const _SummaryCard({required this.summary});
@override
Widget build(BuildContext context) {
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: [
Text(
context.lang.summary,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.textPrimary,
),
),
const SpaceHeight(16),
Row(
children: [
Expanded(
child: ProfitSharingMetric(
label: context.lang.sub_category,
value: '${summary.categoryCount}',
),
),
Expanded(
child: ProfitSharingMetric(
label: context.lang.products,
value: '${summary.productCount}',
),
),
Expanded(
child: ProfitSharingMetric(
label: context.lang.orders,
value: summary.orderCount.thousandFormat,
),
),
],
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Divider(height: 1, color: AppColor.borderLight),
),
Row(
children: [
Expanded(
child: ProfitSharingMetric(
label: context.lang.std_hpp,
value: formatPercent(summary.standardHppPercentage),
),
),
Expanded(
child: ProfitSharingMetric(
label: context.lang.real_hpp,
value: formatPercent(summary.realHppPercentage),
valueColor: statusColor(summary.realHppPercentage),
),
),
Expanded(
child: ProfitSharingMetric(
label: context.lang.gross_profit,
value: summary.grossProfit.currencyFormatRp,
),
),
],
),
],
),
);
}
}
@@ -0,0 +1,203 @@
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/profit_sharing_loader/profit_sharing_loader_bloc.dart';
import '../../../common/theme/theme.dart';
import '../../../injection.dart';
import '../../components/field/date_range_picker_field.dart';
import '../../components/spacer/spacer.dart';
import '../../router/app_router.gr.dart';
import 'widgets/profit_sharing_allocation.dart';
import 'widgets/profit_sharing_categories.dart';
import 'widgets/profit_sharing_header.dart';
import 'widgets/profit_sharing_periods.dart';
@RoutePage()
class ProfitSharingPage extends StatefulWidget implements AutoRouteWrapper {
const ProfitSharingPage({super.key});
@override
State<ProfitSharingPage> createState() => _ProfitSharingPageState();
@override
Widget wrappedRoute(BuildContext context) => BlocProvider(
create: (_) =>
getIt<ProfitSharingLoaderBloc>()
..add(ProfitSharingLoaderEvent.fetched()),
child: this,
);
}
class _ProfitSharingPageState extends State<ProfitSharingPage>
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<ProfitSharingLoaderBloc, ProfitSharingLoaderState>(
listenWhen: (previous, current) =>
previous.dateFrom != current.dateFrom ||
previous.dateTo != current.dateTo,
listener: (context, state) {
context.read<ProfitSharingLoaderBloc>().add(
const ProfitSharingLoaderEvent.fetched(),
);
},
child: BlocBuilder<ProfitSharingLoaderBloc, ProfitSharingLoaderState>(
builder: (context, state) {
return RefreshIndicator(
backgroundColor: AppColor.white,
color: AppColor.primary,
onRefresh: () async {
context.read<ProfitSharingLoaderBloc>().add(
const ProfitSharingLoaderEvent.fetched(),
);
await context.read<ProfitSharingLoaderBloc>().stream.firstWhere(
(s) => !s.isFetching,
);
},
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child: ProfitSharingHeader(state: state),
),
),
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child: Padding(
padding: const EdgeInsets.all(16),
child: DateRangePickerField(
startDate: state.dateFrom,
endDate: state.dateTo,
onChanged: (startDate, endDate) {
if (startDate == null || endDate == null) return;
context.read<ProfitSharingLoaderBloc>().add(
ProfitSharingLoaderEvent.rangeDateChanged(
startDate,
endDate,
),
);
},
),
),
),
),
if (state.isFetching)
SliverToBoxAdapter(child: _buildShimmer())
else ...[
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child: ProfitSharingCategories(
categories: state.profitSharing.categories,
onCategoryTap: (category) => context.router.push(
ProfitSharingDetailRoute(
parentCategoryId: category.parentCategoryId,
parentCategoryName: category.parentCategoryName,
dateFrom: state.dateFrom,
dateTo: state.dateTo,
),
),
),
),
),
const SliverToBoxAdapter(child: SpaceHeight(16)),
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child: ProfitSharingAllocation(
budget: state.profitSharing.budget,
),
),
),
const SliverToBoxAdapter(child: SpaceHeight(16)),
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child: ProfitSharingPeriods(
state: state,
onPeriodTypeChanged: (type) {
context.read<ProfitSharingLoaderBloc>().add(
ProfitSharingLoaderEvent.periodTypeChanged(type),
);
},
),
),
),
],
const SliverToBoxAdapter(child: SizedBox(height: 100)),
],
),
);
},
),
),
);
}
Widget _buildShimmer() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
_shimmerBox(height: 240),
const SpaceHeight(16),
_shimmerBox(height: 300),
],
),
);
}
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),
),
),
);
}
}
@@ -0,0 +1,31 @@
import 'package:intl/intl.dart';
/// Label rentang periode ringkas, contoh: "27 Jul - 2 Agu 2026".
String formatPeriodLabel(DateTime from, DateTime to) {
final dayMonth = DateFormat('d MMM', 'id_ID');
final dayMonthYear = DateFormat('d MMM yyyy', 'id_ID');
if (from.year == to.year && from.month == to.month && from.day == to.day) {
return dayMonthYear.format(from);
}
if (from.year == to.year) {
return '${dayMonth.format(from)} - ${dayMonthYear.format(to)}';
}
return '${dayMonthYear.format(from)} - ${dayMonthYear.format(to)}';
}
/// Label bulan dari format API "2026-08" menjadi "Agustus 2026".
String formatMonthLabel(String month, DateTime fallback) {
final parts = month.split('-');
if (parts.length == 2) {
final year = int.tryParse(parts[0]);
final monthNumber = int.tryParse(parts[1]);
if (year != null && monthNumber != null) {
return DateFormat(
'MMMM yyyy',
'id_ID',
).format(DateTime(year, monthNumber));
}
}
return DateFormat('MMMM yyyy', 'id_ID').format(fallback);
}
@@ -0,0 +1,195 @@
import 'package:flutter/material.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/analytic/analytic.dart';
import '../../../components/spacer/spacer.dart';
/// Warna tetap untuk tiap pos bagi hasil supaya konsisten di semua bagian.
class ProfitSharingColor {
static const Color purchase = Color(0xFF2196F3);
static const Color owner = Color(0xFF4CAF50);
static const Color team = Color(0xFFFF9800);
}
class ProfitSharingAllocation extends StatelessWidget {
final ProfitSharingBudget budget;
const ProfitSharingAllocation({super.key, required this.budget});
@override
Widget build(BuildContext context) {
final total = budget.total;
final percentages = budget.percentages;
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: [
Text(
context.lang.profit_sharing_allocation,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.textPrimary,
),
),
const SpaceHeight(16),
ProfitSharingBar(
purchase: percentages.purchase,
owner: percentages.owner,
team: percentages.team,
),
const SpaceHeight(20),
_row(
context: context,
color: ProfitSharingColor.purchase,
label: context.lang.share_purchase,
percentage: percentages.purchase,
amount: total.limitPurchase,
),
const Divider(height: 24, color: AppColor.borderLight),
_row(
context: context,
color: ProfitSharingColor.owner,
label: context.lang.share_owner,
percentage: percentages.owner,
amount: total.limitOwner,
),
const Divider(height: 24, color: AppColor.borderLight),
_row(
context: context,
color: ProfitSharingColor.team,
label: context.lang.share_team,
percentage: percentages.team,
amount: total.limitTeam,
),
],
),
);
}
Widget _row({
required BuildContext context,
required Color color,
required String label,
required double percentage,
required int amount,
}) {
return Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SpaceWidth(10),
Text(
label,
style: AppStyle.md.copyWith(
color: AppColor.textPrimary,
fontWeight: FontWeight.w600,
),
),
const SpaceWidth(8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withOpacity(0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${percentage.toStringAsFixed(percentage % 1 == 0 ? 0 : 1)}%',
style: AppStyle.xs.copyWith(
color: color,
fontWeight: FontWeight.w700,
),
),
),
const Spacer(),
Flexible(
child: Text(
amount.currencyFormatRp,
textAlign: TextAlign.right,
style: AppStyle.md.copyWith(
color: AppColor.textPrimary,
fontWeight: FontWeight.w700,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}
/// Bar proporsi belanja / owner / tim.
class ProfitSharingBar extends StatelessWidget {
final double purchase;
final double owner;
final double team;
final double height;
const ProfitSharingBar({
super.key,
required this.purchase,
required this.owner,
required this.team,
this.height = 12,
});
@override
Widget build(BuildContext context) {
final total = purchase + owner + team;
if (total <= 0) {
return Container(
height: height,
decoration: BoxDecoration(
color: AppColor.borderLight,
borderRadius: BorderRadius.circular(height),
),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(height),
child: SizedBox(
height: height,
child: Row(
children: [
_segment(purchase, ProfitSharingColor.purchase),
_segment(owner, ProfitSharingColor.owner),
_segment(team, ProfitSharingColor.team),
].whereType<Widget>().toList(),
),
),
);
}
/// Segmen dilewati saat porsinya 0 supaya Expanded tidak dipakai dengan flex 0.
Widget? _segment(double value, Color color) {
final flex = (value * 100).round();
if (flex <= 0) return null;
return Expanded(
flex: flex,
child: Container(color: color),
);
}
}
@@ -0,0 +1,324 @@
import 'package:flutter/material.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/analytic/analytic.dart';
import '../../../components/spacer/spacer.dart';
import 'profit_sharing_status.dart';
/// Ringkasan omzet & HPP per kategori induk (parent category).
/// Tiap kartu bisa diketuk untuk melihat rincian sub kategori & produknya.
class ProfitSharingCategories extends StatelessWidget {
final List<ProfitSharingCategory> categories;
final ValueChanged<ProfitSharingCategory> onCategoryTap;
const ProfitSharingCategories({
super.key,
required this.categories,
required this.onCategoryTap,
});
@override
Widget build(BuildContext context) {
final sorted = [...categories]
..sort((a, b) => b.totalRevenue.compareTo(a.totalRevenue));
final totalRevenue = sorted.fold<int>(0, (sum, e) => sum + e.totalRevenue);
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: [
Text(
context.lang.parent_category_summary,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.textPrimary,
),
),
const SizedBox(height: 2),
Text(
context.lang.tap_row_for_detail,
style: AppStyle.xs.copyWith(color: AppColor.textSecondary),
),
const SpaceHeight(16),
if (sorted.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
context.lang.no_data_yet,
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
),
)
else ...[
...sorted.map(
(category) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _CategoryCard(
category: category,
share: totalRevenue == 0
? 0
: category.totalRevenue / totalRevenue,
onTap: () => onCategoryTap(category),
),
),
),
_TotalCard(categories: sorted),
],
],
),
);
}
}
class _CategoryCard extends StatelessWidget {
final ProfitSharingCategory category;
final double share;
final VoidCallback onTap;
const _CategoryCard({
required this.category,
required this.share,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColor.background,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.borderLight),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
category.parentCategoryName,
style: AppStyle.md.copyWith(
color: AppColor.textPrimary,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SpaceWidth(8),
ProfitSharingStatusBadge(
realHppPercentage: category.realHppPercentage,
),
const Icon(
Icons.chevron_right_rounded,
color: AppColor.textSecondary,
size: 20,
),
],
),
const SpaceHeight(8),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text(
category.totalRevenue.currencyFormatRp,
style: AppStyle.lg.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w800,
),
),
),
Text(
formatPercent(share * 100),
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w600,
),
),
],
),
const SpaceHeight(8),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: share.clamp(0.0, 1.0),
minHeight: 6,
backgroundColor: AppColor.borderLight,
valueColor: const AlwaysStoppedAnimation<Color>(
AppColor.primary,
),
),
),
const SpaceHeight(12),
Row(
children: [
Expanded(
child: ProfitSharingMetric(
label: context.lang.qty,
value: category.totalQuantity.thousandFormat,
),
),
Expanded(
child: ProfitSharingMetric(
label: context.lang.products,
value: '${category.productCount}',
),
),
Expanded(
child: ProfitSharingMetric(
label: context.lang.sub_category,
value: '${category.categoryCount}',
),
),
],
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 10),
child: Divider(height: 1, color: AppColor.border),
),
Row(
children: [
Expanded(
child: ProfitSharingMetric(
label: context.lang.std_hpp,
value: formatPercent(category.standardHppPercentage),
),
),
Expanded(
child: ProfitSharingMetric(
label: context.lang.real_hpp,
value: formatPercent(category.realHppPercentage),
valueColor: statusColor(category.realHppPercentage),
),
),
],
),
],
),
),
);
}
}
class _TotalCard extends StatelessWidget {
final List<ProfitSharingCategory> categories;
const _TotalCard({required this.categories});
@override
Widget build(BuildContext context) {
final totalQuantity = categories.fold<int>(
0,
(sum, e) => sum + e.totalQuantity,
);
final totalRevenue = categories.fold<int>(
0,
(sum, e) => sum + e.totalRevenue,
);
final totalStandardHpp = categories.fold<int>(
0,
(sum, e) => sum + e.totalStandardHpp,
);
final totalFifoHpp = categories.fold<int>(
0,
(sum, e) => sum + e.totalFifoHpp,
);
double percentOf(int hpp) =>
totalRevenue == 0 ? 0 : (hpp / totalRevenue) * 100;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.06),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
context.lang.grand_total,
style: AppStyle.md.copyWith(
color: AppColor.textPrimary,
fontWeight: FontWeight.w800,
),
),
),
Text(
totalRevenue.currencyFormatRp,
style: AppStyle.lg.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w900,
),
),
],
),
const SpaceHeight(12),
Row(
children: [
Expanded(
child: ProfitSharingMetric(
label: context.lang.qty,
value: totalQuantity.thousandFormat,
isBold: true,
),
),
Expanded(
child: ProfitSharingMetric(
label: context.lang.std_hpp,
value: formatPercent(percentOf(totalStandardHpp)),
isBold: true,
),
),
Expanded(
child: ProfitSharingMetric(
label: context.lang.real_hpp,
value: formatPercent(percentOf(totalFifoHpp)),
isBold: true,
valueColor: statusColor(percentOf(totalFifoHpp)),
),
),
],
),
],
),
);
}
}
@@ -0,0 +1,195 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import '../../../../application/analytic/profit_sharing_detail_loader/profit_sharing_detail_loader_bloc.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../components/spacer/spacer.dart';
import 'period_label.dart';
import 'profit_sharing_status.dart';
class ProfitSharingDetailHeader extends StatelessWidget {
final ProfitSharingDetailLoaderState state;
final String fallbackTitle;
const ProfitSharingDetailHeader({
super.key,
required this.state,
required this.fallbackTitle,
});
@override
Widget build(BuildContext context) {
final detail = state.detail;
final summary = detail.summary;
final title = detail.parentCategoryName.isNotEmpty
? detail.parentCategoryName
: fallbackTitle;
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: [
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(
title,
style: AppStyle.xl.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w700,
fontSize: 20,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
formatPeriodLabel(state.dateFrom, state.dateTo),
style: AppStyle.sm.copyWith(
color: AppColor.textWhite.withOpacity(0.75),
fontSize: 12,
),
),
],
),
),
],
),
const SpaceHeight(24),
if (state.isFetching)
_shimmerBox(width: 220, height: 36)
else
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text(
summary.totalRevenue.currencyFormatRp,
style: AppStyle.h1.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w900,
fontSize: 30,
),
),
),
ProfitSharingStatusBadge(
realHppPercentage: summary.realHppPercentage,
onDarkBackground: true,
),
],
),
const SpaceHeight(4),
Text(
context.lang.total_revenue,
style: AppStyle.sm.copyWith(
color: AppColor.textWhite.withOpacity(0.75),
fontSize: 13,
),
),
const SpaceHeight(16),
if (state.isFetching)
Row(
children: [
_shimmerBox(width: 120, height: 32, radius: 20),
const SpaceWidth(8),
_shimmerBox(width: 120, height: 32, radius: 20),
],
)
else
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_chip(
'${context.lang.qty} ${summary.totalQuantity.thousandFormat}',
),
_chip(context.lang.order_count_label(summary.orderCount)),
],
),
],
),
),
),
);
}
Widget _chip(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 _shimmerBox({
required double width,
required double height,
double radius = 8,
}) {
return Shimmer.fromColors(
baseColor: AppColor.textWhite.withOpacity(0.3),
highlightColor: AppColor.textWhite.withOpacity(0.6),
child: Container(
width: width,
height: height,
decoration: BoxDecoration(
color: AppColor.textWhite.withOpacity(0.3),
borderRadius: BorderRadius.circular(radius),
),
),
);
}
}
@@ -0,0 +1,163 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import '../../../../application/analytic/profit_sharing_loader/profit_sharing_loader_bloc.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../components/spacer/spacer.dart';
class ProfitSharingHeader extends StatelessWidget {
final ProfitSharingLoaderState state;
const ProfitSharingHeader({super.key, required this.state});
@override
Widget build(BuildContext context) {
final budget = state.profitSharing.budget;
final outletLabel = state.profitSharing.outletName.isNotEmpty
? state.profitSharing.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: [
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.profit_sharing,
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),
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
const SpaceHeight(24),
state.isFetching
? _shimmerBox(width: 220, height: 36)
: Text(
budget.total.revenue.currencyFormatRp,
style: AppStyle.h1.copyWith(
color: AppColor.textWhite,
fontWeight: FontWeight.w900,
fontSize: 32,
),
),
const SpaceHeight(4),
Text(
context.lang.total_revenue,
style: AppStyle.sm.copyWith(
color: AppColor.textWhite.withOpacity(0.75),
fontSize: 13,
),
),
const SpaceHeight(16),
state.isFetching
? _shimmerBox(width: 140, height: 32, radius: 20)
: _chip(
context.lang.order_count_label(budget.total.orderCount),
),
],
),
),
),
);
}
Widget _chip(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 _shimmerBox({
required double width,
required double height,
double radius = 8,
}) {
return Shimmer.fromColors(
baseColor: AppColor.textWhite.withOpacity(0.3),
highlightColor: AppColor.textWhite.withOpacity(0.6),
child: Container(
width: width,
height: height,
decoration: BoxDecoration(
color: AppColor.textWhite.withOpacity(0.3),
borderRadius: BorderRadius.circular(radius),
),
),
);
}
}
@@ -0,0 +1,270 @@
import 'package:flutter/material.dart';
import '../../../../application/analytic/profit_sharing_loader/profit_sharing_loader_bloc.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/analytic/analytic.dart';
import '../../../components/spacer/spacer.dart';
import 'period_label.dart';
import 'profit_sharing_allocation.dart';
class ProfitSharingPeriods extends StatelessWidget {
final ProfitSharingLoaderState state;
final ValueChanged<ProfitSharingPeriodType> onPeriodTypeChanged;
const ProfitSharingPeriods({
super.key,
required this.state,
required this.onPeriodTypeChanged,
});
@override
Widget build(BuildContext context) {
final periods = state.periods;
final percentages = state.profitSharing.budget.percentages;
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: [
Text(
context.lang.period_breakdown,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.textPrimary,
),
),
const SpaceHeight(16),
_tabs(context),
const SpaceHeight(16),
if (periods.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
context.lang.no_data_yet,
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
),
)
else
...periods.map(
(period) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _PeriodCard(period: period, percentages: percentages),
),
),
],
),
);
}
Widget _tabs(BuildContext context) {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColor.background,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
_tab(
context: context,
label: context.lang.weekly,
type: ProfitSharingPeriodType.weekly,
),
_tab(
context: context,
label: context.lang.monthly,
type: ProfitSharingPeriodType.monthly,
),
],
),
);
}
Widget _tab({
required BuildContext context,
required String label,
required ProfitSharingPeriodType type,
}) {
final isSelected = state.periodType == type;
return Expanded(
child: GestureDetector(
onTap: () => onPeriodTypeChanged(type),
behavior: HitTestBehavior.opaque,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected ? AppColor.primary : Colors.transparent,
borderRadius: BorderRadius.circular(10),
),
child: Text(
label,
textAlign: TextAlign.center,
style: AppStyle.sm.copyWith(
color: isSelected ? AppColor.textWhite : AppColor.textSecondary,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
),
),
),
),
);
}
}
class _PeriodCard extends StatelessWidget {
final ProfitSharingPeriod period;
final ProfitSharingPercentage percentages;
const _PeriodCard({required this.period, required this.percentages});
@override
Widget build(BuildContext context) {
final isMonthly = period.month.isNotEmpty;
final title = isMonthly
? formatMonthLabel(period.month, period.periodStart)
: formatPeriodLabel(period.periodStart, period.periodEnd);
final subtitle = isMonthly
? '${formatPeriodLabel(period.periodStart, period.periodEnd)} - ${context.lang.week_count(period.weekCount)}'
: context.lang.order_count_label(period.orderCount);
return Container(
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: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.textPrimary,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
),
),
],
),
),
const SpaceWidth(8),
Text(
period.revenue.currencyFormatRp,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w800,
color: AppColor.primary,
),
),
],
),
const SpaceHeight(12),
ProfitSharingBar(
purchase: percentages.purchase,
owner: percentages.owner,
team: percentages.team,
height: 6,
),
const SpaceHeight(12),
Row(
children: [
_share(
context.lang.share_purchase,
period.limitPurchase,
ProfitSharingColor.purchase,
),
_share(
context.lang.share_owner,
period.limitOwner,
ProfitSharingColor.owner,
),
_share(
context.lang.share_team,
period.limitTeam,
ProfitSharingColor.team,
),
],
),
],
),
);
}
Widget _share(String label, int amount, Color color) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SpaceWidth(6),
Flexible(
child: Text(
label,
style: AppStyle.xs.copyWith(color: AppColor.textSecondary),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 4),
Text(
amount.currencyFormatRp,
style: AppStyle.xs.copyWith(
color: AppColor.textPrimary,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
/// Ambang kesehatan berdasarkan % HPP riil terhadap omzet.
/// Ubah di sini kalau target margin bisnis berubah.
class HppThreshold {
static const double healthy = 70;
static const double watch = 80;
}
String formatPercent(double value) => '${value.toStringAsFixed(1)}%';
Color statusColor(double realHppPercentage) {
if (realHppPercentage <= HppThreshold.healthy) return AppColor.success;
if (realHppPercentage <= HppThreshold.watch) return AppColor.warning;
return AppColor.error;
}
String statusLabel(BuildContext context, double realHppPercentage) {
if (realHppPercentage <= HppThreshold.healthy) {
return context.lang.status_healthy;
}
if (realHppPercentage <= HppThreshold.watch) return context.lang.status_watch;
return context.lang.status_critical;
}
/// Pill status margin (Sehat / Waspada / Tidak Sehat).
class ProfitSharingStatusBadge extends StatelessWidget {
final double realHppPercentage;
final bool onDarkBackground;
const ProfitSharingStatusBadge({
super.key,
required this.realHppPercentage,
this.onDarkBackground = false,
});
@override
Widget build(BuildContext context) {
final color = statusColor(realHppPercentage);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: onDarkBackground
? AppColor.textWhite.withOpacity(0.18)
: color.withOpacity(0.12),
borderRadius: BorderRadius.circular(20),
border: onDarkBackground
? Border.all(color: AppColor.textWhite.withOpacity(0.3))
: null,
),
child: Text(
statusLabel(context, realHppPercentage),
style: AppStyle.xs.copyWith(
color: onDarkBackground ? AppColor.textWhite : color,
fontWeight: FontWeight.w700,
),
),
);
}
}
/// Label kecil dua baris: judul di atas, nilai di bawah.
class ProfitSharingMetric extends StatelessWidget {
final String label;
final String value;
final Color? valueColor;
final bool isBold;
const ProfitSharingMetric({
super.key,
required this.label,
required this.value,
this.valueColor,
this.isBold = false,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: AppStyle.xs.copyWith(color: AppColor.textSecondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
value,
style: AppStyle.sm.copyWith(
color: valueColor ?? AppColor.textPrimary,
fontWeight: isBold ? FontWeight.w800 : FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
}
@@ -0,0 +1,311 @@
import 'package:flutter/material.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/analytic/analytic.dart';
import '../../../components/spacer/spacer.dart';
import 'profit_sharing_status.dart';
/// Daftar sub kategori; tiap kartu bisa dibuka untuk melihat produknya.
class ProfitSharingSubCategories extends StatelessWidget {
final List<ProfitSharingSubCategory> categories;
final String expandedCategoryId;
final ValueChanged<String> onToggle;
const ProfitSharingSubCategories({
super.key,
required this.categories,
required this.expandedCategoryId,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final sorted = [...categories]
..sort((a, b) => b.totalRevenue.compareTo(a.totalRevenue));
final totalRevenue = sorted.fold<int>(0, (sum, e) => sum + e.totalRevenue);
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: [
Text(
context.lang.sub_category,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.w700,
color: AppColor.textPrimary,
),
),
const SpaceHeight(16),
if (sorted.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
context.lang.no_data_yet,
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
),
)
else
...sorted.map(
(category) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _SubCategoryCard(
category: category,
share: totalRevenue == 0
? 0
: category.totalRevenue / totalRevenue,
isExpanded: expandedCategoryId == category.categoryId,
onToggle: () => onToggle(category.categoryId),
),
),
),
],
),
);
}
}
class _SubCategoryCard extends StatelessWidget {
final ProfitSharingSubCategory category;
final double share;
final bool isExpanded;
final VoidCallback onToggle;
const _SubCategoryCard({
required this.category,
required this.share,
required this.isExpanded,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColor.background,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.borderLight),
),
child: Column(
children: [
InkWell(
onTap: onToggle,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
category.categoryName,
style: AppStyle.md.copyWith(
color: AppColor.textPrimary,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SpaceWidth(8),
Text(
category.totalRevenue.currencyFormatRp,
style: AppStyle.md.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w800,
),
),
AnimatedRotation(
turns: isExpanded ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
child: const Icon(
Icons.keyboard_arrow_down_rounded,
color: AppColor.textSecondary,
size: 22,
),
),
],
),
const SpaceHeight(8),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: share.clamp(0.0, 1.0),
minHeight: 5,
backgroundColor: AppColor.borderLight,
valueColor: const AlwaysStoppedAnimation<Color>(
AppColor.primary,
),
),
),
const SpaceHeight(12),
Row(
children: [
Expanded(
child: ProfitSharingMetric(
label: context.lang.qty,
value: category.totalQuantity.thousandFormat,
),
),
Expanded(
child: ProfitSharingMetric(
label: context.lang.products,
value: '${category.productCount}',
),
),
Expanded(
child: ProfitSharingMetric(
label: context.lang.real_hpp,
value: formatPercent(category.realHppPercentage),
valueColor: statusColor(category.realHppPercentage),
),
),
],
),
],
),
),
),
AnimatedCrossFade(
duration: const Duration(milliseconds: 200),
crossFadeState: isExpanded
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: _ProductList(products: category.products),
secondChild: const SizedBox(width: double.infinity),
),
],
),
);
}
}
class _ProductList extends StatelessWidget {
final List<ProfitSharingProduct> products;
const _ProductList({required this.products});
@override
Widget build(BuildContext context) {
final sorted = [...products]
..sort((a, b) => b.revenue.compareTo(a.revenue));
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
child: Column(
children: [
const Divider(height: 1, color: AppColor.border),
const SpaceHeight(10),
...sorted.map((product) => _ProductRow(product: product)),
],
),
);
}
}
class _ProductRow extends StatelessWidget {
final ProfitSharingProduct product;
const _ProductRow({required this.product});
@override
Widget build(BuildContext context) {
final color = statusColor(product.realHppPercentage);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.08),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${product.quantitySold}x',
style: AppStyle.xs.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w700,
),
),
),
const SpaceWidth(10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.productName,
style: AppStyle.sm.copyWith(
color: AppColor.textPrimary,
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
'${product.productSku} · @${product.averagePrice.round().currencyFormatRp}',
style: AppStyle.xs.copyWith(color: AppColor.textSecondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SpaceWidth(8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
product.revenue.currencyFormatRp,
style: AppStyle.sm.copyWith(
color: AppColor.textPrimary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
'${context.lang.real_hpp} ${formatPercent(product.realHppPercentage)}',
style: AppStyle.xs.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
);
}
}