Initial commit: Krow Worker App

This commit is contained in:
R-Bharathraj
2026-09-17 22:26:11 +05:30
commit cafffd27e0
113 changed files with 13067 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'common.dart';
/// Builds header content for a given collapse progress: 0 fully open, 1 fully
/// collapsed.
typedef CollapsingHeaderBuilder =
Widget Function(BuildContext context, double t);
/// The pinned brand header used by the main tabs.
///
/// It stays on screen while the content scrolls beneath it, and shrinks from
/// [expandedHeight] to [collapsedHeight] on the way. Pinning a tall header at
/// full size would cost a quarter of a phone screen for the whole session;
/// collapsing keeps the context and gives the space back.
class KrowSliverHeader extends StatelessWidget {
const KrowSliverHeader({
super.key,
required this.expandedHeight,
required this.collapsedHeight,
required this.builder,
});
/// Heights exclude the status bar, which is added for you.
final double expandedHeight;
final double collapsedHeight;
final CollapsingHeaderBuilder builder;
@override
Widget build(BuildContext context) {
final top = MediaQuery.paddingOf(context).top;
return SliverPersistentHeader(
pinned: true,
delegate: _HeaderDelegate(
minHeight: collapsedHeight + top,
maxHeight: expandedHeight + top,
topInset: top,
builder: builder,
),
);
}
}
class _HeaderDelegate extends SliverPersistentHeaderDelegate {
_HeaderDelegate({
required this.minHeight,
required this.maxHeight,
required this.topInset,
required this.builder,
});
final double minHeight;
final double maxHeight;
final double topInset;
final CollapsingHeaderBuilder builder;
@override
double get minExtent => minHeight;
@override
double get maxExtent => maxHeight;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlaps) {
final range = maxHeight - minHeight;
final t = range <= 0 ? 1.0 : (shrinkOffset / range).clamp(0.0, 1.0);
return BrandSurface(
child: Padding(
padding: EdgeInsets.only(top: topInset),
child: builder(context, t),
),
);
}
@override
bool shouldRebuild(_HeaderDelegate old) =>
old.minHeight != minHeight ||
old.maxHeight != maxHeight ||
old.topInset != topInset ||
old.builder != builder;
}
/// Cross-fades a header between its open and collapsed states.
///
/// The open content keeps its natural size and is clipped as the header
/// shrinks — letting it compress instead would squash the type on the way
/// down, and would overflow before it finished fading.
class HeaderCrossFade extends StatelessWidget {
const HeaderCrossFade({
super.key,
required this.t,
required this.expandedHeight,
required this.expanded,
required this.collapsed,
});
final double t;
final double expandedHeight;
final Widget expanded;
final Widget collapsed;
@override
Widget build(BuildContext context) {
// The open state clears out over the first two thirds; the collapsed bar
// arrives over the last half. They overlap briefly, which reads as one
// state becoming the other rather than a swap.
final openOpacity = (1 - t * 1.5).clamp(0.0, 1.0);
final shutOpacity = ((t - 0.5) * 2).clamp(0.0, 1.0);
return Stack(
fit: StackFit.expand,
children: [
if (openOpacity > 0)
ClipRect(
child: OverflowBox(
alignment: Alignment.topCenter,
minHeight: 0,
// Unbounded, so content taller than the header lays out at its
// natural size and is clipped. Capping it here instead would
// throw a layout overflow — and one only visible in a debug
// build, so it reaches a device before it reaches you.
maxHeight: double.infinity,
child: Opacity(
opacity: openOpacity,
child: IgnorePointer(ignoring: t > 0.5, child: expanded),
),
),
),
if (shutOpacity > 0)
Opacity(
opacity: shutOpacity,
child: IgnorePointer(ignoring: t < 0.5, child: collapsed),
),
],
);
}
}

835
lib/widgets/common.dart Normal file
View File

@@ -0,0 +1,835 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/theme.dart';
/// Small uppercase section heading used above every group of cards.
class SectionLabel extends StatelessWidget {
const SectionLabel(this.text, {super.key, this.trailing});
final String text;
final Widget? trailing;
@override
Widget build(BuildContext context) {
// No horizontal inset: the label has to share a left edge with the cards
// and body copy it sits above.
return Padding(
padding: const EdgeInsets.fromLTRB(0, 22, 0, 10),
child: Row(
children: [
Expanded(
child: Text(
text.toUpperCase(),
style: const TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w600,
letterSpacing: 1.4,
color: Color(0xFF949FA5),
),
),
),
?trailing,
],
),
);
}
}
enum ChipTone { blue, yellow, neutral, muted, danger }
/// Status pill. Yellow marks anything waiting on the worker.
class StatusChip extends StatelessWidget {
const StatusChip(
this.label, {
super.key,
this.tone = ChipTone.neutral,
this.icon,
});
final String label;
final ChipTone tone;
final IconData? icon;
@override
Widget build(BuildContext context) {
final (bg, fg) = switch (tone) {
ChipTone.blue => (KrowPalette.blueTint, KrowPalette.blue),
ChipTone.yellow => (KrowPalette.yellow, KrowPalette.ink),
ChipTone.neutral => (const Color(0xFFDEEBE2), KrowPalette.ink),
ChipTone.muted => (const Color(0xFFDEEBE2), const Color(0xFF65727A)),
ChipTone.danger => (const Color(0xFFFBE3E3), const Color(0xFFA6403F)),
};
return Container(
padding: EdgeInsets.fromLTRB(icon == null ? 10 : 8, 6, 10, 6),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(9),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 13, color: fg),
const SizedBox(width: 5),
],
// Flexible so the chip gives way when the row it sits in runs out of
// width — at a large text scale it otherwise overflows its parent.
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
color: fg,
),
),
),
],
),
);
}
}
/// Soft informational panel. `warning: true` switches it to the yellow tone.
class InfoBanner extends StatelessWidget {
const InfoBanner(this.text, {super.key, this.warning = false, this.icon});
final String text;
final bool warning;
final IconData? icon;
@override
Widget build(BuildContext context) {
final bg = warning ? const Color(0xFFF7E4AE) : KrowPalette.blueTint;
final fg = warning ? KrowPalette.ink : KrowPalette.blueDeep;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon ?? (warning ? LucideIcons.triangleAlert : LucideIcons.info),
size: 16,
color: fg,
),
const SizedBox(width: 11),
Expanded(
child: Text(
text,
style: TextStyle(fontSize: 12.5, height: 1.5, color: fg),
),
),
],
),
);
}
}
/// White rounded container used for every grouped block.
class KrowCard extends StatelessWidget {
const KrowCard({
super.key,
required this.child,
this.padding = const EdgeInsets.all(16),
this.onTap,
this.dashed = false,
});
final Widget child;
final EdgeInsetsGeometry padding;
final VoidCallback? onTap;
final bool dashed;
@override
Widget build(BuildContext context) {
return Material(
color: dashed ? Colors.transparent : Colors.white,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: padding,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: KrowPalette.line),
),
child: child,
),
),
);
}
}
/// A navigation row: icon tile, title, subtitle, optional badge, chevron.
class NavTile extends StatelessWidget {
const NavTile({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.badge,
this.onTap,
});
final IconData icon;
final String title;
final String? subtitle;
final Widget? badge;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: KrowPalette.blueTint,
borderRadius: BorderRadius.circular(11),
),
child: Icon(icon, size: 18, color: KrowPalette.blue),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w600,
),
),
if (subtitle != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
subtitle!,
style: const TextStyle(
fontSize: 12,
height: 1.35,
color: KrowPalette.mute,
),
),
),
],
),
),
if (badge != null) ...[const SizedBox(width: 8), badge!],
const SizedBox(width: 8),
const Icon(
LucideIcons.chevronRight,
size: 17,
color: Color(0xFF949FA5),
),
],
),
),
);
}
}
/// Groups NavTiles into a single card with hairline dividers between rows.
class TileGroup extends StatelessWidget {
const TileGroup({super.key, required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
return KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) const Divider(height: 1),
children[i],
],
],
),
);
}
}
/// Dotted-leader row: label on the left, value on the right.
class DetailRow extends StatelessWidget {
const DetailRow(
this.label,
this.value, {
super.key,
this.emphasis = false,
this.valueColor,
});
final String label;
final String value;
final bool emphasis;
final Color? valueColor;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 11),
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 13.5,
fontWeight: emphasis ? FontWeight.w600 : FontWeight.w400,
),
),
),
const SizedBox(width: 12),
Text(
value,
style: TextStyle(
fontSize: emphasis ? 16 : 13.5,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: valueColor ?? KrowPalette.ink,
),
),
],
),
);
}
}
/// Three-up figure block: caption over a big number.
///
/// Both lines are pinned to a fixed height and scale down instead of wrapping.
/// A wrapping caption or figure — "CLOCK OUT", "11:50 PM" — would make one
/// card taller than the two beside it and leave the row ragged.
class StatSlab extends StatelessWidget {
const StatSlab(this.caption, this.value, {super.key, this.valueColor});
final String caption;
final String value;
final Color? valueColor;
static const _captionHeight = 13.0;
static const _valueHeight = 25.0;
@override
Widget build(BuildContext context) {
return KrowCard(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 13),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: _captionHeight,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
caption.toUpperCase(),
maxLines: 1,
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
letterSpacing: 1.2,
color: Color(0xFF949FA5),
),
),
),
),
const SizedBox(height: 6),
SizedBox(
height: _valueHeight,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
value,
maxLines: 1,
style: TextStyle(
fontSize: 17.5,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
color: valueColor ?? KrowPalette.ink,
),
),
),
),
],
),
);
}
}
/// Lays out slabs as an evenly spaced row of equal-height cards.
class StatSlabRow extends StatelessWidget {
const StatSlabRow({super.key, required this.slabs});
final List<StatSlab> slabs;
@override
Widget build(BuildContext context) {
// These rows live inside scrollables, where the height is unbounded, so
// `stretch` alone would try to fill infinity. IntrinsicHeight gives the
// row the height of its tallest card first, and stretch then matches the
// others to it.
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < slabs.length; i++) ...[
if (i > 0) const SizedBox(width: 9),
Expanded(child: slabs[i]),
],
],
),
);
}
}
/// Horizontal rail of small stats, divided by hairlines.
class StatRail extends StatelessWidget {
const StatRail({super.key, required this.stats});
final List<(String, String)> stats;
@override
Widget build(BuildContext context) {
return KrowCard(
padding: EdgeInsets.zero,
child: IntrinsicHeight(
child: Row(
children: [
for (var i = 0; i < stats.length; i++) ...[
if (i > 0) const VerticalDivider(width: 1),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
children: [
Text(
stats[i].$2,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
),
),
const SizedBox(height: 4),
Text(
stats[i].$1.toUpperCase(),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 8.5,
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
color: Color(0xFF949FA5),
),
),
],
),
),
),
],
],
),
),
);
}
}
/// Progress that grows into place rather than snapping, so a change — an
/// uploaded document, a day worked — is something you see happen.
class KrowProgressBar extends StatelessWidget {
const KrowProgressBar(this.value, {super.key, this.color});
final double value;
final Color? color;
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
tween: Tween(end: value.clamp(0, 1)),
duration: const Duration(milliseconds: 600),
curve: Curves.easeOutCubic,
builder: (context, animated, _) => ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: animated,
minHeight: 6,
backgroundColor: KrowPalette.line,
valueColor: AlwaysStoppedAnimation(color ?? KrowPalette.blue),
),
),
);
}
}
class EmptyState extends StatelessWidget {
const EmptyState({
super.key,
required this.icon,
required this.title,
required this.message,
this.action,
});
final IconData icon;
final String title;
final String message;
final Widget? action;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 34),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: KrowPalette.line, style: BorderStyle.solid),
),
child: Column(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: KrowPalette.line),
),
child: Icon(icon, size: 24, color: const Color(0xFF949FA5)),
),
const SizedBox(height: 14),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 5),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
height: 1.5,
color: KrowPalette.mute,
),
),
if (action != null) ...[const SizedBox(height: 16), action!],
],
),
);
}
}
/// Business identity tile used on every shift card.
class BusinessBadge extends StatelessWidget {
const BusinessBadge(this.letter, {super.key, this.size = 46});
final String letter;
final double size;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: KrowPalette.blue,
borderRadius: BorderRadius.circular(size / 3.2),
),
child: Text(
letter,
style: TextStyle(
fontSize: size * 0.42,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
);
}
}
/// Small icon + text pair used for shift metadata.
class MetaPill extends StatelessWidget {
const MetaPill(this.icon, this.text, {super.key});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0x0F26353E),
borderRadius: BorderRadius.circular(9),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: const Color(0xFF949FA5)),
const SizedBox(width: 6),
Flexible(
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
/// The blue ground every header sits on: brand colour, rounded bottom, and two
/// soft glows. The glows are quiet on their own, but they give the frosted
/// controls sitting on top something to refract.
class BrandSurface extends StatelessWidget {
const BrandSurface({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
child: Container(
width: double.infinity,
color: KrowPalette.blue,
child: Stack(
children: [
Positioned(
top: -70,
right: -40,
child: _Glow(220, Colors.white.withValues(alpha: 0.16)),
),
Positioned(
bottom: -90,
left: -50,
child: _Glow(240, KrowPalette.yellow.withValues(alpha: 0.14)),
),
child,
],
),
),
);
}
}
/// The blue header block that tops a pushed detail screen. Main tabs use
/// KrowSliverHeader instead, so their header stays put while content scrolls.
class BrandHeader extends StatelessWidget {
const BrandHeader({
super.key,
required this.child,
this.padding = const EdgeInsets.fromLTRB(20, 8, 20, 24),
});
final Widget child;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
return BrandSurface(
child: SafeArea(
bottom: false,
child: Padding(padding: padding, child: child),
),
);
}
}
/// A soft circular bloom. Drawn as a radial gradient rather than a blurred
/// shape, which costs nothing per frame.
class _Glow extends StatelessWidget {
const _Glow(this.size, this.color);
final double size;
final Color color;
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(colors: [color, color.withValues(alpha: 0)]),
),
),
);
}
}
/// Round icon button used in headers, on light or dark ground.
class HeaderIconButton extends StatelessWidget {
const HeaderIconButton({
super.key,
required this.icon,
required this.onPressed,
this.onDark = true,
this.tooltip,
});
final IconData icon;
final VoidCallback onPressed;
final bool onDark;
final String? tooltip;
@override
Widget build(BuildContext context) {
return Material(
color: onDark ? Colors.white24 : Colors.white,
borderRadius: BorderRadius.circular(11),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(11),
child: Tooltip(
message: tooltip ?? '',
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(11),
border: onDark ? null : Border.all(color: KrowPalette.line),
),
child: Icon(
icon,
size: 19,
color: onDark ? Colors.white : KrowPalette.ink,
),
),
),
),
);
}
}
/// Simple bar chart used on the earnings screens.
class MiniBarChart extends StatelessWidget {
const MiniBarChart({super.key, required this.bars});
/// `(label, value, isPaid)` — paid bars read blue, pending read yellow.
final List<(String, double, bool)> bars;
@override
Widget build(BuildContext context) {
final max = bars.fold<double>(0, (m, b) => b.$2 > m ? b.$2 : m);
return Column(
children: [
SizedBox(
height: 118,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final bar in bars)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
// Bars grow to their new height when the range changes,
// so you can see which column moved.
child: TweenAnimationBuilder<double>(
tween: Tween(
end: max == 0
? 0.0
: (bar.$2 / max).clamp(0.0, 1.0).toDouble(),
),
duration: const Duration(milliseconds: 520),
curve: Curves.easeOutCubic,
builder: (context, height, _) => FractionallySizedBox(
heightFactor: height,
child: TweenAnimationBuilder<Color?>(
tween: ColorTween(
end: bar.$3 ? KrowPalette.blue : KrowPalette.yellow,
),
duration: const Duration(milliseconds: 320),
builder: (context, color, _) => Container(
decoration: BoxDecoration(
color: color,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(6),
bottom: Radius.circular(3),
),
),
),
),
),
),
),
),
],
),
),
const SizedBox(height: 9),
Row(
children: [
for (final bar in bars)
Expanded(
child: Text(
bar.$1,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.w500,
color: Color(0xFF949FA5),
),
),
),
],
),
],
);
}
}
/// Legend dot + label, paired with the chart.
class LegendDot extends StatelessWidget {
const LegendDot(this.color, this.label, {super.key});
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(fontSize: 12, color: KrowPalette.mute),
),
],
);
}
}

View File

@@ -0,0 +1,191 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../core/theme.dart';
/// A frosted-glass segmented control for use on the brand header.
///
/// The track is translucent and blurs whatever sits behind it, with a bright
/// top edge and a darker bottom edge so it reads as a pane of glass rather
/// than a flat tint. The selected segment is a brighter frosted pill that
/// slides between positions.
class GlassSegments<T> extends StatelessWidget {
const GlassSegments({
super.key,
required this.segments,
required this.value,
required this.onChanged,
this.height = 44,
});
/// Each segment as `(value, label)`.
final List<(T, String)> segments;
final T value;
final ValueChanged<T> onChanged;
final double height;
static const _radius = 15.0;
static const _pad = 4.0;
@override
Widget build(BuildContext context) {
final index = segments.indexWhere((s) => s.$1 == value);
return SizedBox(
height: height,
child: ClipRRect(
borderRadius: BorderRadius.circular(_radius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_radius),
// Light falling across the pane: brighter at the top edge.
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.white.withValues(alpha: 0.26),
Colors.white.withValues(alpha: 0.12),
],
),
border: Border.all(
color: Colors.white.withValues(alpha: 0.28),
width: 1,
),
),
padding: const EdgeInsets.all(_pad),
child: LayoutBuilder(
builder: (context, constraints) {
final segmentWidth = constraints.maxWidth / segments.length;
// One animation drives everything: the pill's position, its
// stretch, and each label's colour. Animating the selected
// index as a double — rather than moving the pill and flipping
// the labels separately — is what makes the labels light up
// exactly as the pill arrives over them. Retargeting mid-flight
// continues from wherever the pill currently is.
return TweenAnimationBuilder<double>(
tween: Tween(end: index.toDouble()),
duration: const Duration(milliseconds: 340),
curve: Curves.easeOutQuint,
builder: (context, position, _) {
// Squash and stretch: the pill widens at speed and settles
// back to size as it lands.
final travel = (position - position.roundToDouble()).abs();
final stretch = segmentWidth * 0.12 * (travel * 2);
return Stack(
children: [
if (index >= 0)
Positioned(
left: position * segmentWidth - stretch / 2,
width: segmentWidth + stretch,
top: 0,
bottom: 0,
child: const _SelectedPill(),
),
Row(
children: [
for (var i = 0; i < segments.length; i++)
Expanded(
child: _Segment(
label: segments[i].$2,
// 1 when the pill is centred on this label,
// 0 once it has moved a full segment away.
selectedAmount: (1 - (position - i).abs())
.clamp(0.0, 1.0),
onTap: () {
if (segments[i].$1 == value) return;
HapticFeedback.selectionClick();
onChanged(segments[i].$1);
},
),
),
],
),
],
);
},
);
},
),
),
),
),
);
}
}
class _SelectedPill extends StatelessWidget {
const _SelectedPill();
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(11),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.white.withValues(alpha: 0.96),
Colors.white.withValues(alpha: 0.86),
],
),
border: Border.all(color: Colors.white.withValues(alpha: 0.9)),
boxShadow: [
BoxShadow(
color: KrowPalette.blueDeep.withValues(alpha: 0.28),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
);
}
}
class _Segment extends StatelessWidget {
const _Segment({
required this.label,
required this.selectedAmount,
required this.onTap,
});
final String label;
/// How far the travelling pill has arrived over this label, 0 to 1.
final double selectedAmount;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Center(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5,
// Weight is snapped to the nearest real weight; the eye reads the
// colour ramp as the smooth part.
fontWeight: selectedAmount > 0.5
? FontWeight.w700
: FontWeight.w600,
color: Color.lerp(
Colors.white.withValues(alpha: 0.82),
KrowPalette.blue,
Curves.easeIn.transform(selectedAmount),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../core/theme.dart';
class KrowNavItem {
const KrowNavItem({
required this.icon,
required this.label,
this.showDot = false,
});
final IconData icon;
final String label;
/// Marks the tab as needing attention, without saying how much.
final bool showDot;
}
/// Bottom navigation whose indicator slides between tabs.
///
/// Material's own [NavigationBar] fades its indicator in at the destination
/// and out at the origin, so the selection appears to teleport. Here the pill
/// travels, and the icons hand it over as it passes.
class KrowNavBar extends StatelessWidget {
const KrowNavBar({
super.key,
required this.items,
required this.currentIndex,
required this.onSelect,
});
final List<KrowNavItem> items;
final int currentIndex;
final ValueChanged<int> onSelect;
static const _pillWidth = 58.0;
static const _pillHeight = 32.0;
/// Breathing room between the bar's top edge and the icons. The pill and the
/// icons both start here, which is also what keeps them aligned — centring
/// the icons while pinning the pill to a fixed offset left the two a couple
/// of pixels apart.
static const _topPadding = 12.0;
/// Lets tests find the travelling indicator unambiguously.
static const indicatorKey = ValueKey('krow-nav-indicator');
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: KrowPalette.line)),
),
child: SafeArea(
top: false,
child: SizedBox(
height: 76,
child: LayoutBuilder(
builder: (context, constraints) {
final slot = constraints.maxWidth / items.length;
// The selected index animated as a double, so the pill's
// position and every icon's colour come from one value.
return TweenAnimationBuilder<double>(
tween: Tween(end: currentIndex.toDouble()),
duration: const Duration(milliseconds: 360),
curve: Curves.easeOutQuint,
builder: (context, position, _) {
final travel = (position - position.roundToDouble()).abs();
final stretch = 20 * (travel * 2);
return Stack(
children: [
Positioned(
left:
position * slot +
(slot - _pillWidth) / 2 -
stretch / 2,
top: _topPadding,
width: _pillWidth + stretch,
height: _pillHeight,
child: DecoratedBox(
key: indicatorKey,
decoration: BoxDecoration(
color: KrowPalette.blue,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: KrowPalette.blue.withValues(alpha: 0.28),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
),
),
Row(
children: [
for (var i = 0; i < items.length; i++)
Expanded(
child: _NavItem(
item: items[i],
// 1 when the pill is centred on this tab.
proximity: (1 - (position - i).abs()).clamp(
0.0,
1.0,
),
onTap: () {
if (i != currentIndex) {
HapticFeedback.selectionClick();
}
onSelect(i);
},
),
),
],
),
],
);
},
);
},
),
),
),
);
}
}
class _NavItem extends StatelessWidget {
const _NavItem({
required this.item,
required this.proximity,
required this.onTap,
});
final KrowNavItem item;
final double proximity;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
// The icon only turns white once the pill is actually under it — a linear
// ramp would leave it half-white over bare white while the pill is still
// in transit. easeInCubic holds it grey, then flips on arrival.
final onPill = Curves.easeInCubic.transform(proximity);
final iconColor = Color.lerp(KrowPalette.mute, Colors.white, onPill)!;
// The label is never covered, so it can ramp gently the whole way.
final labelColor = Color.lerp(
KrowPalette.mute,
KrowPalette.blue,
Curves.easeIn.transform(proximity),
)!;
// Material's NavigationBar announces each tab and its selected state;
// rolling our own means saying so explicitly. container + excludeSemantics
// collapse the icon, dot and label into a single node — otherwise the
// annotation is split across the children and never reaches the reader.
return Semantics(
container: true,
excludeSemantics: true,
button: true,
selected: proximity > 0.5,
label: item.showDot ? '${item.label}, needs attention' : item.label,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
// Started from the top rather than centred, so the icon sits exactly
// where the pill is drawn.
mainAxisAlignment: MainAxisAlignment.start,
children: [
const SizedBox(height: KrowNavBar._topPadding),
SizedBox(
height: KrowNavBar._pillHeight,
child: Center(child: Icon(item.icon, size: 21, color: iconColor)),
),
const SizedBox(height: 3),
// The dot's slot is always reserved, so tabs with and without one
// keep their labels on the same line.
SizedBox(
height: 6,
child: Center(
child: AnimatedScale(
scale: item.showDot ? 1 : 0,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutBack,
child: Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
shape: BoxShape.circle,
),
),
),
),
),
const SizedBox(height: 2),
Text(
item.label,
style: TextStyle(
fontSize: 11,
fontWeight: proximity > 0.5 ? FontWeight.w600 : FontWeight.w500,
color: labelColor,
),
),
],
),
),
);
}
}

170
lib/widgets/shift_card.dart Normal file
View File

@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/formatters.dart';
import '../core/theme.dart';
import '../models/shift.dart';
import 'common.dart';
/// The one card shape in the app. Identity on top, a hairline split, then the
/// money band — with a status bar welded to the bottom edge.
class ShiftCard extends StatelessWidget {
const ShiftCard({
super.key,
required this.shift,
this.onTap,
this.trailingAction,
this.showDate = false,
});
final Shift shift;
final VoidCallback? onTap;
/// Optional action shown in the money band, e.g. an Apply button.
final Widget? trailingAction;
final bool showDate;
(String, ChipTone) get _status => switch (shift.status) {
ShiftStatus.open => ('Open', ChipTone.blue),
ShiftStatus.booked => (Fmt.untilStart(shift.start), ChipTone.yellow),
ShiftStatus.active => ('On shift', ChipTone.yellow),
ShiftStatus.completed => ('Completed', ChipTone.neutral),
ShiftStatus.cancelled => ('Cancelled', ChipTone.muted),
ShiftStatus.noShow => ('No show', ChipTone.danger),
};
Color get _railColor => switch (shift.status) {
ShiftStatus.active => KrowPalette.yellow,
ShiftStatus.cancelled || ShiftStatus.noShow => const Color(0xFFCFDDD3),
_ => KrowPalette.blue,
};
@override
Widget build(BuildContext context) {
final (statusLabel, statusTone) = _status;
final unpaid =
shift.status == ShiftStatus.cancelled ||
shift.status == ShiftStatus.noShow;
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(color: KrowPalette.line),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BusinessBadge(shift.logoLetter),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
shift.role,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.15,
),
),
const SizedBox(height: 3),
Text(
shift.venue,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12.5,
color: KrowPalette.mute,
),
),
],
),
),
const SizedBox(width: 8),
StatusChip(statusLabel, tone: statusTone),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 7,
runSpacing: 7,
children: [
if (showDate)
MetaPill(
LucideIcons.calendar,
Fmt.dayShort(shift.start),
),
MetaPill(
LucideIcons.clock,
Fmt.window(shift.start, shift.end),
),
MetaPill(
LucideIcons.mapPin,
'${shift.distanceMiles} mi',
),
],
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
Expanded(
child: Text(
unpaid
? 'No pay generated'
: '${Fmt.money(shift.rate)}/hr · '
'${Fmt.duration(shift.scheduledDuration)}',
style: const TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
color: Color(0xFF949FA5),
),
),
),
if (trailingAction != null)
trailingAction!
else
Text(
Fmt.money(shift.pay),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.7,
color: unpaid
? const Color(0xFF949FA5)
: KrowPalette.ink,
),
),
],
),
),
Container(height: 4, color: _railColor),
],
),
),
),
);
}
}

143
lib/widgets/week_strip.dart Normal file
View File

@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../core/theme.dart';
/// Seven-day selector. A dot under a day marks something booked or set.
class WeekStrip extends StatelessWidget {
const WeekStrip({
super.key,
required this.days,
required this.selected,
required this.onSelect,
this.marked = const {},
});
final List<DateTime> days;
final DateTime selected;
final ValueChanged<DateTime> onSelect;
/// Day indices (0..6) that carry a marker dot.
final Set<int> marked;
@override
Widget build(BuildContext context) {
final dow = DateFormat('EEE');
return Row(
children: [
for (var i = 0; i < days.length; i++) ...[
if (i > 0) const SizedBox(width: 7),
Expanded(
child: _DayCell(
day: days[i],
label: dow.format(days[i]),
selected:
days[i].day == selected.day &&
days[i].month == selected.month,
marked: marked.contains(i),
onTap: () => onSelect(days[i]),
),
),
],
],
);
}
}
class _DayCell extends StatelessWidget {
const _DayCell({
required this.day,
required this.label,
required this.selected,
required this.marked,
required this.onTap,
});
final DateTime day;
final String label;
final bool selected;
final bool marked;
final VoidCallback onTap;
static const _duration = Duration(milliseconds: 260);
static const _curve = Curves.easeOutCubic;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
// Colour is animated rather than swapped so moving along the week reads
// as one continuous selection rather than a series of flashes.
child: AnimatedContainer(
duration: _duration,
curve: _curve,
height: 64,
decoration: BoxDecoration(
color: selected ? KrowPalette.blue : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected ? KrowPalette.blue : KrowPalette.line,
),
boxShadow: selected
? [
BoxShadow(
color: KrowPalette.blue.withValues(alpha: 0.26),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: null,
),
child: Stack(
alignment: Alignment.center,
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedDefaultTextStyle(
duration: _duration,
curve: _curve,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
color: selected ? Colors.white70 : const Color(0xFF949FA5),
),
child: Text(label.toUpperCase()),
),
const SizedBox(height: 2),
AnimatedDefaultTextStyle(
duration: _duration,
curve: _curve,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: selected ? Colors.white : KrowPalette.ink,
),
child: Text('${day.day}'),
),
],
),
Positioned(
bottom: 8,
child: AnimatedScale(
scale: marked ? 1 : 0,
duration: _duration,
curve: Curves.easeOutBack,
child: AnimatedContainer(
duration: _duration,
width: 5,
height: 5,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected ? KrowPalette.yellow : KrowPalette.blue,
),
),
),
),
],
),
),
);
}
}