836 lines
23 KiB
Dart
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),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|