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
133 lines
4.0 KiB
Dart
133 lines
4.0 KiB
Dart
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),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|