import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../core/formatters.dart'; import '../core/theme.dart'; import '../models/availability.dart'; import '../state/availability_controller.dart'; import '../widgets/common.dart'; import '../widgets/week_strip.dart'; /// What the worker tells Auto-Match about each day of the coming week: /// available, actively wanting work, or marked off. class AvailabilityScreen extends ConsumerStatefulWidget { const AvailabilityScreen({super.key}); @override ConsumerState createState() => _AvailabilityScreenState(); } class _AvailabilityScreenState extends ConsumerState { DateTime _selected = DateTime.now(); List get _week { final now = DateTime.now(); return [for (var i = 0; i < 7; i++) now.add(Duration(days: i))]; } @override Widget build(BuildContext context) { final controller = ref.watch(availabilityProvider.notifier); ref.watch(availabilityProvider); final plan = controller.planFor(_selected); final marked = { for (var i = 0; i < _week.length; i++) if (!controller.isDayOff(_week[i])) i, }; return Scaffold( body: ListView( padding: EdgeInsets.zero, children: [ BrandHeader( padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ HeaderIconButton( icon: LucideIcons.chevronLeft, onPressed: () => Navigator.of(context).pop(), ), const SizedBox(width: 12), const Expanded( child: Text( 'Availability', style: TextStyle( fontSize: 25, fontWeight: FontWeight.w700, letterSpacing: -0.9, color: Colors.white, ), ), ), ], ), const SizedBox(height: 18), Wrap( spacing: 8, runSpacing: 8, children: [ _Preset( label: 'All week', onTap: () => controller.applyPreset(_week, test: (_) => true), ), _Preset( label: 'Weekdays', onTap: () => controller.applyPreset( _week, test: (d) => d.weekday <= DateTime.friday, ), ), _Preset( label: 'Weekends', onTap: () => controller.applyPreset( _week, test: (d) => d.weekday >= DateTime.saturday, ), ), _Preset( label: 'Clear', accent: true, onTap: () => controller.clearWeek(_week), ), ], ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 18, 20, 28), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ WeekStrip( days: _week, selected: _selected, onSelect: (d) => setState(() => _selected = d), marked: marked, ), const SizedBox(height: 16), KrowCard( padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( Fmt.dayShort(_selected), style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w700, letterSpacing: -0.5, ), ), const SizedBox(height: 2), Text( plan.status.summary, style: TextStyle( fontSize: 12.5, fontWeight: FontWeight.w500, color: plan.isSet ? KrowPalette.blue : KrowPalette.mute, ), ), const SizedBox(height: 14), for (final status in DayAvailability.values) ...[ _StatusOption( status: status, selected: plan.status == status, onTap: () => controller.setStatus(_selected, status), ), // The window belongs to the option it qualifies, so it // opens directly beneath it rather than at the foot of // the card where it would read as a separate question. if (status == DayAvailability.prefers) _Expand( open: plan.status == DayAvailability.prefers, child: _WindowPicker( window: plan.window, onChanged: (w) => controller.setWindow(_selected, w), ), ), const SizedBox(height: 10), ], ], ), ), const SizedBox(height: 14), InfoBanner(switch (plan.status) { DayAvailability.unavailable => 'Auto-Match will not send you anything on this day.', DayAvailability.available => 'Saved. Auto-Match can offer you any shift on this day.', DayAvailability.prefers => plan.window.isAnyTime ? 'Saved. Auto-Match will offer you this day first.' : 'Saved. Auto-Match will look for shifts ' '${plan.window.sentence} first.', }, icon: plan.isSet ? LucideIcons.check : null), ], ), ), ], ), ); } } class _Preset extends StatelessWidget { const _Preset({ required this.label, required this.onTap, this.accent = false, }); final String label; final VoidCallback onTap; final bool accent; @override Widget build(BuildContext context) { // Built by hand rather than as a Chip: the app's ChipTheme paints a white // fill, which would hide white-on-blue header text. return Material( color: Colors.white.withValues(alpha: accent ? 0.06 : 0.16), borderRadius: BorderRadius.circular(9), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(9), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), child: Text( label, style: TextStyle( fontSize: 12.5, fontWeight: FontWeight.w600, color: accent ? KrowPalette.yellow : Colors.white, ), ), ), ), ); } } /// One of the three answers, as a selectable card. class _StatusOption extends StatelessWidget { const _StatusOption({ required this.status, required this.selected, required this.onTap, }); final DayAvailability status; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { const duration = Duration(milliseconds: 220); const curve = Curves.easeOutCubic; return Semantics( inMutuallyExclusiveGroup: true, selected: selected, button: true, child: GestureDetector( onTap: onTap, child: AnimatedContainer( duration: duration, curve: curve, padding: const EdgeInsets.fromLTRB(14, 13, 14, 13), decoration: BoxDecoration( color: selected ? KrowPalette.blueTint : Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all( color: selected ? KrowPalette.blue : KrowPalette.line, width: selected ? 1.5 : 1, ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _Radio(selected: selected), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ AnimatedDefaultTextStyle( duration: duration, curve: curve, style: TextStyle( fontSize: 14.5, fontWeight: FontWeight.w600, color: selected ? KrowPalette.blue : KrowPalette.ink, ), child: Text(status.label), ), const SizedBox(height: 2), Text( status.blurb, style: const TextStyle( fontSize: 11.5, height: 1.35, fontWeight: FontWeight.w500, color: KrowPalette.mute, ), ), ], ), ), ], ), ), ), ); } } /// The filled dot. Drawn rather than using Material's Radio, which carries its /// own 48px tap target and would push the rows apart. class _Radio extends StatelessWidget { const _Radio({required this.selected}); final bool selected; @override Widget build(BuildContext context) { const duration = Duration(milliseconds: 220); return AnimatedContainer( duration: duration, curve: Curves.easeOutCubic, width: 20, height: 20, // Nudged down to sit on the title's optical centre rather than its box. margin: const EdgeInsets.only(top: 1), decoration: BoxDecoration( color: selected ? KrowPalette.blue : Colors.white, shape: BoxShape.circle, border: Border.all( color: selected ? KrowPalette.blue : KrowPalette.edge, width: 1.5, ), ), child: AnimatedScale( scale: selected ? 1 : 0, duration: duration, curve: Curves.easeOutBack, child: const Icon(LucideIcons.check, size: 12, color: Colors.white), ), ); } } /// Reveals its child by height, so the card grows into the extra question /// instead of the rows below it jumping. class _Expand extends StatelessWidget { const _Expand({required this.open, required this.child}); final bool open; final Widget child; @override Widget build(BuildContext context) { return AnimatedSize( duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, alignment: Alignment.topCenter, child: open ? Padding(padding: const EdgeInsets.only(top: 10), child: child) : const SizedBox(width: double.infinity), ); } } /// The optional hours on a "prefers to work" day. class _WindowPicker extends StatelessWidget { const _WindowPicker({required this.window, required this.onChanged}); final PreferredWindow window; final ValueChanged onChanged; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), decoration: BoxDecoration( color: KrowPalette.sage, borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SectionLabel('Preferred window (optional)'), Row( children: [ Expanded( child: _HourField( label: 'From', value: window.from, // A start later than the end is not a range, so the stale // end is dropped rather than kept as something impossible. onChanged: (h) => onChanged( PreferredWindow( from: h, to: (h != null && window.to != null && window.to! <= h) ? null : window.to, ), ), ), ), const SizedBox(width: 12), Expanded( child: _HourField( label: 'To', value: window.to, // Only hours after the start are offered, so the pair cannot // be put into an order that means nothing. min: window.from == null ? null : window.from! + 1, onChanged: (h) => onChanged(PreferredWindow(from: window.from, to: h)), ), ), ], ), ], ), ); } } class _HourField extends StatelessWidget { const _HourField({ required this.label, required this.value, required this.onChanged, this.min, }); final String label; final int? value; final ValueChanged onChanged; /// Earliest hour offered, used to keep "To" after "From". final int? min; @override Widget build(BuildContext context) { final first = min ?? 0; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: const TextStyle( fontSize: 11.5, fontWeight: FontWeight.w600, color: KrowPalette.mute, ), ), const SizedBox(height: 6), Container( height: 44, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10), border: Border.all(color: KrowPalette.line), ), child: DropdownButtonHideUnderline( child: DropdownButton( value: value, isExpanded: true, isDense: true, icon: const Icon( LucideIcons.chevronDown, size: 16, color: KrowPalette.mute, ), style: const TextStyle( fontSize: 13.5, fontWeight: FontWeight.w500, color: KrowPalette.ink, ), borderRadius: BorderRadius.circular(12), onChanged: onChanged, items: [ const DropdownMenuItem( value: null, child: Text('Any time'), ), for (var h = first; h < 24; h++) DropdownMenuItem( value: h, child: Text('${h.toString().padLeft(2, '0')}:00'), ), ], ), ), ), ], ); } }