484 lines
16 KiB
Dart
484 lines
16 KiB
Dart
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<AvailabilityScreen> createState() => _AvailabilityScreenState();
|
|
}
|
|
|
|
class _AvailabilityScreenState extends ConsumerState<AvailabilityScreen> {
|
|
DateTime _selected = DateTime.now();
|
|
|
|
List<DateTime> 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 = <int>{
|
|
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<PreferredWindow> 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<int?> 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<int?>(
|
|
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<int?>(
|
|
value: null,
|
|
child: Text('Any time'),
|
|
),
|
|
for (var h = first; h < 24; h++)
|
|
DropdownMenuItem<int?>(
|
|
value: h,
|
|
child: Text('${h.toString().padLeft(2, '0')}:00'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|