Files
2026-08-11 13:16:33 +05:30

928 lines
32 KiB
Dart

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:lottie/lottie.dart' hide Marker;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/Models/summary/riderweeklykms.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
import 'package:miler/controllers/summary_controller.dart';
import 'package:miler/controllers/rewards_controller.dart';
import 'package:miler/controllers/riderkm.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/miler_type.dart';
class Summary extends StatefulWidget {
const Summary({super.key});
@override
State<Summary> createState() => _SummaryState();
}
class _SummaryState extends State<Summary> {
late final SummaryController controller;
late final RewardsController _rewards;
int _userId = 0;
int _refreshTick = 0;
int _selectedPeriod = 0; // 0=Today, 1=Week, 2=Month
final List<String> _periods = ['Today', 'This Week', 'This Month'];
@override
void initState() {
super.initState();
controller = Get.isRegistered<SummaryController>()
? Get.find<SummaryController>()
: Get.put(SummaryController());
_rewards = Get.isRegistered<RewardsController>()
? Get.find<RewardsController>()
: Get.put(RewardsController());
WidgetsBinding.instance.addPostFrameCallback((_) {
_refreshData();
});
}
@override
void dispose() {
super.dispose();
}
Future<void> _refreshData() async {
try {
final prefs = await SharedPreferences.getInstance();
final uid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0;
if (mounted) setState(() => _userId = uid);
if (uid > 0) {
await controller.fetchSummaryStats(uid);
_rewards.fetchBonusSummary(uid);
}
} catch (e) {
debugPrint('Error fetching summary: $e');
} finally {
if (mounted) {
setState(() => _refreshTick++);
}
}
}
int get _periodCount {
switch (_selectedPeriod) {
case 1:
return controller.week.value;
case 2:
return controller.month.value;
default:
return controller.today.value;
}
}
int get _successRate {
final total = controller.total.value;
final cancelled = controller.cancelled.value;
final attempts = total + cancelled;
if (attempts <= 0) return 0;
return ((total / attempts) * 100).round();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorConstants.surface,
// Earnings is no longer a tab — it opens from Account — so it needs a way
// back that the four tabs never did.
appBar: const MilerAppBar(title: 'Earnings', showBack: true),
body: RefreshIndicator(
onRefresh: _refreshData,
color: ColorConstants.primary,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(),
),
slivers: [
SliverToBoxAdapter(
child: Column(
children: [
SizedBox(height: 16.h),
_buildPeriodTabs(),
SizedBox(height: 16.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: _buildPickupsHero(),
),
SizedBox(height: 16.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: _buildStatsGrid(),
),
SizedBox(height: 16.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: TotalDistanceCard(
userId: _userId,
refreshTick: _refreshTick,
),
),
SizedBox(height: 32.h),
],
),
),
],
),
),
);
}
Widget _buildPeriodTabs() {
return Container(
margin: EdgeInsets.symmetric(horizontal: 16.w),
height: 44.h,
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.outlineVariant),
),
child: Row(
children: List.generate(_periods.length, (index) {
final isSelected = _selectedPeriod == index;
return Expanded(
child: GestureDetector(
onTap: () {
if (mounted) {
setState(() {
_selectedPeriod = index;
});
}
},
child: Container(
margin: EdgeInsets.all(4.r),
decoration: BoxDecoration(
color: isSelected
? ColorConstants.primary
: Colors.transparent,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
alignment: Alignment.center,
child: Text(
_periods[index],
style: TextStyle(
fontSize: 13.sp,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected
? Colors.white
: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
),
),
);
}),
),
);
}
Widget _buildPickupsHero() {
final periodCount = _periodCount;
final total = controller.total.value;
final cancelled = controller.cancelled.value;
final points = _rewards.totalPoints.value;
return Container(
width: double.infinity,
// ── Flat, like the rest of the app ──
//
// A two-stop gradient plus a 20px coloured shadow, on the one tab whose
// neighbours are all flat fills and hairlines. Earnings was visibly from
// a different product. The brand colour still owns this block — it is the
// one celebratory surface in the app and keeps that — but it is a fill,
// not an effect. The two faint circles behind it survive: they are what
// stops a solid maroon slab reading as a error state.
decoration: BoxDecoration(
color: ColorConstants.primary,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: Stack(
children: [
Positioned(
top: -34,
right: -24,
child: Container(
width: 130.w,
height: 130.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: ColorConstants.pureSurface.withValues(alpha: 0.07),
),
),
),
Positioned(
bottom: -46,
left: -28,
child: Container(
width: 150.w,
height: 150.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: ColorConstants.pureSurface.withValues(alpha: 0.05),
),
),
),
Padding(
padding: EdgeInsets.all(20.r),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_periods[_selectedPeriod].toUpperCase()} · PICKUPS/DELIVERIES',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 11.sp,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 14.h),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 320),
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: ScaleTransition(
scale: Tween<double>(
begin: 0.82,
end: 1.0,
).animate(anim),
child: child,
),
),
// 46 → 28, the scale's hero size. A 46sp figure sharing
// a row with a label is the app's highest overflow risk
// at 2x text, and it was 18pt larger than the largest
// type anywhere else in the product.
child: Text(
'$periodCount',
key: ValueKey<int>(periodCount),
style: MilerType.hero.on(ColorConstants.onAccent),
),
),
SizedBox(width: 8.w),
Padding(
padding: EdgeInsets.only(bottom: 6.h),
child: Text(
periodCount == 1 ? 'order' : 'orders',
style: MilerType.label.on(
ColorConstants.onAccent.withValues(alpha: 0.75),
),
),
),
],
),
SizedBox(height: 4.h),
Text(
'$total completed all-time',
style: MilerType.label.on(
ColorConstants.onAccent.withValues(alpha: 0.75),
),
),
SizedBox(height: 20.h),
Row(
children: [
Expanded(
child: _buildHeroMetric(
'$_successRate%',
'Success rate',
icon: Icons.percent_rounded,
),
),
SizedBox(width: 12.w),
Expanded(
child: _buildHeroMetric(
'$cancelled',
'Cancelled',
icon: Icons.do_not_disturb_on_rounded,
),
),
SizedBox(width: 12.w),
Expanded(
child: _buildHeroMetric(
'$points',
'Reward pts',
icon: Icons.stars_rounded,
),
),
],
),
],
),
),
],
),
),
);
}
/// One chip on the hero.
///
/// The three used to be bare numbers over bare words — 92%, 4, 1,240 — which
/// on a saturated red panel is three identical grey-white blocks the rider
/// has to *read* to tell apart. A glyph makes each one findable at a glance,
/// which is the whole job of a hero: the number you came for, without
/// reading. Same rounded family as the cards below, at chip weight.
Widget _buildHeroMetric(String value, String label, {IconData? icon}) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 10.h),
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (icon != null) ...[
Icon(
icon,
size: 14.sp,
color: Colors.white.withValues(alpha: 0.85),
),
SizedBox(height: 6.h),
],
Text(
value,
style: TextStyle(
color: Colors.white,
fontSize: 15.sp,
fontWeight: FontWeight.w800,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 2.h),
Text(
label,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.65),
fontSize: 10.sp,
fontWeight: FontWeight.w500,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
/// ── Lifetime stops ──
///
/// Three stat cards, in the same language Home uses for a trip's duration,
/// distance and cash: a tinted icon disc, the figure, a small-caps caption.
/// Two screens showing the same *kind* of thing — a small set of headline
/// numbers — should not invent two ways to draw it, and this is the one the
/// rider sees first every morning.
///
/// ── What this replaced, and why twice ──
///
/// First it was two floating shadowed cards with a gap. Then one bordered
/// container split by a hairline, which fixed the wrong thing: it presented
/// the numbers as two unrelated metrics sharing a row when they are two parts
/// of one quantity. Then a proportional share bar, which showed the split
/// honestly but spent a lot of height on a figure — the success rate — that is
/// a single number and reads perfectly well as one.
///
/// So the rate is now simply the third card. It is the figure a rider is
/// judged on and the one he is proud of, and giving it equal billing with the
/// counts says that more plainly than a bar underneath them did.
Widget _buildStatsGrid() {
final total = controller.total.value;
final cancelled = controller.cancelled.value;
final attempts = total + cancelled;
final rate = attempts > 0 ? ((total / attempts) * 100).round() : 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(left: 4.w, bottom: 10.h),
child: Text(
'LIFETIME',
style: TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w800,
letterSpacing: 1.0,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
// IntrinsicHeight so the three cards match height whatever their
// figures. `CrossAxisAlignment.stretch` alone would ask for infinite
// height inside this scroll view — the trap pinned by
// `card_stripe_layout_test.dart`.
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: _statCard(
icon: Icons.check_circle_rounded,
value: '$total',
label: 'Completed',
accent: ColorConstants.acceptGreen,
),
),
SizedBox(width: 10.w),
Expanded(
child: _statCard(
// Filled, like the other two. This was the one *outlined*
// glyph in a row of three, so beside a solid tick it read as
// lighter and slightly unfinished — a set of icons has to
// agree on fill weight before it agrees on anything else.
icon: Icons.do_not_disturb_on_rounded,
value: '$cancelled',
label: 'Fell through',
// Grey, not red. A stop that fell through is usually a shut
// shop or an absent customer — a fact about the day's
// logistics, not the rider's fault — and this is the screen
// where he checks his earnings, not where he gets told off.
accent: ColorConstants.secondaryText,
),
),
SizedBox(width: 10.w),
Expanded(
child: _statCard(
// A percentage, not a trend. `trending_up` promises movement
// over time — an arrow climbing — and this is a ratio of two
// lifetime totals that mostly sits still. The glyph should
// say what the number is.
icon: Icons.percent_rounded,
value: attempts > 0 ? '$rate%' : '—',
label: 'Success',
accent: ColorConstants.acceptGreen,
),
),
],
),
),
],
);
}
/// One stat card. Mirrors `TripCard`'s metric card on Home, deliberately.
Widget _statCard({
required IconData icon,
required String value,
required String label,
required Color accent,
}) {
return Container(
padding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 6.w),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
boxShadow: const [
BoxShadow(
color: Color(0x0F000000),
blurRadius: 14,
offset: Offset(0, 5),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36.w,
height: 36.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: Icon(icon, size: 18.sp, color: accent),
),
SizedBox(height: 11.h),
// Fixed height so a four-digit total scales down inside its own card
// rather than making one of the three taller than the other two.
SizedBox(
height: 24.h,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
value,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.6,
height: 1.05,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
),
SizedBox(height: 4.h),
Text(
label.toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 9.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.7,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
}
/// Weekly distance chart backed by the real `getriderweeklykms` endpoint.
class TotalDistanceCard extends StatefulWidget {
final int userId;
final int refreshTick;
const TotalDistanceCard({
super.key,
required this.userId,
this.refreshTick = 0,
});
@override
State<TotalDistanceCard> createState() => _TotalDistanceCardState();
}
class _TotalDistanceCardState extends State<TotalDistanceCard> {
final RiderWeeklyKmController _kmController = RiderWeeklyKmController();
late Future<Map<String, dynamic>> _futureKms;
int _touchedIndex = -1;
@override
void initState() {
super.initState();
_futureKms = _fetchWeeklyKms();
}
@override
void didUpdateWidget(covariant TotalDistanceCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.userId != widget.userId ||
oldWidget.refreshTick != widget.refreshTick) {
// didUpdateWidget already runs as part of a rebuild, so assign directly.
// Calling setState() here re-enters the parent scroll view's viewport
// mid-update and trips the `!_doingMountOrUpdate` assertion.
_futureKms = _fetchWeeklyKms();
}
}
Future<Map<String, dynamic>> _fetchWeeklyKms() async {
if (widget.userId <= 0) {
return _fallbackWeeklyKms();
}
try {
final res = await _kmController.getRiderWeeklyKms(widget.userId);
final details = (res['details'] as List?) ?? const [];
if (details.isEmpty) return _fallbackWeeklyKms();
return res;
} catch (e) {
debugPrint('[WEEKLY_KMS] $e');
return _fallbackWeeklyKms();
}
}
/// Nothing to chart.
///
/// This used to return a week of seeded demo kilometres in debug builds. A
/// distance chart feeds a payout, so an invented week is the one thing this
/// card must not draw — and "debug only" is no protection when the person
/// looking at the debug build is the one deciding whether the feature works.
/// The empty state says "No distance recorded yet", which is true and is what
/// makes a silent endpoint visible.
Map<String, dynamic> _fallbackWeeklyKms() =>
{'details': const <RiderWeeklyKms>[], 'total_kms': 0.0};
String _shortDay(String day) => day.length <= 3 ? day : day.substring(0, 3);
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(18.r),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
boxShadow: const [
BoxShadow(
color: Color(0x0A000000),
blurRadius: 20,
offset: Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// `route_rounded` is a branching-path glyph — it reads as
// "route planning", which is the hub's job, not as distance the
// rider has already covered. A speedometer is the thing on his
// handlebars that counts exactly this, and it needs no tinted
// tile to be recognisable at 18sp.
// `speed_rounded` is a speedometer, and this card counts
// kilometres covered, not how fast he went. A route glyph is
// both accurate and the one every delivery app uses for distance.
Icon(
Icons.route_rounded,
color: ColorConstants.primary,
size: 20.sp,
),
SizedBox(width: 9.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Weekly Distance',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 2.h),
Text(
'Kilometres ridden per day',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
],
),
SizedBox(height: 20.h),
FutureBuilder<Map<String, dynamic>>(
future: _futureKms,
builder: (context, snapshot) {
// A spinner in the space a chart is about to occupy tells the
// rider nothing about what is coming and then hands over in one
// frame. A block the size of the chart does both jobs.
if (snapshot.connectionState == ConnectionState.waiting) {
return SmoothSwap(
child: MilerShimmer(
key: const ValueKey('kms-loading'),
child: SkeletonBone(height: 180.h, radius: 16),
),
);
}
final details =
(snapshot.data?['details'] as List<RiderWeeklyKms>?) ??
<RiderWeeklyKms>[];
final totalKms = (snapshot.data?['total_kms'] as double?) ?? 0.0;
if (details.isEmpty) {
return SmoothSwap(
child: KeyedSubtree(
key: const ValueKey('kms-empty'),
child: _buildEmptyChart(),
),
);
}
return SmoothSwap(
child: KeyedSubtree(
key: const ValueKey('kms-chart'),
child: _buildChart(details, totalKms),
),
);
},
),
],
),
);
}
Widget _buildEmptyChart() {
return SizedBox(
height: 190.h,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 120.w,
height: 120.w,
child: Lottie.asset(
'assets/lotties/no_distance.json',
fit: BoxFit.contain,
repeat: true,
),
),
SizedBox(height: 6.h),
Text(
'No distance recorded yet',
style: TextStyle(
fontSize: 13.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
);
}
Widget _buildChart(List<RiderWeeklyKms> details, double totalKms) {
final values = details.map((e) => e.kms).toList();
final maxVal = values.reduce((a, b) => a > b ? a : b);
final safeMax = maxVal <= 0 ? 1.0 : maxVal;
// Best day + averages for the summary row.
int bestIdx = 0;
for (int i = 1; i < values.length; i++) {
if (values[i] > values[bestIdx]) bestIdx = i;
}
final avgPerDay = details.isEmpty ? 0.0 : totalKms / details.length;
return Column(
children: [
SizedBox(
height: 160.h,
child: BarChart(
BarChartData(
maxY: safeMax * 1.3,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: safeMax / 3,
getDrawingHorizontalLine: (value) => FlLine(
color: ColorConstants.surfaceContainerLow,
strokeWidth: 1,
),
),
borderData: FlBorderData(show: false),
alignment: BarChartAlignment.spaceAround,
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'${rod.toY.toStringAsFixed(1)} km',
TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 12.sp,
fontFamily: FontConstants.fontFamily,
),
);
},
),
touchCallback: (event, response) {
if (!mounted) return;
final int newIndex =
(response != null &&
response.spot != null &&
event is! FlTapUpEvent)
? response.spot!.touchedBarGroupIndex
: -1;
if (newIndex != _touchedIndex) {
Future.microtask(() {
if (mounted) {
setState(() {
_touchedIndex = newIndex;
});
}
});
}
},
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
getTitlesWidget: (value, _) {
final idx = value.toInt();
if (idx >= 0 && idx < details.length) {
final isTouched = idx == _touchedIndex;
return Padding(
padding: EdgeInsets.only(top: 8.h),
child: Text(
_shortDay(details[idx].day),
style: TextStyle(
fontSize: 11.sp,
fontWeight: isTouched
? FontWeight.w800
: FontWeight.w500,
color: isTouched
? ColorConstants.primary
: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
);
}
return const SizedBox.shrink();
},
),
),
),
barGroups: List.generate(values.length, (i) {
final isTouched = i == _touchedIndex;
return BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: values[i],
// Flat, and the selected bar carries the brand while the
// rest stay neutral — the comparison is the point of a
// bar chart, and a gradient on every bar competes with it.
color: isTouched
? ColorConstants.primary
: ColorConstants.outlineVariant,
width: 18.w,
borderRadius: BorderRadius.vertical(
top: Radius.circular(5.r),
),
),
],
);
}),
),
),
),
SizedBox(height: 12.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildChartStat(
'Best Day',
_shortDay(details[bestIdx].day),
ColorConstants.warning,
),
_buildChartStat(
'Total',
'${totalKms.toStringAsFixed(1)} km',
ColorConstants.primary,
),
_buildChartStat(
'Avg/Day',
'${avgPerDay.toStringAsFixed(1)} km',
ColorConstants.tertiary,
),
],
),
],
);
}
Widget _buildChartStat(String label, String value, Color color) {
return Column(
children: [
Text(
value,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w800,
color: color,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 2.h),
Text(
label,
style: TextStyle(
fontSize: 10.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
],
);
}
}