Add Coach, take the reference's colours and copy its nav and motion
Coach is the centre tab: questions about routines, slips, streak, today, active time and debt, answered on the device from the user's own data, with a target bar chart and buttons that start routines or open the right screen. The palette moves to neutral grey, near-black and one orange accent, and Home's header becomes a light panel. The nav is now overlapping frosted circles whose active disc pops forward, and tabs fade and rise in on switch. Goals leaves the nav for Coach and stays one tap away in Home's category row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
This commit is contained in:
40
frontend/lib/Grounded/designs/DashedOutline.dart
Normal file
40
frontend/lib/Grounded/designs/DashedOutline.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A dashed rounded-rectangle outline: the target a bar is measured against.
|
||||
class DashedOutline extends CustomPainter {
|
||||
final Color color;
|
||||
|
||||
final double radius;
|
||||
|
||||
const DashedOutline({required this.color, this.radius = 10});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (size.height <= 0) {
|
||||
return;
|
||||
}
|
||||
final Paint paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.2;
|
||||
|
||||
final Path outline = Path()
|
||||
..addRRect(RRect.fromRectAndRadius(
|
||||
Offset.zero & size, Radius.circular(radius)));
|
||||
|
||||
const double dash = 4;
|
||||
const double gap = 3;
|
||||
for (final metric in outline.computeMetrics()) {
|
||||
double distance = 0;
|
||||
while (distance < metric.length) {
|
||||
canvas.drawPath(
|
||||
metric.extractPath(distance, distance + dash), paint);
|
||||
distance = distance + dash + gap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(DashedOutline oldDelegate) =>
|
||||
oldDelegate.color != color || oldDelegate.radius != radius;
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../utils/Colors.dart';
|
||||
import 'GlassSurface.dart';
|
||||
import 'FloatingNavState.dart';
|
||||
|
||||
typedef NavEntry = ({IconData icon, String label, VoidCallback onTap});
|
||||
|
||||
/// The floating tab bar: a cluster of round frosted buttons, icons only.
|
||||
/// The open tab rises out of the row as a solid white disc. It switches tabs
|
||||
/// in place; nothing here pushes a page.
|
||||
class FloatingNav extends StatelessWidget {
|
||||
/// The floating tab bar, after the reference: a row of frosted circles that
|
||||
/// just overlap, icons only. The open tab is a solid white disc that pops
|
||||
/// forward over its neighbours; a pressed circle dips. It switches tabs in
|
||||
/// place — nothing here pushes a page.
|
||||
class FloatingNav extends StatefulWidget {
|
||||
final List<NavEntry> entries;
|
||||
|
||||
final int selected;
|
||||
@@ -19,84 +19,6 @@ class FloatingNav extends StatelessWidget {
|
||||
required this.selected,
|
||||
});
|
||||
|
||||
static const double _size = 58;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Center(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(_size)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Color(0x1F1A1F1C),
|
||||
blurRadius: 30,
|
||||
offset: Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: GlassSurface(
|
||||
radius: const BorderRadius.all(Radius.circular(_size)),
|
||||
tint: colorGlass,
|
||||
padding: const EdgeInsets.all(5),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int index = 0; index < entries.length; index++) ...[
|
||||
if (index > 0) const SizedBox(width: 4),
|
||||
_button(entries[index], index == selected),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _button(NavEntry entry, bool active) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: active,
|
||||
label: entry.label,
|
||||
child: Tooltip(
|
||||
message: entry.label,
|
||||
child: GestureDetector(
|
||||
onTap: entry.onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: _size,
|
||||
height: _size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: active ? colorWhite : colorWhite.withValues(alpha: 0.35),
|
||||
border: Border.all(
|
||||
color: colorWhite.withValues(alpha: active ? 1 : 0.6),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: active
|
||||
? const [
|
||||
BoxShadow(
|
||||
color: Color(0x261A1F1C),
|
||||
blurRadius: 16,
|
||||
offset: Offset(0, 6),
|
||||
),
|
||||
]
|
||||
: const [],
|
||||
),
|
||||
child: Icon(entry.icon,
|
||||
size: 22, color: active ? colorInk : colorInkSoft),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
State<FloatingNav> createState() => FloatingNavState();
|
||||
}
|
||||
|
||||
132
frontend/lib/Grounded/designs/FloatingNavState.dart
Normal file
132
frontend/lib/Grounded/designs/FloatingNavState.dart
Normal file
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../utils/Colors.dart';
|
||||
import 'FloatingNav.dart';
|
||||
import 'GlassSurface.dart';
|
||||
|
||||
class FloatingNavState extends State<FloatingNav> {
|
||||
static const double _size = 60;
|
||||
|
||||
/// Neighbours overlap by this much, as in the reference.
|
||||
static const double _overlap = 6;
|
||||
|
||||
int _pressed = -1;
|
||||
|
||||
void _onPressDown(int index) {
|
||||
setState(() {
|
||||
_pressed = index;
|
||||
});
|
||||
}
|
||||
|
||||
void _onPressEnd() {
|
||||
setState(() {
|
||||
_pressed = -1;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final int count = widget.entries.length;
|
||||
const double step = _size - _overlap;
|
||||
|
||||
// The active disc is painted last so it sits in front of both neighbours.
|
||||
final List<int> order = <int>[
|
||||
for (int index = 0; index < count; index++)
|
||||
if (index != widget.selected) index,
|
||||
if (widget.selected >= 0 && widget.selected < count) widget.selected,
|
||||
];
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 18),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: step * (count - 1) + _size,
|
||||
height: _size + 8,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
for (int index in order)
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: step * index,
|
||||
top: index == widget.selected ? 0 : 4,
|
||||
child: _button(index),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _button(int index) {
|
||||
final NavEntry entry = widget.entries[index];
|
||||
final bool active = index == widget.selected;
|
||||
final double scale = _pressed == index ? 0.9 : (active ? 1.06 : 1.0);
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: active,
|
||||
label: entry.label,
|
||||
child: GestureDetector(
|
||||
onTap: entry.onTap,
|
||||
onTapDown: (_) => _onPressDown(index),
|
||||
onTapUp: (_) => _onPressEnd(),
|
||||
onTapCancel: _onPressEnd,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: AnimatedScale(
|
||||
scale: scale,
|
||||
duration: Duration(milliseconds: _pressed == index ? 90 : 260),
|
||||
curve: active ? Curves.easeOutBack : Curves.easeOutCubic,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: _size,
|
||||
height: _size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: active
|
||||
? const [
|
||||
BoxShadow(
|
||||
color: Color(0x2E000000),
|
||||
blurRadius: 20,
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
]
|
||||
: const [
|
||||
BoxShadow(
|
||||
color: Color(0x14000000),
|
||||
blurRadius: 14,
|
||||
offset: Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: GlassSurface(
|
||||
radius: const BorderRadius.all(Radius.circular(_size)),
|
||||
tint: active ? colorWhite : colorGlass,
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: colorWhite.withValues(alpha: active ? 1 : 0.85),
|
||||
width: 1.2),
|
||||
),
|
||||
child: TweenAnimationBuilder<Color?>(
|
||||
tween: ColorTween(end: active ? colorInk : colorInkSoft),
|
||||
duration: const Duration(milliseconds: 260),
|
||||
builder: (context, colour, child) =>
|
||||
Icon(entry.icon, size: 23, color: colour),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,3 +37,34 @@ Widget text(
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Copy with **bold** phrases marked inline, for answers that need to stress
|
||||
/// a number. Regular for the body, Medium for the marked parts.
|
||||
Widget markedText(
|
||||
String marked,
|
||||
double textSize, {
|
||||
Color? color,
|
||||
double? height,
|
||||
}) {
|
||||
final List<String> parts = marked.split("**");
|
||||
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
children: <InlineSpan>[
|
||||
for (int index = 0; index < parts.length; index++)
|
||||
TextSpan(
|
||||
text: parts[index],
|
||||
style: TextStyle(
|
||||
fontFamily: getTextType(
|
||||
index.isOdd ? TextType.Medium : TextType.Regular),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: TextStyle(
|
||||
color: color ?? colorPrimaryDark,
|
||||
fontSize: textSize,
|
||||
height: height,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user