feat: enhance UI, branding assets, onboarding flow, and screen layouts

This commit is contained in:
R-Bharathraj
2026-09-18 15:23:30 +05:30
parent cafffd27e0
commit 5122eebc11
68 changed files with 1661 additions and 470 deletions

View File

@@ -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),
],
),
),

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