first commit
This commit is contained in:
927
lib/views/Dashboard/summary/summary.dart
Normal file
927
lib/views/Dashboard/summary/summary.dart
Normal file
@@ -0,0 +1,927 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user