fixed designs and flow

This commit is contained in:
R-Bharathraj
2026-10-05 22:14:58 +05:30
parent d76d409416
commit aef0ac1793
21 changed files with 1594 additions and 423 deletions

View File

@@ -9,7 +9,8 @@ import '../state/availability_controller.dart';
import '../widgets/common.dart';
import '../widgets/week_strip.dart';
/// Auto-Match only offers shifts inside the blocks switched on here.
/// 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});
@@ -30,11 +31,10 @@ class _AvailabilityScreenState extends ConsumerState<AvailabilityScreen> {
final controller = ref.watch(availabilityProvider.notifier);
ref.watch(availabilityProvider);
final blocks = controller.blocksFor(_selected);
final hours = blocks.fold<int>(0, (sum, b) => sum + b.hours);
final plan = controller.planFor(_selected);
final marked = <int>{
for (var i = 0; i < _week.length; i++)
if (controller.blocksFor(_week[i]).isNotEmpty) i,
if (!controller.isDayOff(_week[i])) i,
};
return Scaffold(
@@ -49,7 +49,7 @@ class _AvailabilityScreenState extends ConsumerState<AvailabilityScreen> {
Row(
children: [
HeaderIconButton(
icon: LucideIcons.arrowLeft,
icon: LucideIcons.chevronLeft,
onPressed: () => Navigator.of(context).pop(),
),
const SizedBox(width: 12),
@@ -115,67 +115,63 @@ class _AvailabilityScreenState extends ConsumerState<AvailabilityScreen> {
KrowCard(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
Fmt.dayShort(_selected),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
const SizedBox(height: 2),
Text(
blocks.isEmpty
? 'Marked as a day off'
: 'Available · $hours hrs',
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w500,
color: blocks.isEmpty
? KrowPalette.mute
: KrowPalette.blue,
),
),
],
),
),
Switch(
value: blocks.isNotEmpty,
onChanged: (v) =>
controller.setDayAvailable(_selected, v),
),
],
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 block in DayBlock.values)
Padding(
padding: const EdgeInsets.only(bottom: 9),
child: _BlockRow(
block: block,
on: blocks.contains(block),
onTap: () =>
controller.toggleBlock(_selected, block),
),
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(
blocks.isEmpty
? 'With every block off, Auto-Match will not send you '
'anything on this day.'
: 'Saved. Auto-Match will only offer you shifts inside '
'these blocks.',
icon: blocks.isEmpty ? null : LucideIcons.check,
),
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),
],
),
),
@@ -222,116 +218,266 @@ class _Preset extends StatelessWidget {
}
}
class _BlockRow extends StatelessWidget {
const _BlockRow({required this.block, required this.on, required this.onTap});
/// 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 DayBlock block;
final bool on;
final DayAvailability status;
final bool selected;
final VoidCallback onTap;
IconData get _icon => switch (block) {
DayBlock.morning => LucideIcons.sunrise,
DayBlock.afternoon => LucideIcons.sun,
DayBlock.evening => LucideIcons.moon,
};
@override
Widget build(BuildContext context) {
const duration = Duration(milliseconds: 260);
const duration = Duration(milliseconds: 220);
const curve = Curves.easeOutCubic;
return GestureDetector(
onTap: onTap,
// Every part of the row animates together, so switching a block on is a
// single motion rather than four things changing at once.
child: AnimatedContainer(
duration: duration,
curve: curve,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: on ? KrowPalette.blueTint : const Color(0xFFE5F0E8),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: on
? KrowPalette.blue.withValues(alpha: 0.3)
: Colors.transparent,
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(
children: [
AnimatedContainer(
duration: duration,
curve: curve,
width: 38,
height: 38,
decoration: BoxDecoration(
color: on ? KrowPalette.blue : Colors.white,
borderRadius: BorderRadius.circular(11),
border: Border.all(
color: on ? KrowPalette.blue : KrowPalette.line,
),
),
child: Icon(
_icon,
size: 18,
color: on ? Colors.white : const Color(0xFF949FA5),
),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedDefaultTextStyle(
duration: duration,
curve: curve,
style: TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: on ? KrowPalette.ink : KrowPalette.mute,
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),
),
child: Text(block.label),
),
AnimatedDefaultTextStyle(
duration: duration,
curve: curve,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: on ? KrowPalette.blue : const Color(0xFF949FA5),
const SizedBox(height: 2),
Text(
status.blurb,
style: const TextStyle(
fontSize: 11.5,
height: 1.35,
fontWeight: FontWeight.w500,
color: KrowPalette.mute,
),
),
child: Text(block.window),
),
],
),
),
AnimatedContainer(
duration: duration,
curve: curve,
width: 24,
height: 24,
decoration: BoxDecoration(
color: on ? KrowPalette.blue : Colors.transparent,
borderRadius: BorderRadius.circular(7),
border: Border.all(
color: on ? KrowPalette.blue : const Color(0xFFC4D4C9),
width: 1.5,
],
),
),
child: AnimatedScale(
scale: on ? 1 : 0,
duration: duration,
curve: Curves.easeOutBack,
child: const Icon(
LucideIcons.check,
size: 14,
color: Colors.white,
),
),
),
],
],
),
),
),
);
}
}
/// 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'),
),
],
),
),
),
],
);
}
}