Initial commit: Krow Worker App
This commit is contained in:
137
lib/widgets/collapsing_header.dart
Normal file
137
lib/widgets/collapsing_header.dart
Normal 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
835
lib/widgets/common.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
191
lib/widgets/glass_segments.dart
Normal file
191
lib/widgets/glass_segments.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
215
lib/widgets/krow_nav_bar.dart
Normal file
215
lib/widgets/krow_nav_bar.dart
Normal 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
170
lib/widgets/shift_card.dart
Normal 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
143
lib/widgets/week_strip.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user