feat: enhance UI, branding assets, onboarding flow, and screen layouts
This commit is contained in:
@@ -43,6 +43,9 @@ class KrowNavBar extends StatelessWidget {
|
||||
/// of pixels apart.
|
||||
static const _topPadding = 12.0;
|
||||
|
||||
static const _labelSize = 11.0;
|
||||
static const _dotSlot = 6.0;
|
||||
|
||||
/// Lets tests find the travelling indicator unambiguously.
|
||||
static const indicatorKey = ValueKey('krow-nav-indicator');
|
||||
|
||||
@@ -55,74 +58,75 @@ class KrowNavBar extends StatelessWidget {
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
height: 76,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final slot = constraints.maxWidth / items.length;
|
||||
// No fixed height: the Stack below takes the height of the item row,
|
||||
// and the pill is positioned within it. A hard number here has to be
|
||||
// retuned every time the label grows — a bigger text scale, a longer
|
||||
// word, a different font — and overflows the moment it is not.
|
||||
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);
|
||||
// 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
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);
|
||||
},
|
||||
),
|
||||
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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -171,6 +175,7 @@ class _NavItem extends StatelessWidget {
|
||||
// Started from the top rather than centred, so the icon sits exactly
|
||||
// where the pill is drawn.
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: KrowNavBar._topPadding),
|
||||
SizedBox(
|
||||
@@ -181,7 +186,7 @@ class _NavItem extends StatelessWidget {
|
||||
// The dot's slot is always reserved, so tabs with and without one
|
||||
// keep their labels on the same line.
|
||||
SizedBox(
|
||||
height: 6,
|
||||
height: KrowNavBar._dotSlot,
|
||||
child: Center(
|
||||
child: AnimatedScale(
|
||||
scale: item.showDot ? 1 : 0,
|
||||
@@ -202,11 +207,12 @@ class _NavItem extends StatelessWidget {
|
||||
Text(
|
||||
item.label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontSize: KrowNavBar._labelSize,
|
||||
fontWeight: proximity > 0.5 ? FontWeight.w600 : FontWeight.w500,
|
||||
color: labelColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
78
lib/widgets/krow_wordmark.dart
Normal file
78
lib/widgets/krow_wordmark.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../core/theme.dart';
|
||||
|
||||
/// The brand wordmark.
|
||||
///
|
||||
/// The asset is an alpha mask — the shape lives in the alpha channel — so one
|
||||
/// file tints to any colour. `Image.asset` blends a `color` with `srcIn` by
|
||||
/// default, which is the same thing the brand sheet did with a CSS mask.
|
||||
///
|
||||
/// Sized by width, because the mark is a fixed 3.06:1 and the width is what
|
||||
/// has to sit within a layout.
|
||||
class KrowWordmark extends StatelessWidget {
|
||||
const KrowWordmark({super.key, this.width = 132, this.color});
|
||||
|
||||
final double width;
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Image.asset(
|
||||
'assets/images/krow_logo.png',
|
||||
width: width,
|
||||
color: color ?? KrowPalette.yellow,
|
||||
filterQuality: FilterQuality.medium,
|
||||
semanticLabel: 'KROW',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The line that tops each main tab: the wordmark on the left, and whatever
|
||||
/// that tab already had up there on the right — an action button, or its
|
||||
/// caption. Putting the mark on the existing line rather than a new one keeps
|
||||
/// the brand consistent across tabs without spending header height on it.
|
||||
class HeaderBrandLine extends StatelessWidget {
|
||||
const HeaderBrandLine({super.key, this.trailing});
|
||||
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
// White rather than yellow: the accent is already carrying the avatar
|
||||
// and the money figures, and a third yellow element leads nowhere.
|
||||
const KrowWordmark(width: 62, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
if (trailing != null)
|
||||
Expanded(
|
||||
child: Align(alignment: Alignment.centerRight, child: trailing),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The small uppercase caption that sits beside the wordmark on a tab header.
|
||||
class HeaderEyebrow extends StatelessWidget {
|
||||
const HeaderEyebrow(this.text, {super.key});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text.toUpperCase(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user