import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../core/theme.dart'; /// A frosted-glass segmented control for use on the brand header. /// /// The track is translucent and blurs whatever sits behind it, with a bright /// top edge and a darker bottom edge so it reads as a pane of glass rather /// than a flat tint. The selected segment is a brighter frosted pill that /// slides between positions. class GlassSegments extends StatelessWidget { const GlassSegments({ super.key, required this.segments, required this.value, required this.onChanged, this.height = 44, }); /// Each segment as `(value, label)`. final List<(T, String)> segments; final T value; final ValueChanged onChanged; final double height; static const _radius = 15.0; static const _pad = 4.0; @override Widget build(BuildContext context) { final index = segments.indexWhere((s) => s.$1 == value); return SizedBox( height: height, child: ClipRRect( borderRadius: BorderRadius.circular(_radius), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(_radius), // Light falling across the pane: brighter at the top edge. gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ Colors.white.withValues(alpha: 0.26), Colors.white.withValues(alpha: 0.12), ], ), border: Border.all( color: Colors.white.withValues(alpha: 0.28), width: 1, ), ), padding: const EdgeInsets.all(_pad), child: LayoutBuilder( builder: (context, constraints) { final segmentWidth = constraints.maxWidth / segments.length; // One animation drives everything: the pill's position, its // stretch, and each label's colour. Animating the selected // index as a double — rather than moving the pill and flipping // the labels separately — is what makes the labels light up // exactly as the pill arrives over them. Retargeting mid-flight // continues from wherever the pill currently is. return TweenAnimationBuilder( tween: Tween(end: index.toDouble()), duration: const Duration(milliseconds: 340), curve: Curves.easeOutQuint, builder: (context, position, _) { // Squash and stretch: the pill widens at speed and settles // back to size as it lands. final travel = (position - position.roundToDouble()).abs(); final stretch = segmentWidth * 0.12 * (travel * 2); return Stack( children: [ if (index >= 0) Positioned( left: position * segmentWidth - stretch / 2, width: segmentWidth + stretch, top: 0, bottom: 0, child: const _SelectedPill(), ), Row( children: [ for (var i = 0; i < segments.length; i++) Expanded( child: _Segment( label: segments[i].$2, // 1 when the pill is centred on this label, // 0 once it has moved a full segment away. selectedAmount: (1 - (position - i).abs()) .clamp(0.0, 1.0), onTap: () { if (segments[i].$1 == value) return; HapticFeedback.selectionClick(); onChanged(segments[i].$1); }, ), ), ], ), ], ); }, ); }, ), ), ), ), ); } } class _SelectedPill extends StatelessWidget { const _SelectedPill(); @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(11), gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.white.withValues(alpha: 0.96), Colors.white.withValues(alpha: 0.86), ], ), border: Border.all(color: Colors.white.withValues(alpha: 0.9)), boxShadow: [ BoxShadow( color: KrowPalette.blueDeep.withValues(alpha: 0.28), blurRadius: 12, offset: const Offset(0, 4), ), ], ), ); } } class _Segment extends StatelessWidget { const _Segment({ required this.label, required this.selectedAmount, required this.onTap, }); final String label; /// How far the travelling pill has arrived over this label, 0 to 1. final double selectedAmount; final VoidCallback onTap; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Center( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5, // Weight is snapped to the nearest real weight; the eye reads the // colour ramp as the smooth part. fontWeight: selectedAmount > 0.5 ? FontWeight.w700 : FontWeight.w600, color: Color.lerp( Colors.white.withValues(alpha: 0.82), KrowPalette.blue, Curves.easeIn.transform(selectedAmount), ), ), ), ), ); } }