diff --git a/assets/brand.png b/assets/brand.png new file mode 100644 index 0000000..3fe2d90 Binary files /dev/null and b/assets/brand.png differ diff --git a/assets/icon_256.png b/assets/icon_256.png new file mode 100644 index 0000000..30f138b Binary files /dev/null and b/assets/icon_256.png differ diff --git a/assets/icon_512.png b/assets/icon_512.png new file mode 100644 index 0000000..db6e5b5 Binary files /dev/null and b/assets/icon_512.png differ diff --git a/assets/logo2.png b/assets/logo2.png new file mode 100644 index 0000000..9138d38 Binary files /dev/null and b/assets/logo2.png differ diff --git a/companion/assets/images/evoliohealth-logo.png b/companion/assets/images/evoliohealth-logo.png new file mode 100644 index 0000000..9138d38 Binary files /dev/null and b/companion/assets/images/evoliohealth-logo.png differ diff --git a/companion/lib/src/biometrics_screen.dart b/companion/lib/src/biometrics_screen.dart new file mode 100644 index 0000000..084ff27 --- /dev/null +++ b/companion/lib/src/biometrics_screen.dart @@ -0,0 +1,788 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'evolio_theme.dart'; +import 'strings.dart'; + +class BiometricsScreen extends StatefulWidget { + const BiometricsScreen({super.key}); + + @override + State createState() => _BiometricsScreenState(); +} + +class _BiometricsScreenState extends State { + int _selectedDestination = 2; + int _selectedPeriod = 1; + + @override + Widget build(BuildContext context) { + final strings = AppStrings.of(context); + + return Scaffold( + appBar: AppBar( + backgroundColor: EvolioColors.background.withValues(alpha: 0.96), + surfaceTintColor: Colors.transparent, + titleSpacing: 20, + title: const _Brand(), + actions: [ + IconButton( + tooltip: strings.settings, + onPressed: () => _showNotice(strings.scaleUnavailable), + icon: const Icon(Icons.settings_outlined), + ), + const SizedBox(width: 8), + ], + ), + body: SafeArea( + top: false, + child: CustomScrollView( + key: const Key('biometrics-scroll-view'), + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 112), + sliver: SliverList.list( + children: [ + _WeightCard(strings: strings), + const SizedBox(height: 16), + _GradientAction( + icon: Icons.bluetooth_rounded, + label: strings.connectScale, + onPressed: () => _showNotice(strings.scaleUnavailable), + ), + const SizedBox(height: 28), + _SectionHeader(title: strings.silhouetteTitle), + const SizedBox(height: 12), + _SilhouetteCard(strings: strings), + const SizedBox(height: 28), + _EvolutionHeader( + strings: strings, + selectedPeriod: _selectedPeriod, + onSelected: (value) => + setState(() => _selectedPeriod = value), + ), + const SizedBox(height: 12), + _ChartCard(strings: strings, selectedPeriod: _selectedPeriod), + const SizedBox(height: 28), + _SectionHeader(title: strings.bodyMeasurements), + const SizedBox(height: 12), + _MetricsGrid(strings: strings), + const SizedBox(height: 16), + _GuidanceCard(strings: strings), + const SizedBox(height: 20), + _GradientAction( + icon: Icons.save_outlined, + label: strings.saveSession, + onPressed: () => _showNotice(strings.saveUnavailable), + ), + ], + ), + ), + ], + ), + ), + bottomNavigationBar: NavigationBar( + selectedIndex: _selectedDestination, + onDestinationSelected: (index) { + setState(() => _selectedDestination = index); + }, + destinations: [ + NavigationDestination( + icon: const Icon(Icons.dashboard_outlined), + selectedIcon: const Icon(Icons.dashboard), + label: strings.overview, + ), + NavigationDestination( + icon: const Icon(Icons.directions_run_outlined), + selectedIcon: const Icon(Icons.directions_run), + label: strings.activity, + ), + NavigationDestination( + icon: const Icon(Icons.straighten_outlined), + selectedIcon: const Icon(Icons.straighten), + label: strings.measurements, + ), + NavigationDestination( + icon: const Icon(Icons.photo_library_outlined), + selectedIcon: const Icon(Icons.photo_library), + label: strings.photos, + ), + ], + ), + ); + } + + void _showNotice(String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + } +} + +class _Brand extends StatelessWidget { + const _Brand(); + + @override + Widget build(BuildContext context) { + return Semantics( + label: 'EvolioHealth', + header: true, + excludeSemantics: true, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.asset( + 'assets/images/evoliohealth-logo.png', + width: 42, + height: 42, + fit: BoxFit.cover, + semanticLabel: 'EvolioHealth logo', + ), + ), + const SizedBox(width: 11), + RichText( + text: const TextSpan( + style: TextStyle( + color: EvolioColors.text, + fontSize: 18, + fontWeight: FontWeight.w800, + letterSpacing: -0.6, + ), + children: [ + TextSpan(text: 'EVOLIO'), + TextSpan( + text: 'HEALTH', + style: TextStyle(color: EvolioColors.cyan), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _Panel extends StatelessWidget { + const _Panel({ + required this.child, + this.padding = const EdgeInsets.all(18), + this.accent, + }); + + final Widget child; + final EdgeInsets padding; + final Color? accent; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: EvolioColors.surface, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + clipBehavior: Clip.antiAlias, + child: Stack( + children: [ + Padding(padding: padding, child: child), + if (accent != null) + Positioned( + left: 0, + top: 0, + bottom: 0, + child: ColoredBox( + color: accent!, + child: const SizedBox(width: 3), + ), + ), + ], + ), + ); + } +} + +class _WeightCard extends StatelessWidget { + const _WeightCard({required this.strings}); + + final AppStrings strings; + + @override + Widget build(BuildContext context) { + return _Panel( + accent: EvolioColors.cyan, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CapsLabel(strings.currentWeight), + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '82.4', + style: Theme.of(context).textTheme.displayLarge, + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Text( + strings.kilograms, + style: const TextStyle(color: EvolioColors.muted), + ), + ), + ], + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 7, + ), + decoration: BoxDecoration( + color: EvolioColors.magenta.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(9), + border: Border.all( + color: EvolioColors.magenta.withValues(alpha: 0.35), + ), + ), + child: const Row( + children: [ + Icon( + Icons.south_east_rounded, + size: 16, + color: EvolioColors.magenta, + ), + SizedBox(width: 4), + Text( + '-1.2 kg', + style: TextStyle( + color: EvolioColors.magenta, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Text( + strings.dateLabel, + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ], + ), + ); + } +} + +class _GradientAction extends StatelessWidget { + const _GradientAction({ + required this.icon, + required this.label, + required this.onPressed, + }); + + final IconData icon; + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [EvolioColors.cyan, EvolioColors.lime], + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: EvolioColors.cyan.withValues(alpha: 0.16), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: FilledButton.icon( + onPressed: onPressed, + style: FilledButton.styleFrom( + foregroundColor: const Color(0xFF09201E), + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + minimumSize: const Size.fromHeight(54), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: Icon(icon, size: 20), + label: Text( + label.toUpperCase(), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + letterSpacing: 1.5, + ), + ), + ), + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Text( + title.toUpperCase(), + style: Theme.of(context).textTheme.titleMedium, + ); + } +} + +class _SilhouetteCard extends StatelessWidget { + const _SilhouetteCard({required this.strings}); + + final AppStrings strings; + + @override + Widget build(BuildContext context) { + return _Panel( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 18, 18, 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CapsLabel(strings.silhouetteHint), + const SizedBox(height: 5), + Text( + strings.silhouetteDisclaimer, + style: const TextStyle( + color: EvolioColors.muted, + fontSize: 12, + height: 1.35, + ), + ), + ], + ), + ), + SizedBox( + height: 260, + width: double.infinity, + child: Semantics( + image: true, + label: strings.silhouetteTitle, + child: const CustomPaint(painter: _SilhouettePainter()), + ), + ), + ], + ), + ); + } +} + +class _SilhouettePainter extends CustomPainter { + const _SilhouettePainter(); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final glow = Paint() + ..shader = + RadialGradient( + colors: [ + EvolioColors.cyan.withValues(alpha: 0.18), + EvolioColors.surface.withValues(alpha: 0), + ], + ).createShader( + Rect.fromCircle(center: center, radius: size.shortestSide * 0.48), + ); + canvas.drawCircle(center, size.shortestSide * 0.48, glow); + + final grid = Paint() + ..color = Colors.white.withValues(alpha: 0.035) + ..strokeWidth = 1; + for (double x = 18; x < size.width; x += 24) { + canvas.drawLine(Offset(x, 0), Offset(x, size.height), grid); + } + for (double y = 18; y < size.height; y += 24) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), grid); + } + + final outline = Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [EvolioColors.cyan, EvolioColors.lime], + ).createShader(Rect.fromLTWH(0, 20, size.width, size.height - 30)) + ..style = PaintingStyle.stroke + ..strokeWidth = 2.2; + final fill = Paint() + ..color = EvolioColors.cyan.withValues(alpha: 0.07) + ..style = PaintingStyle.fill; + + final x = size.width / 2; + final body = Path() + ..addOval(Rect.fromCircle(center: Offset(x, 46), radius: 22)) + ..moveTo(x - 18, 70) + ..cubicTo(x - 48, 88, x - 47, 122, x - 38, 151) + ..cubicTo(x - 34, 171, x - 42, 205, x - 34, 242) + ..lineTo(x - 13, 242) + ..cubicTo(x - 8, 211, x - 7, 184, x, 169) + ..cubicTo(x + 7, 184, x + 8, 211, x + 13, 242) + ..lineTo(x + 34, 242) + ..cubicTo(x + 42, 205, x + 34, 171, x + 38, 151) + ..cubicTo(x + 47, 122, x + 48, 88, x + 18, 70) + ..close(); + canvas.drawPath(body, fill); + canvas.drawPath(body, outline); + + final marker = Paint()..color = EvolioColors.magenta; + for (final y in [105.0, 139.0, 168.0]) { + canvas.drawCircle(Offset(x + 39, y), 3, marker); + canvas.drawLine( + Offset(x + 39, y), + Offset(x + 70, y), + Paint() + ..color = EvolioColors.magenta.withValues(alpha: 0.5) + ..strokeWidth = 1, + ); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +class _EvolutionHeader extends StatelessWidget { + const _EvolutionHeader({ + required this.strings, + required this.selectedPeriod, + required this.onSelected, + }); + + final AppStrings strings; + final int selectedPeriod; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final periods = [strings.oneWeek, strings.oneMonth, strings.sixMonths]; + return Row( + children: [ + Expanded(child: _SectionHeader(title: strings.evolution)), + SegmentedButton( + segments: [ + for (var i = 0; i < periods.length; i++) + ButtonSegment(value: i, label: Text(periods[i])), + ], + selected: {selectedPeriod}, + onSelectionChanged: (selection) => onSelected(selection.first), + showSelectedIcon: false, + style: ButtonStyle( + visualDensity: VisualDensity.compact, + textStyle: const WidgetStatePropertyAll( + TextStyle(fontSize: 10, fontWeight: FontWeight.w700), + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 9), + ), + ), + ), + ], + ); + } +} + +class _ChartCard extends StatelessWidget { + const _ChartCard({required this.strings, required this.selectedPeriod}); + + final AppStrings strings; + final int selectedPeriod; + + @override + Widget build(BuildContext context) { + return _Panel( + padding: const EdgeInsets.fromLTRB(12, 18, 12, 12), + child: SizedBox( + height: 170, + child: Stack( + children: [ + Positioned.fill( + child: CustomPaint( + painter: _TrendPainter(period: selectedPeriod), + ), + ), + Positioned( + left: 6, + right: 6, + bottom: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '01.07', + style: TextStyle(color: EvolioColors.muted, fontSize: 10), + ), + const Text( + '10.07', + style: TextStyle(color: EvolioColors.muted, fontSize: 10), + ), + Text( + strings.latest.toUpperCase(), + style: const TextStyle( + color: EvolioColors.magenta, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _TrendPainter extends CustomPainter { + const _TrendPainter({required this.period}); + + final int period; + + @override + void paint(Canvas canvas, Size size) { + final chartHeight = size.height - 26; + final gridPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.05) + ..strokeWidth = 1; + for (var i = 0; i < 4; i++) { + final y = chartHeight * i / 3; + canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint); + } + + final phase = period * 0.35; + final line = Path(); + for (var i = 0; i <= 60; i++) { + final progress = i / 60; + final wave = math.sin(progress * math.pi * 3 + phase) * 8; + final y = chartHeight * (0.72 - progress * 0.32) + wave; + final point = Offset(size.width * progress, y); + if (i == 0) { + line.moveTo(point.dx, point.dy); + } else { + line.lineTo(point.dx, point.dy); + } + } + final area = Path.from(line) + ..lineTo(size.width, chartHeight) + ..lineTo(0, chartHeight) + ..close(); + canvas.drawPath( + area, + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + EvolioColors.magenta.withValues(alpha: 0.3), + EvolioColors.magenta.withValues(alpha: 0), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, chartHeight)), + ); + canvas.drawPath( + line, + Paint() + ..color = EvolioColors.magenta + ..style = PaintingStyle.stroke + ..strokeWidth = 2.2 + ..strokeCap = StrokeCap.round, + ); + } + + @override + bool shouldRepaint(covariant _TrendPainter oldDelegate) => + oldDelegate.period != period; +} + +class _MetricsGrid extends StatelessWidget { + const _MetricsGrid({required this.strings}); + + final AppStrings strings; + + @override + Widget build(BuildContext context) { + final metrics = [ + (strings.chest, '104.5', Icons.straighten, EvolioColors.cyan), + (strings.waist, '82.0', Icons.accessibility_new, EvolioColors.lime), + (strings.hips, '98.2', Icons.data_usage, EvolioColors.magenta), + (strings.arms, '38.5', Icons.fitness_center, const Color(0xFFFFB4EE)), + ]; + return LayoutBuilder( + builder: (context, constraints) { + final columnCount = constraints.maxWidth >= 620 ? 4 : 2; + final width = + (constraints.maxWidth - 12 * (columnCount - 1)) / columnCount; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final metric in metrics) + SizedBox( + width: width, + child: _MetricCard( + label: metric.$1, + value: metric.$2, + icon: metric.$3, + accent: metric.$4, + unit: strings.centimeters, + ), + ), + ], + ); + }, + ); + } +} + +class _MetricCard extends StatelessWidget { + const _MetricCard({ + required this.label, + required this.value, + required this.icon, + required this.accent, + required this.unit, + }); + + final String label; + final String value; + final IconData icon; + final Color accent; + final String unit; + + @override + Widget build(BuildContext context) { + return _Panel( + accent: accent.withValues(alpha: 0.55), + padding: const EdgeInsets.all(15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: _CapsLabel(label)), + Icon(icon, color: accent, size: 19), + ], + ), + const SizedBox(height: 17), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextFormField( + initialValue: value, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + ), + decoration: InputDecoration( + semanticCounterText: '$label, $unit', + ), + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + unit, + style: const TextStyle( + color: EvolioColors.muted, + fontSize: 11, + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _GuidanceCard extends StatelessWidget { + const _GuidanceCard({required this.strings}); + + final AppStrings strings; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: EvolioColors.cyan.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: EvolioColors.cyan.withValues(alpha: 0.18)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.lightbulb_outline, + color: EvolioColors.cyan, + size: 19, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + strings.measurementGuidance, + style: const TextStyle( + color: EvolioColors.muted, + fontSize: 12, + height: 1.4, + ), + ), + ), + ], + ), + ); + } +} + +class _CapsLabel extends StatelessWidget { + const _CapsLabel(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall, + ); + } +} diff --git a/companion/lib/src/evolio_theme.dart b/companion/lib/src/evolio_theme.dart new file mode 100644 index 0000000..dc425c0 --- /dev/null +++ b/companion/lib/src/evolio_theme.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; + +abstract final class EvolioColors { + static const background = Color(0xFF101315); + static const surface = Color(0xFF191D20); + static const surfaceRaised = Color(0xFF22272B); + static const cyan = Color(0xFF51E5DC); + static const lime = Color(0xFFB8EE70); + static const magenta = Color(0xFFE77ADD); + static const text = Color(0xFFF0F4F3); + static const muted = Color(0xFF9DA9A7); +} + +abstract final class EvolioTheme { + static ThemeData get dark { + const scheme = ColorScheme.dark( + primary: EvolioColors.cyan, + onPrimary: Color(0xFF00201E), + secondary: EvolioColors.magenta, + onSecondary: Color(0xFF32102F), + tertiary: EvolioColors.lime, + onTertiary: Color(0xFF172500), + surface: EvolioColors.surface, + onSurface: EvolioColors.text, + error: Color(0xFFFFB4AB), + ); + + return ThemeData( + brightness: Brightness.dark, + colorScheme: scheme, + scaffoldBackgroundColor: EvolioColors.background, + useMaterial3: true, + fontFamily: 'sans-serif', + textTheme: const TextTheme( + displayLarge: TextStyle( + color: EvolioColors.text, + fontSize: 48, + height: 1, + fontWeight: FontWeight.w800, + letterSpacing: -1.5, + ), + headlineMedium: TextStyle( + color: EvolioColors.text, + fontSize: 24, + height: 1.2, + fontWeight: FontWeight.w700, + ), + titleMedium: TextStyle( + color: EvolioColors.text, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + bodyMedium: TextStyle( + color: EvolioColors.text, + fontSize: 15, + height: 1.45, + ), + labelSmall: TextStyle( + color: EvolioColors.muted, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.25, + ), + ), + navigationBarTheme: const NavigationBarThemeData( + backgroundColor: EvolioColors.background, + indicatorColor: Color(0x2451E5DC), + height: 72, + labelTextStyle: WidgetStatePropertyAll( + TextStyle(fontSize: 10, fontWeight: FontWeight.w700), + ), + ), + inputDecorationTheme: const InputDecorationTheme( + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + ), + ); + } +} diff --git a/companion/lib/src/strings.dart b/companion/lib/src/strings.dart new file mode 100644 index 0000000..65b40c6 --- /dev/null +++ b/companion/lib/src/strings.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +class AppStrings { + const AppStrings._(this.locale); + + final Locale locale; + + static const supportedLocales = [Locale('en'), Locale('fr')]; + + static AppStrings of(BuildContext context) { + return AppStrings._(Localizations.localeOf(context)); + } + + bool get _fr => locale.languageCode == 'fr'; + + String get appName => 'EvolioHealth'; + String get overview => _fr ? 'Aperçu' : 'Overview'; + String get activity => _fr ? 'Activité' : 'Activity'; + String get measurements => _fr ? 'Mesures' : 'Measurements'; + String get photos => _fr ? 'Photos' : 'Photos'; + String get settings => _fr ? 'Paramètres' : 'Settings'; + String get currentWeight => _fr ? 'Poids actuel' : 'Current weight'; + String get weightChange => + _fr ? 'depuis la dernière mesure' : 'since last measurement'; + String get connectScale => _fr ? 'Connecter la balance' : 'Connect scale'; + String get scaleUnavailable => _fr + ? 'La connexion BLE sera disponible dans une prochaine étape.' + : 'BLE connection will be available in a future step.'; + String get silhouetteTitle => + _fr ? 'Silhouette indicative' : 'Indicative silhouette'; + String get silhouetteHint => _fr + ? 'Aperçu paramétrique basé sur vos mesures' + : 'Parametric preview based on your measurements'; + String get silhouetteDisclaimer => _fr + ? 'Visualisation indicative uniquement — ce n’est ni une reconstruction fidèle ni une analyse médicale.' + : 'Indicative visualization only — not a faithful reconstruction or medical analysis.'; + String get evolution => _fr ? 'Évolution' : 'Evolution'; + String get oneWeek => _fr ? '1 sem.' : '1 wk'; + String get oneMonth => _fr ? '1 mois' : '1 mo'; + String get sixMonths => _fr ? '6 mois' : '6 mo'; + String get latest => _fr ? 'Récent' : 'Latest'; + String get bodyMeasurements => _fr ? 'Mensurations' : 'Body measurements'; + String get chest => _fr ? 'Poitrine' : 'Chest'; + String get waist => _fr ? 'Taille' : 'Waist'; + String get hips => _fr ? 'Hanches' : 'Hips'; + String get arms => _fr ? 'Bras' : 'Arms'; + String get saveSession => _fr ? 'Enregistrer la session' : 'Save session'; + String get saveUnavailable => _fr + ? 'Cette maquette ne persiste pas encore les mesures.' + : 'This prototype does not persist measurements yet.'; + String get measurementGuidance => _fr + ? 'Pour suivre une tendance fiable, mesurez-vous dans des conditions similaires.' + : 'For a reliable trend, measure under similar conditions.'; + String get dateLabel => _fr ? '14 juillet' : 'July 14'; + String get kilograms => 'kg'; + String get centimeters => 'cm'; +} diff --git a/companion/pubspec.lock b/companion/pubspec.lock new file mode 100644 index 0000000..a71dc29 --- /dev/null +++ b/companion/pubspec.lock @@ -0,0 +1,218 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" +sdks: + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/web/assets/images/evoliohealth-brand.png b/web/assets/images/evoliohealth-brand.png new file mode 100644 index 0000000..3fe2d90 Binary files /dev/null and b/web/assets/images/evoliohealth-brand.png differ diff --git a/web/assets/images/evoliohealth-logo.png b/web/assets/images/evoliohealth-logo.png new file mode 100644 index 0000000..9138d38 Binary files /dev/null and b/web/assets/images/evoliohealth-logo.png differ diff --git a/web/lib/src/evolio_theme.dart b/web/lib/src/evolio_theme.dart new file mode 100644 index 0000000..7325176 --- /dev/null +++ b/web/lib/src/evolio_theme.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +abstract final class EvolioColors { + static const background = Color(0xFF101315); + static const surface = Color(0xFF191D20); + static const surfaceRaised = Color(0xFF22272B); + static const cyan = Color(0xFF51E5DC); + static const lime = Color(0xFFB8EE70); + static const magenta = Color(0xFFE77ADD); + static const text = Color(0xFFF0F4F3); + static const muted = Color(0xFF9DA9A7); +} + +abstract final class EvolioTheme { + static ThemeData get dark { + const scheme = ColorScheme.dark( + primary: EvolioColors.cyan, + onPrimary: Color(0xFF00201E), + secondary: EvolioColors.magenta, + onSecondary: Color(0xFF32102F), + tertiary: EvolioColors.lime, + onTertiary: Color(0xFF172500), + surface: EvolioColors.surface, + onSurface: EvolioColors.text, + error: Color(0xFFFFB4AB), + ); + + return ThemeData( + brightness: Brightness.dark, + colorScheme: scheme, + scaffoldBackgroundColor: EvolioColors.background, + useMaterial3: true, + fontFamily: 'sans-serif', + textTheme: const TextTheme( + displayLarge: TextStyle( + color: EvolioColors.text, + fontSize: 52, + height: 1, + fontWeight: FontWeight.w800, + letterSpacing: -1.5, + ), + headlineMedium: TextStyle( + color: EvolioColors.text, + fontSize: 26, + height: 1.2, + fontWeight: FontWeight.w700, + ), + titleMedium: TextStyle( + color: EvolioColors.text, + fontSize: 13, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + ), + bodyMedium: TextStyle( + color: EvolioColors.text, + fontSize: 15, + height: 1.45, + ), + labelSmall: TextStyle( + color: EvolioColors.muted, + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 1.25, + ), + ), + inputDecorationTheme: const InputDecorationTheme( + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: EvolioColors.surfaceRaised, + contentTextStyle: TextStyle(color: EvolioColors.text), + ), + ); + } +} diff --git a/web/lib/src/strings.dart b/web/lib/src/strings.dart new file mode 100644 index 0000000..5985f6f --- /dev/null +++ b/web/lib/src/strings.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +class AppStrings { + const AppStrings._(this.locale); + + final Locale locale; + + static const supportedLocales = [Locale('en'), Locale('fr')]; + + static AppStrings of(BuildContext context) => + AppStrings._(Localizations.localeOf(context)); + + bool get _fr => locale.languageCode == 'fr'; + + String get appName => 'EvolioHealth'; + String get overview => _fr ? 'Aperçu' : 'Overview'; + String get activity => _fr ? 'Activité' : 'Activity'; + String get measurements => _fr ? 'Mesures' : 'Measurements'; + String get photos => _fr ? 'Photos' : 'Photos'; + String get exports => _fr ? 'Exports' : 'Exports'; + String get settings => _fr ? 'Paramètres' : 'Settings'; + String get greeting => _fr ? 'Bonjour, Alex' : 'Hello, Alex'; + String get subtitle => _fr + ? 'Voici l’évolution de vos mesures personnelles.' + : 'Here is the evolution of your personal measurements.'; + String get currentWeight => _fr ? 'Poids actuel' : 'Current weight'; + String get lastMeasurement => _fr ? 'Dernière mesure' : 'Last measurement'; + String get bodyFat => _fr ? 'Masse grasse estimée' : 'Estimated body fat'; + String get pendingSync => _fr ? 'Synchronisation' : 'Synchronization'; + String get synchronized => _fr ? 'À jour' : 'Up to date'; + String get newSession => _fr ? 'Nouvelle session' : 'New session'; + String get prototypeNotice => _fr + ? 'Cette maquette n’est pas encore connectée à l’API EvolioHealth.' + : 'This prototype is not connected to the EvolioHealth API yet.'; + String get silhouetteTitle => + _fr ? 'Silhouette indicative' : 'Indicative silhouette'; + String get silhouetteHint => _fr + ? 'Aperçu paramétrique basé sur vos mesures' + : 'Parametric preview based on your measurements'; + String get silhouetteDisclaimer => _fr + ? 'Visualisation indicative uniquement — ce n’est ni une reconstruction fidèle ni une analyse médicale.' + : 'Indicative visualization only — not a faithful reconstruction or medical analysis.'; + String get evolution => _fr ? 'Évolution du poids' : 'Weight evolution'; + String get oneWeek => _fr ? '1 sem.' : '1 wk'; + String get oneMonth => _fr ? '1 mois' : '1 mo'; + String get sixMonths => _fr ? '6 mois' : '6 mo'; + String get latest => _fr ? 'Récent' : 'Latest'; + String get bodyMeasurements => _fr ? 'Mensurations' : 'Body measurements'; + String get chest => _fr ? 'Poitrine' : 'Chest'; + String get waist => _fr ? 'Taille' : 'Waist'; + String get hips => _fr ? 'Hanches' : 'Hips'; + String get arms => _fr ? 'Bras' : 'Arms'; + String get shoulders => _fr ? 'Épaules' : 'Shoulders'; + String get thighs => _fr ? 'Cuisses' : 'Thighs'; + String get history => _fr ? 'Voir l’historique' : 'View history'; + String get guidance => _fr + ? 'Pour suivre une tendance fiable, effectuez vos mesures dans des conditions similaires.' + : 'For a reliable trend, take measurements under similar conditions.'; + String get dateLabel => _fr ? '14 juillet 2026' : 'July 14, 2026'; + String get kilograms => 'kg'; + String get centimeters => 'cm'; +} diff --git a/web/lib/src/web_dashboard.dart b/web/lib/src/web_dashboard.dart new file mode 100644 index 0000000..c8752bc --- /dev/null +++ b/web/lib/src/web_dashboard.dart @@ -0,0 +1,1120 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'evolio_theme.dart'; +import 'strings.dart'; + +class WebDashboard extends StatefulWidget { + const WebDashboard({super.key}); + + @override + State createState() => _WebDashboardState(); +} + +class _WebDashboardState extends State { + int _selectedDestination = 2; + int _selectedPeriod = 1; + + @override + Widget build(BuildContext context) { + final strings = AppStrings.of(context); + return LayoutBuilder( + builder: (context, constraints) { + final desktop = constraints.maxWidth >= 900; + if (desktop) { + return Scaffold( + body: Row( + children: [ + _SideNavigation( + strings: strings, + selectedIndex: _selectedDestination, + onSelected: _selectDestination, + ), + Expanded( + child: _DashboardBody( + strings: strings, + selectedPeriod: _selectedPeriod, + onPeriodSelected: _selectPeriod, + onAction: _showPrototypeNotice, + ), + ), + ], + ), + ); + } + + return Scaffold( + appBar: AppBar( + backgroundColor: EvolioColors.background, + surfaceTintColor: Colors.transparent, + title: const _CompactBrand(), + actions: [ + IconButton( + tooltip: strings.settings, + onPressed: _showPrototypeNotice, + icon: const Icon(Icons.settings_outlined), + ), + const SizedBox(width: 8), + ], + ), + body: _DashboardBody( + strings: strings, + selectedPeriod: _selectedPeriod, + onPeriodSelected: _selectPeriod, + onAction: _showPrototypeNotice, + ), + bottomNavigationBar: NavigationBar( + selectedIndex: _selectedDestination.clamp(0, 3), + backgroundColor: EvolioColors.background, + indicatorColor: EvolioColors.cyan.withValues(alpha: 0.12), + onDestinationSelected: _selectDestination, + destinations: [ + _destination(Icons.dashboard_outlined, strings.overview), + _destination(Icons.directions_run_outlined, strings.activity), + _destination(Icons.straighten_outlined, strings.measurements), + _destination(Icons.photo_library_outlined, strings.photos), + ], + ), + ); + }, + ); + } + + NavigationDestination _destination(IconData icon, String label) { + return NavigationDestination(icon: Icon(icon), label: label); + } + + void _selectDestination(int value) { + setState(() => _selectedDestination = value); + } + + void _selectPeriod(int value) { + setState(() => _selectedPeriod = value); + } + + void _showPrototypeNotice() { + final message = AppStrings.of(context).prototypeNotice; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + } +} + +class _SideNavigation extends StatelessWidget { + const _SideNavigation({ + required this.strings, + required this.selectedIndex, + required this.onSelected, + }); + + final AppStrings strings; + final int selectedIndex; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final entries = [ + (Icons.dashboard_outlined, strings.overview), + (Icons.directions_run_outlined, strings.activity), + (Icons.straighten_outlined, strings.measurements), + (Icons.photo_library_outlined, strings.photos), + (Icons.file_download_outlined, strings.exports), + ]; + + return Container( + key: const Key('desktop-navigation'), + width: 244, + padding: const EdgeInsets.fromLTRB(20, 24, 20, 22), + decoration: BoxDecoration( + color: const Color(0xFF0C0F11), + border: Border( + right: BorderSide(color: Colors.white.withValues(alpha: 0.06)), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + label: 'EvolioHealth', + header: true, + child: Image.asset( + 'assets/images/evoliohealth-brand.png', + height: 68, + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + ), + ), + const SizedBox(height: 34), + for (var i = 0; i < entries.length; i++) ...[ + _NavigationEntry( + icon: entries[i].$1, + label: entries[i].$2, + selected: i == selectedIndex, + onTap: () => onSelected(i), + ), + const SizedBox(height: 7), + ], + const Spacer(), + const Divider(color: Color(0x1AFFFFFF)), + const SizedBox(height: 8), + _NavigationEntry( + icon: Icons.settings_outlined, + label: strings.settings, + selected: false, + onTap: () {}, + ), + const SizedBox(height: 14), + const Row( + children: [ + CircleAvatar( + radius: 18, + backgroundColor: Color(0x2451E5DC), + child: Text( + 'A', + style: TextStyle( + color: EvolioColors.cyan, + fontWeight: FontWeight.w800, + ), + ), + ), + SizedBox(width: 11), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Alex', style: TextStyle(fontWeight: FontWeight.w700)), + Text( + 'alex@example.invalid', + overflow: TextOverflow.ellipsis, + style: TextStyle(color: EvolioColors.muted, fontSize: 11), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } +} + +class _NavigationEntry extends StatelessWidget { + const _NavigationEntry({ + required this.icon, + required this.label, + required this.selected, + required this.onTap, + }); + + final IconData icon; + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final color = selected ? EvolioColors.cyan : EvolioColors.muted; + return Material( + color: selected + ? EvolioColors.cyan.withValues(alpha: 0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Icon(icon, color: color, size: 21), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + color: color, + fontSize: 13, + fontWeight: selected ? FontWeight.w700 : FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _CompactBrand extends StatelessWidget { + const _CompactBrand(); + + @override + Widget build(BuildContext context) { + return Semantics( + label: 'EvolioHealth', + header: true, + excludeSemantics: true, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(9), + child: Image.asset( + 'assets/images/evoliohealth-logo.png', + width: 38, + height: 38, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 10), + const Text( + 'EVOLIOHEALTH', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + ), + ), + ], + ), + ); + } +} + +class _DashboardBody extends StatelessWidget { + const _DashboardBody({ + required this.strings, + required this.selectedPeriod, + required this.onPeriodSelected, + required this.onAction, + }); + + final AppStrings strings; + final int selectedPeriod; + final ValueChanged onPeriodSelected; + final VoidCallback onAction; + + @override + Widget build(BuildContext context) { + return CustomScrollView( + key: const Key('web-dashboard-scroll'), + slivers: [ + SliverToBoxAdapter( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1380), + child: Padding( + padding: const EdgeInsets.fromLTRB(28, 30, 28, 100), + child: LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 980; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _PageHeader(strings: strings, onAction: onAction), + const SizedBox(height: 28), + _SummaryGrid(strings: strings), + const SizedBox(height: 24), + if (wide) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 4, + child: _SilhouetteCard(strings: strings), + ), + const SizedBox(width: 20), + Expanded( + flex: 6, + child: _EvolutionCard( + strings: strings, + selectedPeriod: selectedPeriod, + onPeriodSelected: onPeriodSelected, + ), + ), + ], + ) + else ...[ + _SilhouetteCard(strings: strings), + const SizedBox(height: 20), + _EvolutionCard( + strings: strings, + selectedPeriod: selectedPeriod, + onPeriodSelected: onPeriodSelected, + ), + ], + const SizedBox(height: 28), + _MeasurementsSection( + strings: strings, + onAction: onAction, + ), + const SizedBox(height: 18), + _GuidanceCard(strings: strings), + ], + ); + }, + ), + ), + ), + ), + ), + ], + ); + } +} + +class _PageHeader extends StatelessWidget { + const _PageHeader({required this.strings, required this.onAction}); + + final AppStrings strings; + final VoidCallback onAction; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 620; + final heading = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + strings.greeting, + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + strings.subtitle, + style: const TextStyle(color: EvolioColors.muted), + ), + ], + ); + final action = _GradientButton( + icon: Icons.add_rounded, + label: strings.newSession, + onPressed: onAction, + ); + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [heading, const SizedBox(height: 18), action], + ); + } + return Row( + children: [ + Expanded(child: heading), + const SizedBox(width: 24), + action, + ], + ); + }, + ); + } +} + +class _SummaryGrid extends StatelessWidget { + const _SummaryGrid({required this.strings}); + + final AppStrings strings; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final count = constraints.maxWidth >= 1060 + ? 4 + : constraints.maxWidth >= 600 + ? 2 + : 1; + final width = (constraints.maxWidth - (count - 1) * 14) / count; + return Wrap( + spacing: 14, + runSpacing: 14, + children: [ + SizedBox( + width: width, + child: _SummaryCard( + label: strings.currentWeight, + value: '82.4', + unit: strings.kilograms, + detail: '-1.2 kg', + icon: Icons.monitor_weight_outlined, + accent: EvolioColors.cyan, + ), + ), + SizedBox( + width: width, + child: _SummaryCard( + label: strings.lastMeasurement, + value: '14', + unit: strings.dateLabel, + detail: '08:24', + icon: Icons.calendar_today_outlined, + accent: EvolioColors.lime, + ), + ), + SizedBox( + width: width, + child: _SummaryCard( + label: strings.bodyFat, + value: '18.6', + unit: '%', + detail: 'estimated', + icon: Icons.data_usage_rounded, + accent: EvolioColors.magenta, + ), + ), + SizedBox( + width: width, + child: _SummaryCard( + label: strings.pendingSync, + value: strings.synchronized, + unit: '', + detail: '2 min', + icon: Icons.cloud_done_outlined, + accent: EvolioColors.cyan, + ), + ), + ], + ); + }, + ); + } +} + +class _SummaryCard extends StatelessWidget { + const _SummaryCard({ + required this.label, + required this.value, + required this.unit, + required this.detail, + required this.icon, + required this.accent, + }); + + final String label; + final String value; + final String unit; + final String detail; + final IconData icon; + final Color accent; + + @override + Widget build(BuildContext context) { + return _Panel( + accent: accent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: _CapsLabel(label)), + Icon(icon, size: 20, color: accent), + ], + ), + const SizedBox(height: 18), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible( + child: Text( + value, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 30, + fontWeight: FontWeight.w800, + ), + ), + ), + if (unit.isNotEmpty) ...[ + const SizedBox(width: 7), + Flexible( + child: Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + unit, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: EvolioColors.muted, + fontSize: 11, + ), + ), + ), + ), + ], + ], + ), + const SizedBox(height: 8), + Text( + detail, + style: TextStyle( + color: accent, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _SilhouetteCard extends StatelessWidget { + const _SilhouetteCard({required this.strings}); + + final AppStrings strings; + + @override + Widget build(BuildContext context) { + return _Panel( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader(strings.silhouetteTitle), + const SizedBox(height: 7), + Text( + strings.silhouetteHint, + style: const TextStyle( + color: EvolioColors.cyan, + fontSize: 12, + ), + ), + ], + ), + ), + SizedBox( + height: 320, + width: double.infinity, + child: Semantics( + image: true, + label: strings.silhouetteTitle, + child: const CustomPaint(painter: _SilhouettePainter()), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: Text( + strings.silhouetteDisclaimer, + style: const TextStyle( + color: EvolioColors.muted, + fontSize: 11, + height: 1.4, + ), + ), + ), + ], + ), + ); + } +} + +class _EvolutionCard extends StatelessWidget { + const _EvolutionCard({ + required this.strings, + required this.selectedPeriod, + required this.onPeriodSelected, + }); + + final AppStrings strings; + final int selectedPeriod; + final ValueChanged onPeriodSelected; + + @override + Widget build(BuildContext context) { + final periods = [strings.oneWeek, strings.oneMonth, strings.sixMonths]; + return _Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 18, + runSpacing: 12, + children: [ + _SectionHeader(strings.evolution), + SegmentedButton( + segments: [ + for (var i = 0; i < periods.length; i++) + ButtonSegment(value: i, label: Text(periods[i])), + ], + selected: {selectedPeriod}, + onSelectionChanged: (selection) => + onPeriodSelected(selection.first), + showSelectedIcon: false, + style: ButtonStyle( + visualDensity: VisualDensity.compact, + textStyle: const WidgetStatePropertyAll( + TextStyle(fontSize: 11, fontWeight: FontWeight.w700), + ), + ), + ), + ], + ), + const SizedBox(height: 20), + SizedBox( + height: 285, + child: Stack( + children: [ + Positioned.fill( + child: CustomPaint( + painter: _TrendPainter(period: selectedPeriod), + ), + ), + Positioned( + left: 4, + right: 4, + bottom: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const _AxisLabel('01.07'), + const _AxisLabel('10.07'), + Text( + strings.latest.toUpperCase(), + style: const TextStyle( + color: EvolioColors.magenta, + fontSize: 10, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _MeasurementsSection extends StatelessWidget { + const _MeasurementsSection({required this.strings, required this.onAction}); + + final AppStrings strings; + final VoidCallback onAction; + + @override + Widget build(BuildContext context) { + final metrics = [ + (strings.chest, '104.5', EvolioColors.cyan), + (strings.waist, '82.0', EvolioColors.lime), + (strings.hips, '98.2', EvolioColors.magenta), + (strings.arms, '38.5', const Color(0xFFFFB4EE)), + (strings.shoulders, '118.0', EvolioColors.cyan), + (strings.thighs, '59.4', EvolioColors.lime), + ]; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded(child: _SectionHeader(strings.bodyMeasurements)), + TextButton.icon( + onPressed: onAction, + icon: const Icon(Icons.history, size: 18), + label: Text(strings.history), + ), + ], + ), + const SizedBox(height: 12), + LayoutBuilder( + builder: (context, constraints) { + final count = constraints.maxWidth >= 1080 + ? 3 + : constraints.maxWidth >= 620 + ? 2 + : 1; + final width = (constraints.maxWidth - (count - 1) * 14) / count; + return Wrap( + spacing: 14, + runSpacing: 14, + children: [ + for (final metric in metrics) + SizedBox( + width: width, + child: _MetricCard( + label: metric.$1, + value: metric.$2, + unit: strings.centimeters, + accent: metric.$3, + ), + ), + ], + ); + }, + ), + ], + ); + } +} + +class _MetricCard extends StatelessWidget { + const _MetricCard({ + required this.label, + required this.value, + required this.unit, + required this.accent, + }); + + final String label; + final String value; + final String unit; + final Color accent; + + @override + Widget build(BuildContext context) { + return _Panel( + accent: accent.withValues(alpha: 0.65), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.straighten, color: accent, size: 19), + ), + const SizedBox(width: 13), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CapsLabel(label), + const SizedBox(height: 6), + Text( + '$value $unit', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + IconButton( + tooltip: label, + onPressed: () {}, + icon: const Icon( + Icons.edit_outlined, + size: 18, + color: EvolioColors.muted, + ), + ), + ], + ), + ); + } +} + +class _GuidanceCard extends StatelessWidget { + const _GuidanceCard({required this.strings}); + + final AppStrings strings; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: EvolioColors.cyan.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: EvolioColors.cyan.withValues(alpha: 0.18)), + ), + child: Row( + children: [ + const Icon( + Icons.lightbulb_outline, + color: EvolioColors.cyan, + size: 20, + ), + const SizedBox(width: 11), + Expanded( + child: Text( + strings.guidance, + style: const TextStyle(color: EvolioColors.muted, fontSize: 12), + ), + ), + ], + ), + ); + } +} + +class _Panel extends StatelessWidget { + const _Panel({ + required this.child, + this.padding = const EdgeInsets.all(18), + this.accent, + }); + + final Widget child; + final EdgeInsets padding; + final Color? accent; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: EvolioColors.surface, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + clipBehavior: Clip.antiAlias, + child: Stack( + children: [ + Padding(padding: padding, child: child), + if (accent != null) + Positioned( + left: 0, + top: 0, + bottom: 0, + child: ColoredBox( + color: accent!, + child: const SizedBox(width: 3), + ), + ), + ], + ), + ); + } +} + +class _GradientButton extends StatelessWidget { + const _GradientButton({ + required this.icon, + required this.label, + required this.onPressed, + }); + + final IconData icon; + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [EvolioColors.cyan, EvolioColors.lime], + ), + borderRadius: BorderRadius.circular(12), + ), + child: FilledButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 19), + label: Text( + label.toUpperCase(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w800, + letterSpacing: 1.1, + ), + ), + style: FilledButton.styleFrom( + foregroundColor: const Color(0xFF09201E), + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + minimumSize: const Size(190, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text.toUpperCase(), + style: Theme.of(context).textTheme.titleMedium, + ); + } +} + +class _CapsLabel extends StatelessWidget { + const _CapsLabel(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text.toUpperCase(), + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall, + ); + } +} + +class _AxisLabel extends StatelessWidget { + const _AxisLabel(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text, + style: const TextStyle(color: EvolioColors.muted, fontSize: 10), + ); + } +} + +class _SilhouettePainter extends CustomPainter { + const _SilhouettePainter(); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final glow = Paint() + ..shader = + RadialGradient( + colors: [ + EvolioColors.cyan.withValues(alpha: 0.18), + EvolioColors.surface.withValues(alpha: 0), + ], + ).createShader( + Rect.fromCircle(center: center, radius: size.shortestSide * 0.46), + ); + canvas.drawCircle(center, size.shortestSide * 0.46, glow); + + final grid = Paint() + ..color = Colors.white.withValues(alpha: 0.035) + ..strokeWidth = 1; + for (double x = 18; x < size.width; x += 25) { + canvas.drawLine(Offset(x, 0), Offset(x, size.height), grid); + } + for (double y = 18; y < size.height; y += 25) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), grid); + } + + final figureHeight = math.min(size.height - 25, 275.0); + final top = (size.height - figureHeight) / 2; + final x = size.width / 2; + final outline = Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [EvolioColors.cyan, EvolioColors.lime], + ).createShader(Rect.fromLTWH(0, top, size.width, figureHeight)) + ..style = PaintingStyle.stroke + ..strokeWidth = 2.2; + final fill = Paint() + ..color = EvolioColors.cyan.withValues(alpha: 0.07) + ..style = PaintingStyle.fill; + + final body = Path() + ..addOval(Rect.fromCircle(center: Offset(x, top + 28), radius: 22)) + ..moveTo(x - 18, top + 52) + ..cubicTo(x - 48, top + 70, x - 47, top + 104, x - 38, top + 133) + ..cubicTo(x - 34, top + 153, x - 42, top + 187, x - 34, top + 224) + ..lineTo(x - 13, top + 224) + ..cubicTo(x - 8, top + 193, x - 7, top + 166, x, top + 151) + ..cubicTo(x + 7, top + 166, x + 8, top + 193, x + 13, top + 224) + ..lineTo(x + 34, top + 224) + ..cubicTo(x + 42, top + 187, x + 34, top + 153, x + 38, top + 133) + ..cubicTo(x + 47, top + 104, x + 48, top + 70, x + 18, top + 52) + ..close(); + canvas.drawPath(body, fill); + canvas.drawPath(body, outline); + + for (final markerY in [top + 87, top + 121, top + 150]) { + canvas.drawCircle( + Offset(x + 39, markerY), + 3, + Paint()..color = EvolioColors.magenta, + ); + canvas.drawLine( + Offset(x + 39, markerY), + Offset(x + 72, markerY), + Paint() + ..color = EvolioColors.magenta.withValues(alpha: 0.5) + ..strokeWidth = 1, + ); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +class _TrendPainter extends CustomPainter { + const _TrendPainter({required this.period}); + + final int period; + + @override + void paint(Canvas canvas, Size size) { + final chartHeight = size.height - 28; + final gridPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.05) + ..strokeWidth = 1; + for (var i = 0; i < 5; i++) { + final y = chartHeight * i / 4; + canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint); + } + + final phase = period * 0.35; + final line = Path(); + for (var i = 0; i <= 80; i++) { + final progress = i / 80; + final wave = math.sin(progress * math.pi * 3 + phase) * 9; + final y = chartHeight * (0.72 - progress * 0.32) + wave; + if (i == 0) { + line.moveTo(size.width * progress, y); + } else { + line.lineTo(size.width * progress, y); + } + } + final area = Path.from(line) + ..lineTo(size.width, chartHeight) + ..lineTo(0, chartHeight) + ..close(); + canvas.drawPath( + area, + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + EvolioColors.magenta.withValues(alpha: 0.3), + EvolioColors.magenta.withValues(alpha: 0), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, chartHeight)), + ); + canvas.drawPath( + line, + Paint() + ..color = EvolioColors.magenta + ..style = PaintingStyle.stroke + ..strokeWidth = 2.2 + ..strokeCap = StrokeCap.round, + ); + } + + @override + bool shouldRepaint(covariant _TrendPainter oldDelegate) => + oldDelegate.period != period; +} diff --git a/web/pubspec.lock b/web/pubspec.lock new file mode 100644 index 0000000..a71dc29 --- /dev/null +++ b/web/pubspec.lock @@ -0,0 +1,218 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" +sdks: + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/web/web/icons/Icon-256.png b/web/web/icons/Icon-256.png new file mode 100644 index 0000000..30f138b Binary files /dev/null and b/web/web/icons/Icon-256.png differ diff --git a/web/web/icons/Icon-512.png b/web/web/icons/Icon-512.png new file mode 100644 index 0000000..db6e5b5 Binary files /dev/null and b/web/web/icons/Icon-512.png differ