Files
krow-worker-app/lib/widgets/common.dart
2026-09-17 22:26:11 +05:30

836 lines
23 KiB
Dart

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),
),
],
);
}
}