import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:slide_to_submit_button/slide_to_submit_button.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/widgets/miler_app_bar.dart' show milerGlassSheet; import 'package:miler/views/helpers/widgets/page_transitions.dart'; /// ───────────────────────────────────────────────────────────────────────── /// GOING ON / OFF DUTY /// /// The confirmation behind the duty switch in the app bar. It used to be a /// white slab with a 32pt red ✕ floating above a 88pt red circle holding a /// **wifi** glyph, under the heading "Go Online?" and one grey line — "You /// will stop receiving bookings". /// /// Three things were wrong with that, and none of them was the styling: /// /// • **Wifi is the wrong idea.** Duty is not connectivity. A rider reading a /// wifi symbol on a screen that stops his work is being told the app has a /// network problem. /// • **It did not say what changes.** "You will stop receiving bookings" is /// one consequence of three. Live tracking stops, and — the one that /// actually catches people out — the app will refuse to take him off duty /// while a booking is still open. He found that out *after* sliding. /// • **It was the only sheet in the app that looked like this.** Every other /// sheet is frosted glass with a grab handle and a small round close /// button; see [milerGlassSheet]. /// /// So: the app's own sheet chrome, the duty glyph the rest of the app uses, /// a strip showing the switch moving from one state to the other, and the /// consequences named one per line instead of summarised in a sentence. /// /// The slide-to-confirm survives both directions. Going off duty mid-shift is /// worth a deliberate gesture, and a rider whose thumb is wet from the rain /// should not be able to end his day with a stray tap. /// ───────────────────────────────────────────────────────────────────────── class DutySheet extends StatelessWidget { /// True when this sheet is asking to go **on** duty. final bool goingOnline; /// Runs on a completed slide, and owns the work: the pending-booking check, /// the API call, the polling restart. Returns true when duty actually /// changed. /// /// The knob belongs to the sheet, not the caller — it springs back on every /// outcome, and the sheet closes itself only on a true. That is why this is a /// `Future` and not the package's controller: the controller type is /// not exported from `slide_to_submit_button`, so naming it here would mean /// reaching into the package's `src/`. final Future Function() onConfirm; const DutySheet({ super.key, required this.goingOnline, required this.onConfirm, }); /// Opens the sheet. The caller keeps the toggle logic; this only presents it. static Future show( BuildContext context, { required bool goingOnline, required Future Function() onConfirm, }) { return showModalBottomSheet( context: context, sheetAnimationStyle: kMilerSheetStyle, isScrollControlled: true, // The sheet paints its own frosted shape — a white sheet behind it would // show as a square corner behind the rounded one. backgroundColor: Colors.transparent, builder: (_) => DutySheet(goingOnline: goingOnline, onConfirm: onConfirm), ); } /// Green to start, brand red to stop. /// /// Green matches the duty switch's own "on" fill, so the sheet is visibly /// about the control that opened it. Stopping is not grey: grey is what a /// disabled control looks like, and this is the most consequential button on /// the screen. Color get _accent => goingOnline ? ColorConstants.success : ColorConstants.primary; @override Widget build(BuildContext context) { return SafeArea( top: false, left: false, right: false, bottom: true, child: milerGlassSheet( child: Padding( padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Container( width: 40.w, height: 4.h, decoration: BoxDecoration( color: ColorConstants.borderSubtle, borderRadius: BorderRadius.circular( DesignConstants.radiusLg, ), ), ), ), SizedBox(height: 16.h), _header(context), SizedBox(height: 18.h), // What the switch is about to do, drawn rather than described. _TransitionStrip(goingOnline: goingOnline, accent: _accent), SizedBox(height: 16.h), ..._consequences(), SizedBox(height: 20.h), _slider(context), ], ), ), ), ); } Widget _header(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // The duty glyph, not a wifi bar. Same icon the "Go on duty" button on // an empty trip carries, so one idea keeps one symbol. Container( padding: EdgeInsets.all(10.r), decoration: BoxDecoration( color: _accent.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), child: Icon( Icons.power_settings_new_rounded, color: _accent, size: 20.sp, ), ), SizedBox(width: 12.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( goingOnline ? 'Go on duty?' : 'Go off duty?', style: TextStyle( fontSize: 19.sp, fontWeight: FontWeight.w800, letterSpacing: -0.3, fontFamily: FontConstants.fontFamily, color: ColorConstants.slateText, ), ), SizedBox(height: 2.h), Text( goingOnline ? 'Your hub can start assigning you trips again.' : 'Your hub stops assigning you trips until you are back.', style: TextStyle( fontSize: 12.5.sp, height: 1.35, fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, color: ColorConstants.secondaryText, ), ), ], ), ), SizedBox(width: 10.w), // The same small round dismiss every other sheet uses, in place of the // 32pt red ✕ that used to sit alone on its own row above everything. InkWell( onTap: () => Navigator.of(context).maybePop(), borderRadius: BorderRadius.circular(DesignConstants.radiusFull), child: Container( padding: EdgeInsets.all(8.r), decoration: BoxDecoration( color: ColorConstants.neutralLight, shape: BoxShape.circle, ), child: Icon( Icons.close_rounded, color: ColorConstants.slateText, size: 18.sp, ), ), ), ], ); } /// One line per thing that actually changes. /// /// The third off-duty line is the one the old sheet withheld: the app blocks /// the switch while a booking is still open, and it used to say so only /// after the rider had completed the slide and watched it bounce back. List _consequences() { final rows = goingOnline ? const [ (Icons.alt_route_rounded, 'Your hub can add you to the next slot'), ( Icons.my_location_rounded, 'Live tracking runs while you are on duty', ), ] : const [ ( Icons.notifications_paused_rounded, 'No new trips until you are back on', ), (Icons.location_off_rounded, 'Live tracking pauses'), ( Icons.inventory_2_rounded, 'Any booking you are holding must be finished first', ), ]; return [ for (var i = 0; i < rows.length; i++) ...[ if (i > 0) SizedBox(height: 10.h), _ConsequenceRow( icon: rows[i].$1, label: rows[i].$2, accent: _accent, ), ], ]; } Widget _slider(BuildContext context) { final label = goingOnline ? 'Slide to go on duty' : 'Slide to go off duty'; return SlideToSubmit.custom( height: 58, sliderWidth: 46, padding: const EdgeInsets.all(6), // The track is the accent at low alpha, so the control is the same colour // as the decision. It was a flat grey slab that gave no clue which of the // two things it was about to do. backgroundDecoration: BoxDecoration( color: _accent.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(DesignConstants.radiusFull), ), foregroundDecoration: const BoxDecoration(color: Colors.transparent), slider: Center( child: Container( height: 46, width: 46, alignment: Alignment.center, decoration: BoxDecoration( // A filled knob in the accent, not a white disc with a grey // chevron: on a tinted track the white one read as the empty part. color: _accent, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: _accent.withValues(alpha: 0.30), blurRadius: 10, offset: const Offset(0, 3), ), ], ), child: Icon( Icons.arrow_forward_rounded, size: 20.sp, color: ColorConstants.onAccent, ), ), ), // Words, not just a drifting arrow. The old hint was an arrow asset and // nothing else, which says "something slides" without saying what // happens when it lands. hint: IgnorePointer( child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox(width: 46.0 + 8.w), Expanded( child: Text( label, textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w800, letterSpacing: -0.2, fontFamily: FontConstants.fontFamily, color: _accent, ), ), ), Padding( padding: EdgeInsets.only(right: 14.w), // Boxed and clipped. The arrow animates in from `Offset(-2, 0)` — // two of its own widths to the left — so left to itself it drifts // straight across the label and prints an arrow through the // middle of the words. Its own box turns that into what it was // meant to be: an arrow sliding in at the end of the track. child: SizedBox( width: 26.w, child: ClipRect( // The asset is a fixed dark glyph, so it is tinted rather // than dropped: the track is coloured now, and a black arrow // on a green track was the one thing on the control that did // not belong to it. child: ColorFiltered( colorFilter: ColorFilter.mode( _accent.withValues(alpha: 0.55), BlendMode.srcIn, ), child: const AnimatedSlideArrow( arrowImage: AssetImage( 'assets/arrow_right.png', package: 'slide_to_submit_button', ), ), ), ), ), ), ], ), ), // The knob springs back whatever happens, and the sheet closes only when // duty actually changed — a failed API call leaves the rider looking at // the decision he was trying to make, not at a screen that has silently // dropped it. onSubmit: (controller) async { final ok = await onConfirm(); try { controller.reset(); } catch (_) {} if (ok && context.mounted) Navigator.of(context).maybePop(); }, ); } } /// `Off duty ──→ On duty`, as the two states of the switch that opened this. /// /// A confirmation should show the change, not only name it. The state he is /// leaving stays grey and the one he is heading for takes the accent, so which /// direction this sheet runs in is legible before a word is read. class _TransitionStrip extends StatelessWidget { final bool goingOnline; final Color accent; const _TransitionStrip({required this.goingOnline, required this.accent}); @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: _StateChip( label: goingOnline ? 'Off duty' : 'On duty', color: ColorConstants.secondaryText, filled: false, ), ), Padding( padding: EdgeInsets.symmetric(horizontal: 10.w), child: Icon( Icons.arrow_forward_rounded, size: 16.sp, color: ColorConstants.borderStrong, ), ), Expanded( child: _StateChip( label: goingOnline ? 'On duty' : 'Off duty', color: accent, filled: true, ), ), ], ); } } class _StateChip extends StatelessWidget { final String label; final Color color; /// The destination state. The one being left is drawn hollow. final bool filled; const _StateChip({ required this.label, required this.color, required this.filled, }); @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.symmetric(vertical: 10.h, horizontal: 12.w), decoration: BoxDecoration( color: filled ? color.withValues(alpha: 0.12) : Colors.transparent, border: Border.all( color: filled ? Colors.transparent : ColorConstants.borderStrong, ), borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 8.w, height: 8.w, decoration: BoxDecoration(color: color, shape: BoxShape.circle), ), SizedBox(width: 8.w), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, fontWeight: FontWeight.w800, letterSpacing: -0.2, fontFamily: FontConstants.fontFamily, color: filled ? color : ColorConstants.secondaryText, ), ), ), ], ), ); } } class _ConsequenceRow extends StatelessWidget { final IconData icon; final String label; final Color accent; const _ConsequenceRow({ required this.icon, required this.label, required this.accent, }); @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsets.only(top: 1.h), child: Icon(icon, size: 17.sp, color: accent.withValues(alpha: 0.75)), ), SizedBox(width: 10.w), Expanded( child: Text( label, style: TextStyle( fontSize: 13.sp, height: 1.35, fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, color: ColorConstants.slateText, ), ), ), ], ); } }