From 75f48007c3f231a7eb1edc7409f6a47015ebe50f Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Thu, 11 Jun 2026 14:21:12 +0700 Subject: [PATCH 01/57] feat(mobile): Sign in with Apple App Store Guideline 4.8 requires Sign in with Apple wherever a third-party social login (Google) is offered. Adds the native credential flow (sign_in_with_apple + hashed-nonce verification through supabase.auth.signInWithIdToken), the official SignInWithAppleButton placed above Google on iOS/macOS, and the com.apple.developer.applesignin entitlement wired into all Runner build configs. Enabling the capability in the Apple Developer account and the Apple provider in the Supabase dashboard remain account-side steps. Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 2 + apps/mobile-flutter/assets/l10n/vi.json | 2 + .../ios/Runner.xcodeproj/project.pbxproj | 3 + .../ios/Runner/Runner.entitlements | 10 ++ .../auth/providers/auth_form_controller.dart | 104 +++++++++++++++--- .../features/auth/widgets/apple_button.dart | 38 +++++++ .../lib/features/auth/widgets/auth_page.dart | 77 ++++++++----- apps/mobile-flutter/pubspec.lock | 26 ++++- apps/mobile-flutter/pubspec.yaml | 2 + 9 files changed, 219 insertions(+), 45 deletions(-) create mode 100644 apps/mobile-flutter/ios/Runner/Runner.entitlements create mode 100644 apps/mobile-flutter/lib/features/auth/widgets/apple_button.dart diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index 41a44ba4..5ab22a61 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -677,6 +677,8 @@ "signUpSubtitle": "Start tracking Vietnamese meals with AI-powered nutrition analysis.", "signInTab": "Sign in", "signUpTab": "Sign up", + "continueWithApple": "Continue with Apple", + "appleError": "Apple sign-in failed. Please try again.", "continueWithGoogle": "Continue with Google", "orContinueWithEmail": "or continue with email", "googleError": "Google sign-in failed. Please try again." diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index 01ce1076..f9abd813 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -676,6 +676,8 @@ "signUpSubtitle": "Bắt đầu theo dõi bữa ăn Việt với phân tích dinh dưỡng bằng AI.", "signInTab": "Đăng nhập", "signUpTab": "Đăng ký", + "continueWithApple": "Tiếp tục với Apple", + "appleError": "Đăng nhập bằng Apple thất bại. Vui lòng thử lại.", "continueWithGoogle": "Tiếp tục với Google", "orContinueWithEmail": "hoặc tiếp tục với email", "googleError": "Đăng nhập bằng Google thất bại. Vui lòng thử lại." diff --git a/apps/mobile-flutter/ios/Runner.xcodeproj/project.pbxproj b/apps/mobile-flutter/ios/Runner.xcodeproj/project.pbxproj index d818373d..f69dc6a5 100644 --- a/apps/mobile-flutter/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/mobile-flutter/ios/Runner.xcodeproj/project.pbxproj @@ -491,6 +491,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.khoivo.nham; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -673,6 +674,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.khoivo.nham; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -695,6 +697,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.khoivo.nham; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; diff --git a/apps/mobile-flutter/ios/Runner/Runner.entitlements b/apps/mobile-flutter/ios/Runner/Runner.entitlements new file mode 100644 index 00000000..a812db50 --- /dev/null +++ b/apps/mobile-flutter/ios/Runner/Runner.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.applesignin + + Default + + + diff --git a/apps/mobile-flutter/lib/features/auth/providers/auth_form_controller.dart b/apps/mobile-flutter/lib/features/auth/providers/auth_form_controller.dart index 8567226e..37b78731 100644 --- a/apps/mobile-flutter/lib/features/auth/providers/auth_form_controller.dart +++ b/apps/mobile-flutter/lib/features/auth/providers/auth_form_controller.dart @@ -1,6 +1,11 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sign_in_with_apple/sign_in_with_apple.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../../../data/session_provider.dart'; @@ -13,7 +18,7 @@ const String kAuthRedirect = 'nham://auth-callback'; /// Which auth action is currently in flight. Lets the UI spin only the button /// that was tapped while still disabling the others — replacing the old single /// `busy` bool that made one tap spin every control. -enum AuthAction { email, google } +enum AuthAction { email, google, apple } /// Immutable view-state for an auth screen, mirroring the RN screens' /// `busy` / `error` / `notice` `useState` triplet — except `busy` is now an @@ -40,6 +45,9 @@ class AuthFormState { /// Whether Google is the in-flight action (drives the Google button spinner). bool get googleBusy => action == AuthAction.google; + /// Whether Apple is the in-flight action (drives the Apple button state). + bool get appleBusy => action == AuthAction.apple; + AuthFormState copyWith({ AuthAction? action, String? error, @@ -73,10 +81,9 @@ class AuthFormController extends StateNotifier { }) async { state = state.copyWith(action: AuthAction.email, clearError: true); try { - await _ref.read(authControllerProvider).signInWithPassword( - email: email.trim(), - password: password, - ); + await _ref + .read(authControllerProvider) + .signInWithPassword(email: email.trim(), password: password); // Success: the sessionProvider stream fires and the router redirect // routes into the app (RN did `router.replace('/logging')`). state = state.copyWith(clearAction: true); @@ -89,20 +96,16 @@ class AuthFormController extends StateNotifier { /// RN `signUp`: trims email, calls `signUp`. If a session comes back the /// router routes in; otherwise show the "check your email" notice. - Future signUp({ - required String email, - required String password, - }) async { + Future signUp({required String email, required String password}) async { state = state.copyWith( action: AuthAction.email, clearError: true, clearNotice: true, ); try { - final res = await _ref.read(authControllerProvider).signUpWithPassword( - email: email.trim(), - password: password, - ); + final res = await _ref + .read(authControllerProvider) + .signUpWithPassword(email: email.trim(), password: password); if (res.session != null) { // Signed in immediately — router redirect takes over. state = state.copyWith(clearAction: true); @@ -152,6 +155,73 @@ class AuthFormController extends StateNotifier { } } + /// Native Sign in with Apple. Required by App Store Guideline 4.8 whenever a + /// third-party social login (Google) is offered. Uses the native credential + /// sheet, then hands Supabase the identity token + raw nonce so it verifies + /// the Apple-signed hashed nonce embedded in the token. + Future signInWithApple() async { + state = state.copyWith( + action: AuthAction.apple, + clearError: true, + clearNotice: true, + ); + try { + final rawNonce = _generateRawNonce(); + final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString(); + final credential = await SignInWithApple.getAppleIDCredential( + scopes: const [ + AppleIDAuthorizationScopes.email, + AppleIDAuthorizationScopes.fullName, + ], + nonce: hashedNonce, + ); + final idToken = credential.identityToken; + if (idToken == null) { + state = state.copyWith( + clearAction: true, + error: tr('auth.dialog.appleError'), + ); + return; + } + await _auth.signInWithIdToken( + provider: OAuthProvider.apple, + idToken: idToken, + nonce: rawNonce, + ); + // Success: onAuthStateChange fires and the router redirect routes in. + state = state.copyWith(clearAction: true); + } on SignInWithAppleAuthorizationException catch (e) { + // User cancelled the sheet → no error surfaced, just clear the spinner. + if (e.code == AuthorizationErrorCode.canceled) { + state = state.copyWith(clearAction: true); + return; + } + state = state.copyWith( + clearAction: true, + error: tr('auth.dialog.appleError'), + ); + } on AuthException catch (e) { + state = state.copyWith(clearAction: true, error: e.message); + } catch (_) { + state = state.copyWith( + clearAction: true, + error: tr('auth.dialog.appleError'), + ); + } + } + + /// A cryptographically-random nonce. Its SHA-256 is sent to Apple; the raw + /// value is sent to Supabase, which checks the two match to prevent replay. + String _generateRawNonce([int length = 32]) { + const charset = + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._'; + final random = Random.secure(); + return List.generate( + length, + (_) => charset[random.nextInt(charset.length)], + ).join(); + } + /// Clear any surfaced error/notice (e.g. when the user edits a field). void clearMessages() { if (state.error != null || state.notice != null) { @@ -163,12 +233,12 @@ class AuthFormController extends StateNotifier { /// Sign-in screen controller. final signInControllerProvider = StateNotifierProvider.autoDispose( - AuthFormController.new, -); + AuthFormController.new, + ); /// Sign-up screen controller (separate instance so its `notice` state is /// independent of sign-in). final signUpControllerProvider = StateNotifierProvider.autoDispose( - AuthFormController.new, -); + AuthFormController.new, + ); diff --git a/apps/mobile-flutter/lib/features/auth/widgets/apple_button.dart b/apps/mobile-flutter/lib/features/auth/widgets/apple_button.dart new file mode 100644 index 00000000..81737777 --- /dev/null +++ b/apps/mobile-flutter/lib/features/auth/widgets/apple_button.dart @@ -0,0 +1,38 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:sign_in_with_apple/sign_in_with_apple.dart'; + +import '../../../theme/nham_theme.dart'; + +/// "Continue with Apple" — the official Apple-styled button. +/// +/// Apple's Human Interface Guidelines require the system-provided button (or a +/// close, compliant equivalent) for Sign in with Apple, so we use +/// [SignInWithAppleButton] rather than a hand-rolled one. The black style sits +/// well on the cream surface; placed above Google it is the most prominent +/// social option, satisfying App Store Guideline 4.8. The brand radius keeps it +/// visually consistent with the Google button beneath it. +class AppleButton extends StatelessWidget { + const AppleButton({super.key, required this.onPressed, required this.busy}); + + final VoidCallback onPressed; + + /// Any auth request is in flight — dims + blocks the button. + final bool busy; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: busy ? 0.6 : 1.0, + child: IgnorePointer( + ignoring: busy, + child: SignInWithAppleButton( + onPressed: onPressed, + text: tr('auth.dialog.continueWithApple'), + height: 48, + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + ), + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/auth/widgets/auth_page.dart b/apps/mobile-flutter/lib/features/auth/widgets/auth_page.dart index 2841b5bb..627535e2 100644 --- a/apps/mobile-flutter/lib/features/auth/widgets/auth_page.dart +++ b/apps/mobile-flutter/lib/features/auth/widgets/auth_page.dart @@ -1,4 +1,5 @@ import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -6,6 +7,7 @@ import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; import '../../../theme/nham_typography.dart'; import '../providers/auth_form_controller.dart'; +import 'apple_button.dart'; import 'auth_divider.dart'; import 'google_button.dart'; import 'sign_in_form.dart'; @@ -52,8 +54,9 @@ class _AuthPageState extends ConsumerState { ), content: Text( message, - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) - .copyWith(color: NhamColors.text), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), ), ), ); @@ -78,7 +81,7 @@ class _AuthPageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _header(), - _googleBlock(state, ref.read(provider.notifier).signInWithGoogle), + _socialBlock(state, ref.read(provider.notifier)), _formBlock(state), ], ), @@ -100,8 +103,9 @@ class _AuthPageState extends ConsumerState { : tr('auth.dialog.signUpTitle'), textAlign: TextAlign.center, // Lora w400, 24px (text-2xl), #2C2416. - style: NhamTextStyles.serifRegular(fontSize: NhamFontSize.h3) - .copyWith(color: NhamColors.text), + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h3, + ).copyWith(color: NhamColors.text), ), const SizedBox(height: 4), // mb-1 Text( @@ -110,8 +114,9 @@ class _AuthPageState extends ConsumerState { : tr('auth.dialog.signUpSubtitle'), textAlign: TextAlign.center, // text-sm #8B7355 DM Sans. - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm) - .copyWith(color: NhamColors.textMuted), + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted), ), ], ), @@ -119,15 +124,27 @@ class _AuthPageState extends ConsumerState { } // space-y-3, pt-4. - Widget _googleBlock(AuthFormState state, VoidCallback onGoogle) { + Widget _socialBlock(AuthFormState state, AuthFormController controller) { + // Sign in with Apple is iOS/macOS-only (and an App Store requirement + // there); placed above Google as the most prominent social option. + final showApple = + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS; return Padding( padding: const EdgeInsets.only(top: 16), child: Column( children: [ + if (showApple) ...[ + AppleButton( + busy: state.busy, + onPressed: controller.signInWithApple, + ), + const SizedBox(height: 12), // space-y-3 + ], GoogleButton( busy: state.busy, loading: state.googleBusy, - onPressed: onGoogle, + onPressed: controller.signInWithGoogle, ), const SizedBox(height: 12), // space-y-3 const AuthDivider(), @@ -156,21 +173,26 @@ class _AuthPageState extends ConsumerState { opacity: animation, child: AnimatedBuilder( animation: animation, - builder: (context, c) => Transform.translate( - offset: Offset(beginDx * (1 - animation.value), 0), - child: c, - ), + builder: + (context, c) => Transform.translate( + offset: Offset(beginDx * (1 - animation.value), 0), + child: c, + ), child: child, ), ); }, - child: _signIn - ? SignInForm(key: const ValueKey('sign-in'), onError: _toast) - : SignUpForm( - key: const ValueKey('sign-up'), - onError: _toast, - onNotice: _toast, - ), + child: + _signIn + ? SignInForm( + key: const ValueKey('sign-in'), + onError: _toast, + ) + : SignUpForm( + key: const ValueKey('sign-up'), + onError: _toast, + onNotice: _toast, + ), ), const SizedBox(height: 20), // mt-5 _footer(state), @@ -180,9 +202,8 @@ class _AuthPageState extends ConsumerState { } Widget _footer(AuthFormState state) { - final prompt = _signIn - ? tr('auth.signIn.noAccount') - : tr('auth.signUp.hasAccount'); + final prompt = + _signIn ? tr('auth.signIn.noAccount') : tr('auth.signUp.hasAccount'); final action = _signIn ? tr('auth.signIn.signUpLink') : tr('auth.signUp.signInLink'); return Row( @@ -190,8 +211,9 @@ class _AuthPageState extends ConsumerState { children: [ Text( '$prompt ', - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm) - .copyWith(color: NhamColors.textMuted), + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted), ), // Inert while a request is in flight — same guard as the tab toggle. Opacity( @@ -234,8 +256,9 @@ class _FooterLinkState extends State<_FooterLink> { onTap: widget.onTap, child: AnimatedDefaultTextStyle( duration: const Duration(milliseconds: 200), // transition-colors - style: NhamTextStyles.sansSemiBold(fontSize: NhamFontSize.sm) - .copyWith(color: _pressed ? _hover : NhamColors.accent), + style: NhamTextStyles.sansSemiBold( + fontSize: NhamFontSize.sm, + ).copyWith(color: _pressed ? _hover : NhamColors.accent), child: Text(widget.label), ), ); diff --git a/apps/mobile-flutter/pubspec.lock b/apps/mobile-flutter/pubspec.lock index 74acba9c..f96ec2e9 100644 --- a/apps/mobile-flutter/pubspec.lock +++ b/apps/mobile-flutter/pubspec.lock @@ -98,7 +98,7 @@ packages: source: hosted version: "3.1.2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -677,6 +677,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + sign_in_with_apple: + dependency: "direct main" + description: + name: sign_in_with_apple + sha256: e84a62e17b7e463abf0a64ce826c2cd1f0b72dff07b7b275e32d5302d76fb4c5 + url: "https://pub.dev" + source: hosted + version: "6.1.4" + sign_in_with_apple_platform_interface: + dependency: transitive + description: + name: sign_in_with_apple_platform_interface + sha256: c2ef2ce6273fce0c61acd7e9ff5be7181e33d7aa2b66508b39418b786cca2119 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + sign_in_with_apple_web: + dependency: transitive + description: + name: sign_in_with_apple_web + sha256: "2f7c38368f49e3f2043bca4b46a4a61aaae568c140a79aa0675dc59ad0ca49bc" + url: "https://pub.dev" + source: hosted + version: "2.1.1" sky_engine: dependency: transitive description: flutter diff --git a/apps/mobile-flutter/pubspec.yaml b/apps/mobile-flutter/pubspec.yaml index 08ab8fdd..ce0342f9 100644 --- a/apps/mobile-flutter/pubspec.yaml +++ b/apps/mobile-flutter/pubspec.yaml @@ -26,6 +26,8 @@ dependencies: lucide_icons_flutter: ^3.1.14 intl: ^0.20.2 uuid: ^4.5.1 + sign_in_with_apple: ^6.1.4 + crypto: ^3.0.6 dev_dependencies: flutter_test: From 377fb2162b34715b3c1cb4d928ecd46c817a7dbf Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Thu, 11 Jun 2026 14:26:08 +0700 Subject: [PATCH 02/57] =?UTF-8?q?feat(mobile):=20account=20section=20?= =?UTF-8?q?=E2=80=94=20export,=20sign=20out,=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the launch-blocking account layer to Settings. Account deletion (App Store Guideline 5.1.1(v)) pushes a type-to-confirm screen in the brand's calm terracotta register and calls DELETE /api/v1/account, then signs out. Sign out gains a Cupertino confirmation sheet (previously a one-tap drawer action), and 'Export my data' fetches GET /api/v1/account and shares the JSON via the system share sheet. New api_client.deleteAccount()/exportMyData() helpers. Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 15 + apps/mobile-flutter/assets/l10n/vi.json | 15 + apps/mobile-flutter/lib/data/api_client.dart | 40 +- .../settings/screens/account_section.dart | 439 ++++++++++++++++++ .../settings/screens/settings_screen.dart | 121 +++-- apps/mobile-flutter/pubspec.lock | 26 +- apps/mobile-flutter/pubspec.yaml | 2 + 7 files changed, 593 insertions(+), 65 deletions(-) create mode 100644 apps/mobile-flutter/lib/features/settings/screens/account_section.dart diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index 5ab22a61..e8efc0fe 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -1106,6 +1106,21 @@ }, "settings": { "title": "Settings", + "account": { + "title": "Account", + "exportTitle": "Export my data", + "exportError": "Could not export your data. Please try again.", + "signOut": "Sign out", + "signOutConfirmTitle": "Sign out of Nhẩm?", + "cancel": "Cancel", + "delete": "Delete account", + "deleteScreenTitle": "Delete account", + "deleteConsequence": "This removes your profile, every meal you've logged, and your weight history. There is no undo.", + "deleteConfirmLabel": "Type {word} to confirm", + "deleteConfirmWord": "DELETE", + "deleteConfirmAction": "Delete my account", + "deleteError": "Could not delete your account. Please try again." + }, "profile": "Profile", "preferences": "Preferences", "bodyMetrics": "Body Metrics", diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index f9abd813..85d44a84 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -1105,6 +1105,21 @@ }, "settings": { "title": "Cài đặt", + "account": { + "title": "Tài khoản", + "exportTitle": "Xuất dữ liệu của tôi", + "exportError": "Không thể xuất dữ liệu. Vui lòng thử lại.", + "signOut": "Đăng xuất", + "signOutConfirmTitle": "Đăng xuất khỏi Nhẩm?", + "cancel": "Hủy", + "delete": "Xóa tài khoản", + "deleteScreenTitle": "Xóa tài khoản", + "deleteConsequence": "Thao tác này xóa hồ sơ, mọi bữa ăn bạn đã ghi, và lịch sử cân nặng của bạn. Không thể hoàn tác.", + "deleteConfirmLabel": "Nhập {word} để xác nhận", + "deleteConfirmWord": "XÓA", + "deleteConfirmAction": "Xóa tài khoản của tôi", + "deleteError": "Không thể xóa tài khoản. Vui lòng thử lại." + }, "profile": "Hồ sơ", "preferences": "Tùy chỉnh", "bodyMetrics": "Chỉ số cơ thể", diff --git a/apps/mobile-flutter/lib/data/api_client.dart b/apps/mobile-flutter/lib/data/api_client.dart index eaa63aa7..b2bb9d26 100644 --- a/apps/mobile-flutter/lib/data/api_client.dart +++ b/apps/mobile-flutter/lib/data/api_client.dart @@ -40,7 +40,8 @@ class ApiError implements Exception { ]); @override - String toString() => 'ApiError($code, $status, retryable=$retryable): $message'; + String toString() => + 'ApiError($code, $status, retryable=$retryable): $message'; } /// Parse a `Retry-After` header: numeric seconds first, else HTTP-date delta. @@ -96,17 +97,17 @@ class StreamAnalyzeInput { }); Map toJson() => { - 'message': message, - 'loggedDate': loggedDate, - 'timezoneOffset': timezoneOffset, - if (locale != null) 'locale': locale, - }; + 'message': message, + 'loggedDate': loggedDate, + 'timezoneOffset': timezoneOffset, + if (locale != null) 'locale': locale, + }; } class ApiClient { ApiClient({http.Client? httpClient}) - : _http = httpClient ?? http.Client(), - _baseUrl = Env.apiBaseUrl; + : _http = httpClient ?? http.Client(), + _baseUrl = Env.apiBaseUrl; final http.Client _http; final String _baseUrl; @@ -118,11 +119,7 @@ class ApiClient { return token != null ? {'Authorization': 'Bearer $token'} : {}; } - Future _request( - String method, - String path, [ - Object? body, - ]) async { + Future _request(String method, String path, [Object? body]) async { final headers = await _authHeaders(); if (body != null) { headers['Content-Type'] = 'application/json'; @@ -158,6 +155,16 @@ class ApiClient { /// DELETE. Future delete(String path) => _request('DELETE', path); + /// Permanently delete the signed-in user's account and all their data + /// (`DELETE /api/v1/account`). The server removes the Supabase auth user, + /// which cascades to every app row. There is no undo. + Future deleteAccount() => delete('/api/v1/account'); + + /// Fetch a complete JSON snapshot of the user's data + /// (`GET /api/v1/account`): profile, meals (with items), and weights. + Future> exportMyData() => + get>('/api/v1/account'); + /// Fire-and-forget ping to wake a scale-to-zero backend on launch. Failures /// are swallowed (offline / unreachable is fine). Mirrors `warmupApi()`. Future warmup() async { @@ -182,9 +189,10 @@ class ApiClient { headers['Content-Type'] = 'application/json'; headers['Accept'] = 'text/event-stream'; - final req = http.Request('POST', Uri.parse('$_baseUrl/api/analyze-meal')) - ..headers.addAll(headers) - ..body = jsonEncode(input.toJson()); + final req = + http.Request('POST', Uri.parse('$_baseUrl/api/analyze-meal')) + ..headers.addAll(headers) + ..body = jsonEncode(input.toJson()); http.StreamedResponse streamed; try { diff --git a/apps/mobile-flutter/lib/features/settings/screens/account_section.dart b/apps/mobile-flutter/lib/features/settings/screens/account_section.dart new file mode 100644 index 00000000..bdfe5a14 --- /dev/null +++ b/apps/mobile-flutter/lib/features/settings/screens/account_section.dart @@ -0,0 +1,439 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../../data/api_client.dart'; +import '../../../data/session_provider.dart'; +import '../../../shared/widgets/nham_primitives.dart'; +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_theme.dart'; +import '../../../theme/nham_typography.dart'; + +/// Account section of the settings list: export data, sign out (with a +/// confirmation sheet), and permanent account deletion. Account deletion is an +/// App Store requirement whenever the app offers account creation. +class AccountSection extends ConsumerStatefulWidget { + const AccountSection({super.key}); + + @override + ConsumerState createState() => _AccountSectionState(); +} + +class _AccountSectionState extends ConsumerState { + bool _exporting = false; + bool _signingOut = false; + + Future _export() async { + if (_exporting) return; + setState(() => _exporting = true); + try { + final data = await ref.read(apiClientProvider).exportMyData(); + final pretty = const JsonEncoder.withIndent(' ').convert(data); + final dir = await getTemporaryDirectory(); + final stamp = DateTime.now().toIso8601String().split('T').first; + final file = File('${dir.path}/nham-data-$stamp.json'); + await file.writeAsString(pretty); + await Share.shareXFiles([XFile(file.path)]); + } catch (_) { + _toast(tr('settings.account.exportError')); + } finally { + if (mounted) setState(() => _exporting = false); + } + } + + Future _confirmSignOut() async { + final confirmed = await showCupertinoModalPopup( + context: context, + builder: + (sheetContext) => CupertinoActionSheet( + title: Text(tr('settings.account.signOutConfirmTitle')), + actions: [ + CupertinoActionSheetAction( + isDestructiveAction: true, + onPressed: () => Navigator.of(sheetContext).pop(true), + child: Text(tr('settings.account.signOut')), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.of(sheetContext).pop(false), + child: Text(tr('settings.account.cancel')), + ), + ), + ); + if (confirmed != true || _signingOut) return; + setState(() => _signingOut = true); + try { + await ref.read(authControllerProvider).signOut(); + if (!mounted) return; + context.go('/sign-in'); + } catch (_) { + if (!mounted) return; + setState(() => _signingOut = false); + _toast(tr('app.userMenu.signOutError')); + } + } + + void _openDelete() { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const _AccountDeleteScreen()), + ); + } + + void _toast(String message) { + ScaffoldMessenger.maybeOf( + context, + )?.showSnackBar(SnackBar(content: Text(message))); + } + + @override + Widget build(BuildContext context) { + final busy = _exporting || _signingOut; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(left: NhamSpacing.sp3, bottom: 4), + child: Text( + tr('settings.account.title').toUpperCase(), + style: NhamTextStyles.sansBold( + fontSize: 10, + ).copyWith(letterSpacing: 2.0, color: NhamColors.textMuted), + ), + ), + _AccountRow( + icon: LucideIcons.download, + label: tr('settings.account.exportTitle'), + busy: _exporting, + enabled: !busy, + onTap: _export, + ), + _AccountRow( + icon: LucideIcons.logOut, + label: tr('settings.account.signOut'), + enabled: !busy, + onTap: _confirmSignOut, + ), + _AccountRow( + icon: LucideIcons.trash2, + label: tr('settings.account.delete'), + danger: true, + enabled: !busy, + onTap: _openDelete, + ), + ], + ); + } +} + +class _AccountRow extends StatefulWidget { + const _AccountRow({ + required this.icon, + required this.label, + required this.onTap, + this.danger = false, + this.busy = false, + this.enabled = true, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool danger; + final bool busy; + final bool enabled; + + @override + State<_AccountRow> createState() => _AccountRowState(); +} + +class _AccountRowState extends State<_AccountRow> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final color = widget.danger ? NhamColors.danger : NhamColors.textMuted; + final pressedColor = widget.danger ? NhamColors.danger : NhamColors.text; + final fill = + widget.danger + ? const Color(0x1AD37B69) // danger @ 10% + : NhamColors.hover50; + + return Opacity( + opacity: widget.enabled ? 1.0 : 0.6, + child: GestureDetector( + onTap: widget.enabled ? widget.onTap : null, + onTapDown: + widget.enabled ? (_) => setState(() => _pressed = true) : null, + onTapUp: + widget.enabled ? (_) => setState(() => _pressed = false) : null, + onTapCancel: + widget.enabled ? () => setState(() => _pressed = false) : null, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: 10, + ), + decoration: BoxDecoration( + color: _pressed ? fill : Colors.transparent, + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + ), + child: Row( + children: [ + Icon( + widget.icon, + size: 16, + color: _pressed ? pressedColor : color, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + widget.label, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: _pressed ? pressedColor : color), + ), + ), + if (widget.busy) + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: NhamColors.textMuted, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Pushed delete-account screen: plain-language consequences and a type-to- +/// confirm gate before the irreversible deletion. +class _AccountDeleteScreen extends ConsumerStatefulWidget { + const _AccountDeleteScreen(); + + @override + ConsumerState<_AccountDeleteScreen> createState() => + _AccountDeleteScreenState(); +} + +class _AccountDeleteScreenState extends ConsumerState<_AccountDeleteScreen> { + final _controller = TextEditingController(); + bool _deleting = false; + + @override + void initState() { + super.initState(); + _controller.addListener(() => setState(() {})); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + bool get _canDelete => + _controller.text.trim() == tr('settings.account.deleteConfirmWord') && + !_deleting; + + Future _delete() async { + if (!_canDelete) return; + setState(() => _deleting = true); + try { + await ref.read(apiClientProvider).deleteAccount(); + // The account (and all data) is gone; clear the local session and leave. + await ref.read(authControllerProvider).signOut(); + if (!mounted) return; + context.go('/sign-in'); + } catch (_) { + if (!mounted) return; + setState(() => _deleting = false); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar(content: Text(tr('settings.account.deleteError'))), + ); + } + } + + @override + Widget build(BuildContext context) { + return Screen( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _DeleteBackHeader(onBack: () => Navigator.of(context).maybePop()), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + NhamSpacing.sp5, + NhamSpacing.sp4, + NhamSpacing.sp5, + NhamSpacing.sp6, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('settings.account.deleteScreenTitle'), + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h3, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: NhamSpacing.sp3), + Text( + tr('settings.account.deleteConsequence'), + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text, height: 1.5), + ), + const SizedBox(height: NhamSpacing.sp5), + Text( + tr( + 'settings.account.deleteConfirmLabel', + namedArgs: { + 'word': tr('settings.account.deleteConfirmWord'), + }, + ), + style: NhamTextStyles.sansRegular( + fontSize: 12, + ).copyWith(color: NhamColors.textMuted), + ), + const SizedBox(height: 6), + TextField( + controller: _controller, + autocorrect: false, + enableSuggestions: false, + textCapitalization: TextCapitalization.characters, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), + decoration: InputDecoration( + filled: true, + fillColor: NhamColors.surface, + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + borderSide: const BorderSide(color: NhamColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + borderSide: const BorderSide(color: NhamColors.danger), + ), + ), + ), + const SizedBox(height: NhamSpacing.sp4), + _DeleteButton( + enabled: _canDelete, + deleting: _deleting, + onTap: _delete, + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _DeleteButton extends StatelessWidget { + const _DeleteButton({ + required this.enabled, + required this.deleting, + required this.onTap, + }); + + final bool enabled; + final bool deleting; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: enabled ? 1.0 : 0.4, + child: GestureDetector( + onTap: enabled ? onTap : null, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: NhamColors.danger, + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + ), + child: Center( + child: + deleting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text( + tr('settings.account.deleteConfirmAction'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: Colors.white), + ), + ), + ), + ), + ); + } +} + +class _DeleteBackHeader extends StatelessWidget { + const _DeleteBackHeader({required this.onBack}); + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const BoxDecoration( + color: NhamColors.surface, + border: Border(bottom: BorderSide(color: NhamColors.border)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onBack, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + LucideIcons.arrowLeft, + size: 16, + color: NhamColors.textMuted, + ), + const SizedBox(width: 6), + Text( + tr('settings.title'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart b/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart index c8007e9d..d1ab68f0 100644 --- a/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart +++ b/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart @@ -13,6 +13,7 @@ import '../../../theme/nham_theme.dart'; import '../../../theme/nham_typography.dart'; import '../data/profile_providers.dart'; import '../widgets/profile_form.dart'; +import 'account_section.dart'; /// Settings tab — two-level nav (list → profile drill-in), mirroring the RN /// expo-router stack inside the settings tab. A nested [Navigator] owns the @@ -23,10 +24,11 @@ class SettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Navigator( - onGenerateRoute: (settings) => MaterialPageRoute( - settings: settings, - builder: (_) => const _SettingsList(), - ), + onGenerateRoute: + (settings) => MaterialPageRoute( + settings: settings, + builder: (_) => const _SettingsList(), + ), ); } } @@ -62,19 +64,24 @@ class _SettingsList extends StatelessWidget { // Web sidebar h2: text-lg (18) font-medium tracking-tight, // Lora. tr('settings.title'), - style: NhamTextStyles.serifMedium(fontSize: NhamFontSize.lg) - .copyWith( - letterSpacing: NhamTracking.tight, - color: NhamColors.text), + style: NhamTextStyles.serifMedium( + fontSize: NhamFontSize.lg, + ).copyWith( + letterSpacing: NhamTracking.tight, + color: NhamColors.text, + ), ), const SizedBox(height: NhamSpacing.sp4), _ProfileRowTile( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const _ProfileScreen(), - ), - ), + onTap: + () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const _ProfileScreen(), + ), + ), ), + const SizedBox(height: NhamSpacing.sp5), + const AccountSection(), ], ), ), @@ -125,14 +132,19 @@ class _ProfileRowTileState extends State<_ProfileRowTile> { Expanded( child: Text( tr('settings.sidebar.profile'), - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) - .copyWith( + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith( color: _pressed ? NhamColors.text : NhamColors.textMuted, ), ), ), // ChevronRight inactive = text-muted/50. - const Icon(Icons.chevron_right, size: 16, color: NhamColors.textMuted50), + const Icon( + Icons.chevron_right, + size: 16, + color: NhamColors.textMuted50, + ), ], ), ), @@ -159,24 +171,32 @@ class _ProfileScreen extends ConsumerWidget { // Back header — bg-[#FDFCF8]/90 backdrop-blur-sm, border-b. const _BackHeader(), Expanded( - child: userId == null - ? _Centered( - child: Text( - tr('common.notSignedIn'), - style: NhamTextStyles.bodySmall().copyWith(color: NhamColors.text), - ), - ) - : profileAsync.when( - loading: () => const _Centered( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(NhamColors.accent), + child: + userId == null + ? _Centered( + child: Text( + tr('common.notSignedIn'), + style: NhamTextStyles.bodySmall().copyWith( + color: NhamColors.text, + ), ), + ) + : profileAsync.when( + loading: + () => const _Centered( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + NhamColors.accent, + ), + ), + ), + error: (_, __) => const _ProfileEmpty(), + data: + (profile) => + profile != null + ? ProfileForm(profile: profile) + : const _ProfileEmpty(), ), - error: (_, __) => const _ProfileEmpty(), - data: (profile) => profile != null - ? ProfileForm(profile: profile) - : const _ProfileEmpty(), - ), ), ], ), @@ -190,11 +210,11 @@ class _Centered extends StatelessWidget { @override Widget build(BuildContext context) => Center( - child: Padding( - padding: const EdgeInsets.all(NhamSpacing.sp6), - child: child, - ), - ); + child: Padding( + padding: const EdgeInsets.all(NhamSpacing.sp6), + child: child, + ), + ); } /// Empty state when no profile exists yet (onboarding never ran). @@ -211,14 +231,19 @@ class _ProfileEmpty extends StatelessWidget { children: [ Text( tr('settings.profilePage.emptyTitle'), - style: NhamTextStyles.serifMedium(fontSize: NhamFontSize.h3) - .copyWith(letterSpacing: NhamTracking.tight, color: NhamColors.text), + style: NhamTextStyles.serifMedium( + fontSize: NhamFontSize.h3, + ).copyWith( + letterSpacing: NhamTracking.tight, + color: NhamColors.text, + ), ), const SizedBox(height: NhamSpacing.sp4), Text( tr('settings.profilePage.emptyDescription'), - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm) - .copyWith(height: 22 / 14, color: NhamColors.textWarm), + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith(height: 22 / 14, color: NhamColors.textWarm), ), const SizedBox(height: NhamSpacing.sp4), // RN routes "Start setup" to /logging (where the onboarding overlay @@ -239,8 +264,9 @@ class _ProfileEmpty extends StatelessWidget { ), child: Text( tr('settings.profilePage.startSetup'), - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) - .copyWith(color: Colors.white), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: Colors.white), ), ), ), @@ -283,9 +309,7 @@ class _BackHeaderState extends State<_BackHeader> { ), decoration: const BoxDecoration( color: Color(0xE6FDFCF8), // cream @ 90% - border: Border( - bottom: BorderSide(color: NhamColors.inputBorder), - ), + border: Border(bottom: BorderSide(color: NhamColors.inputBorder)), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -294,8 +318,9 @@ class _BackHeaderState extends State<_BackHeader> { const SizedBox(width: 6), // gap-1.5 Text( tr('settings.title'), - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) - .copyWith(color: color), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: color), ), ], ), diff --git a/apps/mobile-flutter/pubspec.lock b/apps/mobile-flutter/pubspec.lock index f96ec2e9..6d0b7fed 100644 --- a/apps/mobile-flutter/pubspec.lock +++ b/apps/mobile-flutter/pubspec.lock @@ -97,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: "direct main" description: @@ -486,7 +494,7 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -621,6 +629,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + url: "https://pub.dev" + source: hosted + version: "10.1.4" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + url: "https://pub.dev" + source: hosted + version: "5.0.2" shared_preferences: dependency: transitive description: diff --git a/apps/mobile-flutter/pubspec.yaml b/apps/mobile-flutter/pubspec.yaml index ce0342f9..ddeb9669 100644 --- a/apps/mobile-flutter/pubspec.yaml +++ b/apps/mobile-flutter/pubspec.yaml @@ -28,6 +28,8 @@ dependencies: uuid: ^4.5.1 sign_in_with_apple: ^6.1.4 crypto: ^3.0.6 + share_plus: ^10.1.4 + path_provider: ^2.1.5 dev_dependencies: flutter_test: From 3f5e255e8e0eba4a62ccc56ea9da7611ec764154 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Thu, 11 Jun 2026 14:30:11 +0700 Subject: [PATCH 03/57] feat(mobile): forgot-password recovery flow There was no password recovery on mobile at all (the forgotPassword string was translated but never rendered). Adds a 'Forgot password?' link on the sign-in form that opens a recovery screen: it sends resetPasswordForEmail with a redirect through the web /auth/callback to the web /reset-password page (reusing the web reset surface, so no in-app deep-link handling is needed), then holds on a persistent check-your-email state. Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 12 + apps/mobile-flutter/assets/l10n/vi.json | 12 + .../auth/screens/forgot_password_screen.dart | 225 ++++++++++++++++++ .../features/auth/widgets/sign_in_form.dart | 29 ++- 4 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 apps/mobile-flutter/lib/features/auth/screens/forgot_password_screen.dart diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index e8efc0fe..93387593 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -683,6 +683,18 @@ "orContinueWithEmail": "or continue with email", "googleError": "Google sign-in failed. Please try again." }, + "forgot": { + "title": "Reset your password", + "description": "Enter the email you signed up with and we'll send a reset link.", + "email": "Email", + "emailPlaceholder": "you@example.com", + "emailError": "Enter a valid email address", + "submit": "Send reset link", + "back": "Back to sign in", + "sentTitle": "Check your email", + "sentDescription": "We sent a password reset link to", + "backToSignIn": "Back to sign in" + }, "otp": { "title": "Check your email", "subtitle": "We sent a verification code to {email}", diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index 85d44a84..6c4a9aa4 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -682,6 +682,18 @@ "orContinueWithEmail": "hoặc tiếp tục với email", "googleError": "Đăng nhập bằng Google thất bại. Vui lòng thử lại." }, + "forgot": { + "title": "Đặt lại mật khẩu", + "description": "Nhập email bạn đã đăng ký, chúng tôi sẽ gửi liên kết đặt lại.", + "email": "Email", + "emailPlaceholder": "ban@example.com", + "emailError": "Nhập địa chỉ email hợp lệ", + "submit": "Gửi liên kết", + "back": "Quay lại đăng nhập", + "sentTitle": "Kiểm tra email", + "sentDescription": "Chúng tôi đã gửi liên kết đặt lại mật khẩu đến", + "backToSignIn": "Quay lại đăng nhập" + }, "otp": { "title": "Kiểm tra email", "subtitle": "Chúng tôi đã gửi mã xác nhận đến {email}", diff --git a/apps/mobile-flutter/lib/features/auth/screens/forgot_password_screen.dart b/apps/mobile-flutter/lib/features/auth/screens/forgot_password_screen.dart new file mode 100644 index 00000000..e45b753f --- /dev/null +++ b/apps/mobile-flutter/lib/features/auth/screens/forgot_password_screen.dart @@ -0,0 +1,225 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../data/env.dart'; +import '../../../services/supabase_service.dart'; +import '../../../shared/widgets/nham_primitives.dart'; +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_typography.dart'; +import '../widgets/auth_submit_button.dart'; +import '../widgets/auth_text_field.dart'; + +/// Forgot-password flow. There was no password recovery at all before this. +/// +/// Sends a reset email whose link routes through the web `/auth/callback` to the +/// web `/reset-password` page (the same flow the web app uses), then holds on a +/// persistent "check your email" state. Mobile reuses the web reset surface so +/// no in-app deep-link recovery handling is needed. +class ForgotPasswordScreen extends StatefulWidget { + const ForgotPasswordScreen({super.key}); + + @override + State createState() => _ForgotPasswordScreenState(); +} + +class _ForgotPasswordScreenState extends State { + final _email = TextEditingController(); + String? _emailError; + bool _busy = false; + bool _sent = false; + + @override + void dispose() { + _email.dispose(); + super.dispose(); + } + + Future _submit() async { + final email = _email.text.trim(); + final ok = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email); + if (!ok) { + setState(() => _emailError = tr('auth.forgot.emailError')); + return; + } + setState(() { + _emailError = null; + _busy = true; + }); + final locale = context.locale.languageCode; + final redirect = + '${Env.apiBaseUrl}/auth/callback?next=${Uri.encodeComponent('/$locale/reset-password')}'; + try { + // Anti-enumeration: Supabase returns success regardless, so we always + // advance to the "check your email" state. + await SupabaseService.client.auth.resetPasswordForEmail( + email, + redirectTo: redirect, + ); + } catch (_) { + // Swallow — still show the neutral confirmation. + } + if (!mounted) return; + setState(() { + _busy = false; + _sent = true; + }); + } + + @override + Widget build(BuildContext context) { + return Screen( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _BackHeader(onBack: () => Navigator.of(context).maybePop()), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: _sent ? _sentBody() : _formBody(), + ), + ), + ), + ], + ), + ); + } + + Widget _formBody() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('auth.forgot.title'), + textAlign: TextAlign.center, + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h3, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: 8), + Text( + tr('auth.forgot.description'), + textAlign: TextAlign.center, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted, height: 1.5), + ), + const SizedBox(height: 20), + AuthTextField( + controller: _email, + label: tr('auth.forgot.email'), + placeholder: tr('auth.forgot.emailPlaceholder'), + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.email], + enabled: !_busy, + errorText: _emailError, + onSubmitted: (_) => _submit(), + onChanged: (_) { + if (_emailError != null) setState(() => _emailError = null); + }, + ), + const SizedBox(height: 16), + AuthSubmitButton( + label: tr('auth.forgot.submit'), + busy: _busy, + loading: _busy, + onPressed: _submit, + ), + ], + ); + } + + Widget _sentBody() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + color: Color(0x26C9A87C), // accent @ 15% + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.mailCheck, + size: 20, + color: Color(0xFFA88B63), + ), + ), + ), + const SizedBox(height: 16), + Text( + tr('auth.forgot.sentTitle'), + textAlign: TextAlign.center, + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h3, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: 8), + Text( + tr('auth.forgot.sentDescription'), + textAlign: TextAlign.center, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted, height: 1.5), + ), + const SizedBox(height: 4), + Text( + _email.text.trim(), + textAlign: TextAlign.center, + style: NhamTextStyles.serifRegular( + fontSize: 16, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: 24), + AuthSubmitButton( + label: tr('auth.forgot.backToSignIn'), + busy: false, + loading: false, + onPressed: () => Navigator.of(context).maybePop(), + ), + ], + ); + } +} + +class _BackHeader extends StatelessWidget { + const _BackHeader({required this.onBack}); + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Align( + alignment: Alignment.centerLeft, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onBack, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + LucideIcons.arrowLeft, + size: 16, + color: NhamColors.textMuted, + ), + const SizedBox(width: 6), + Text( + tr('auth.forgot.back'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/auth/widgets/sign_in_form.dart b/apps/mobile-flutter/lib/features/auth/widgets/sign_in_form.dart index 471c580c..ac35a27c 100644 --- a/apps/mobile-flutter/lib/features/auth/widgets/sign_in_form.dart +++ b/apps/mobile-flutter/lib/features/auth/widgets/sign_in_form.dart @@ -2,7 +2,10 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_typography.dart'; import '../providers/auth_form_controller.dart'; +import '../screens/forgot_password_screen.dart'; import 'auth_submit_button.dart'; import 'auth_text_field.dart'; @@ -54,10 +57,7 @@ class _SignInFormState extends ConsumerState { void _submit() { if (!_validate()) return; - _controller.signInWithEmail( - email: _email.text, - password: _password.text, - ); + _controller.signInWithEmail(email: _email.text, password: _password.text); } @override @@ -106,6 +106,27 @@ class _SignInFormState extends ConsumerState { if (_passwordError != null) setState(() => _passwordError = null); }, ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: + busy + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ForgotPasswordScreen(), + ), + ), + child: Text( + tr('auth.signIn.forgotPassword'), + style: NhamTextStyles.sansRegular( + fontSize: 12, + ).copyWith(color: NhamColors.textMuted), + ), + ), + ), const SizedBox(height: 16), // space-y-4 AuthSubmitButton( label: tr('auth.signIn.submit'), From 22eb797b31907054c8adf071205f66e211fd30fe Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Thu, 11 Jun 2026 14:33:16 +0700 Subject: [PATCH 04/57] =?UTF-8?q?feat(mobile):=20branded=20cold=20start=20?= =?UTF-8?q?=E2=80=94=20cream=20launch=20screen=20+=20wordmark=20splash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first frames were a pure-white native launch (on a cream-paper brand) then a bare Material spinner. Sets the native LaunchScreen background to cream so there's no white flash, and replaces the splash spinner with the Lora 'Nhẩm' wordmark breathing gently on the cream surface (paused under reduced motion). Offline font bundling (allowRuntimeFetching=false) is deferred — it needs the Vietnamese-subset static font files and an on-device verify to avoid regressing rendering. Co-Authored-By: Claude Opus 4.8 --- .../Runner/Base.lproj/LaunchScreen.storyboard | 2 +- apps/mobile-flutter/lib/router.dart | 57 +++++++++++++++---- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/apps/mobile-flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard b/apps/mobile-flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard index f2e259c7..7b11df06 100644 --- a/apps/mobile-flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ b/apps/mobile-flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -19,7 +19,7 @@ - + diff --git a/apps/mobile-flutter/lib/router.dart b/apps/mobile-flutter/lib/router.dart index 9e7f7ca0..e3aa10e2 100644 --- a/apps/mobile-flutter/lib/router.dart +++ b/apps/mobile-flutter/lib/router.dart @@ -19,6 +19,7 @@ import 'services/supabase_service.dart'; import 'shell/placeholder_screen.dart'; import 'shell/tab_scaffold.dart'; import 'theme/nham_colors.dart'; +import 'theme/nham_typography.dart'; final _rootKey = GlobalKey(debugLabel: 'root'); final _shellKey = GlobalKey(debugLabel: 'shell'); @@ -256,23 +257,59 @@ class _GoRouterAuthRefresh extends ChangeNotifier { } } -/// Minimal cream splash shown on the index route while the redirect resolves. -/// Mirrors RN's centered [ActivityIndicator] on the surface background. -class _SplashScreen extends StatelessWidget { +/// Cream splash shown on the index route while the redirect resolves. The +/// first frame of brand: the Lora "Nhẩm" wordmark breathing gently on the cream +/// surface, instead of a generic Material spinner. The cream background matches +/// the native LaunchScreen so the native→Flutter handoff is seamless. +class _SplashScreen extends StatefulWidget { const _SplashScreen(); + @override + State<_SplashScreen> createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State<_SplashScreen> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + ); + + @override + void initState() { + super.initState(); + // Gentle breathing pulse, paused under reduced-motion. + if (!WidgetsBinding + .instance + .platformDispatcher + .accessibilityFeatures + .disableAnimations) { + _controller.repeat(reverse: true); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return const ColoredBox( + final wordmark = Text( + 'Nhẩm', + style: NhamTextStyles.serifRegular( + fontSize: 32, + ).copyWith(color: NhamColors.text), + ); + return ColoredBox( color: NhamColors.surface, child: Center( - child: SizedBox( - width: 28, - height: 28, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(NhamColors.accent), + child: FadeTransition( + opacity: Tween(begin: 0.5, end: 1.0).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), ), + child: wordmark, ), ), ); From 9236f1d3e980fe1698b6191cd7f209cc16b1b1a5 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Thu, 11 Jun 2026 23:01:08 +0700 Subject: [PATCH 05/57] feat(mobile): replace nav drawer with bottom tab bar Retire the web-ported hamburger drawer in favour of an HIG-native three-tab cream bottom bar: Today (dashboard) / Log (logging, center) / Patterns (nutrition). Hairline top border, no elevation, safe-area padding, hidden when the keyboard is open. selectionClick haptic on tab switch; instant branch swaps. PopScope returns to Today on system back from a secondary tab. Settings/account moves to a 32px avatar disc in the header's right slot (carrying the onboarding pulse-dot), pushing the Settings screen as a root CupertinoPage with swipe-back. Groups + Admin stay reachable as routes but are off the bar until built. Delete the drawer (tab_scaffold drawer) and the mobile sidebar port. Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 5 + apps/mobile-flutter/assets/l10n/vi.json | 5 + .../dashboard/screens/dashboard_screen.dart | 5 +- .../settings/screens/settings_screen.dart | 6 +- apps/mobile-flutter/lib/router.dart | 34 +- apps/mobile-flutter/lib/shell/app_header.dart | 250 +++++-- apps/mobile-flutter/lib/shell/sidebar.dart | 703 ------------------ .../lib/shell/tab_scaffold.dart | 264 ++++--- 8 files changed, 358 insertions(+), 914 deletions(-) delete mode 100644 apps/mobile-flutter/lib/shell/sidebar.dart diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index 93387593..d40f9671 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -53,6 +53,11 @@ "openMenu": "Open menu", "closeMenu": "Close menu" }, + "tabBar": { + "today": "Today", + "log": "Log", + "patterns": "Patterns" + }, "mainSidebar": { "navigationLabel": "Main navigation", "sectionLabel": "Main", diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index 6c4a9aa4..22cc505e 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -53,6 +53,11 @@ "openMenu": "Mở menu", "closeMenu": "Đóng menu" }, + "tabBar": { + "today": "Hôm nay", + "log": "Ghi món", + "patterns": "Xu hướng" + }, "mainSidebar": { "navigationLabel": "Điều hướng chính", "sectionLabel": "Chính", diff --git a/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart b/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart index 3e604cbc..af08a97d 100644 --- a/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart +++ b/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart @@ -66,7 +66,10 @@ class DashboardScreen extends ConsumerWidget { children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: NhamSpacing.sp3), - child: AppHeader(child: Text('Hello', style: dashHeadline())), + child: AppHeader( + showAvatar: true, + child: Text('Hello', style: dashHeadline()), + ), ), Expanded( child: bundle.when( diff --git a/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart b/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart index d1ab68f0..09fb51f9 100644 --- a/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart +++ b/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart @@ -45,9 +45,9 @@ class _SettingsList extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Padding( - padding: EdgeInsets.symmetric(horizontal: NhamSpacing.sp3), - child: AppHeader(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: NhamSpacing.sp3), + child: AppHeader(onBack: () => GoRouter.of(context).pop()), ), Expanded( child: Padding( diff --git a/apps/mobile-flutter/lib/router.dart b/apps/mobile-flutter/lib/router.dart index e3aa10e2..750f49c1 100644 --- a/apps/mobile-flutter/lib/router.dart +++ b/apps/mobile-flutter/lib/router.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -27,11 +27,13 @@ final _shellKey = GlobalKey(debugLabel: 'shell'); /// The app's [GoRouter], wired to Riverpod for the auth redirect. /// /// Routing model (per the port contract): -/// • A [StatefulShellRoute] hosts the primary destinations behind the shell's -/// left nav drawer (`/dashboard`, `/nutrition`, `/logging`, `/groups`, -/// `/admin`) plus `/settings` (drawer footer). Each is its own branch so -/// state/scroll persist across drawer navigations. -/// • `/sign-in`, `/sign-up`, and `/onboarding` are standalone root routes. +/// • A [StatefulShellRoute] hosts the primary destinations behind the bottom +/// tab bar (`/dashboard`, `/nutrition`, `/logging`) plus the off-bar +/// `/groups` and `/admin`. Each is its own branch so state/scroll persist +/// across tab switches. +/// • `/sign-in`, `/sign-up`, `/onboarding`, `/welcome`, and `/settings` are +/// standalone root routes (`/settings` pushes over the shell from the +/// header avatar with Cupertino swipe-back). /// • `/` redirects based on auth + onboarding state. /// /// The redirect mirrors the RN gates (`app/index.tsx` + @@ -145,10 +147,18 @@ final routerProvider = Provider((ref) { parentNavigatorKey: _rootKey, builder: (context, state) => const WelcomeSetupScreen(), ), + // Settings pushes over the shell (Cupertino swipe-back) from the header + // avatar — it's an account surface, not a primary tab destination. + GoRoute( + path: '/settings', + parentNavigatorKey: _rootKey, + pageBuilder: (context, state) => + const CupertinoPage(child: SettingsScreen()), + ), // The primary destinations — each its own branch so state/scroll persist - // across drawer navigations. Order mirrors the web nav list (dashboard, - // nutrition, logging, groups, admin) plus settings (drawer footer). + // across tab switches. Order: dashboard, nutrition, logging, groups, + // admin (the last two are off-bar — reachable by route, not the tab bar). StatefulShellRoute.indexedStack( parentNavigatorKey: _rootKey, builder: @@ -205,14 +215,6 @@ final routerProvider = Provider((ref) { ), ], ), - StatefulShellBranch( - routes: [ - GoRoute( - path: '/settings', - builder: (context, state) => const SettingsScreen(), - ), - ], - ), ], ), ], diff --git a/apps/mobile-flutter/lib/shell/app_header.dart b/apps/mobile-flutter/lib/shell/app_header.dart index 3eb80032..5f34f02e 100644 --- a/apps/mobile-flutter/lib/shell/app_header.dart +++ b/apps/mobile-flutter/lib/shell/app_header.dart @@ -1,76 +1,89 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../data/session_provider.dart'; import '../features/onboarding/providers/onboarding_providers.dart'; import '../theme/nham_colors.dart'; import '../theme/nham_theme.dart'; -import 'tab_scaffold.dart'; +import '../theme/nham_typography.dart'; -/// In-flow mobile header — ported 1:1 from the RN `AppHeader` -/// (`components/app/app-header.tsx`). +/// In-flow app header. /// -/// A hamburger button on the left that triggers [onMenu], a flexible center -/// [child] slot (the logging screen fills it with the timeline date strip, -/// like the web header slot), and a right spacer mirroring the hamburger so -/// the slot stays centered. +/// A flexible center [child] slot (the logging screen fills it with the +/// timeline date strip), an optional left back button ([onBack]), and an +/// optional 32px account avatar in the right slot ([showAvatar]) that pushes +/// the Settings screen. The avatar carries the onboarding pulse-dot when setup +/// is incomplete. Empty side slots are mirrored so the center slot stays +/// centered. /// /// When [expanded] is true the header morphs (chip pill → full-width strip): -/// the full row is handed to [child] (the timeline week strip) and the -/// hamburger + spacer are dropped so the strip isn't squeezed — mirroring the -/// web header's data-strip-mode contract. The container width/shape animates -/// over 280ms with the same `cubic-bezier(0.16, 1, 0.3, 1)` easing; the slot -/// cross-fades in over 150ms. -/// -/// lucide-react-native `Menu` → Material `Icons.menu`. +/// the full row is handed to [child] (the timeline week strip) and the side +/// slots are dropped so the strip isn't squeezed. class AppHeader extends StatelessWidget { - const AppHeader({this.child, this.expanded = false, this.onMenu, super.key}); + const AppHeader({ + this.child, + this.expanded = false, + this.showAvatar = false, + this.onBack, + super.key, + }); /// Center slot content. final Widget? child; - /// Strip mode — hand the full row to [child] and drop the hamburger/spacer. + /// Strip mode — hand the full row to [child] and drop the side slots. final bool expanded; - /// Hamburger tap handler. When null the button falls back to opening the - /// shell's left nav drawer via [NavDrawerScope] — so no feature screen needs - /// to pass a handler (matches the web Sheet trigger). - final VoidCallback? onMenu; + /// Show the 32px account avatar in the right slot (pushes Settings). + final bool showAvatar; + + /// When non-null, a back chevron fills the left slot (Settings/pushed screens). + final VoidCallback? onBack; // Web animates the container shape over 0.28s with this easing. static const Duration _morphDuration = Duration(milliseconds: 280); static const Curve _morphCurve = Cubic(0.16, 1, 0.3, 1); static const Duration _fadeDuration = Duration(milliseconds: 150); - // RN: HIT = 44 (square hit target for the hamburger + the mirrored spacer). + // Square hit target for the side slots. static const double _hit = 44; @override Widget build(BuildContext context) { - final Widget content = - expanded - ? _FadeKey( - // Re-key on the mode so AnimatedSwitcher cross-fades on morph. - key: const ValueKey('expanded'), - duration: _fadeDuration, - child: child ?? const SizedBox.shrink(), - ) - : Row( - key: const ValueKey('collapsed'), - children: [ - _MenuButton(onMenu: onMenu), - Expanded( - child: _FadeKey( - duration: _fadeDuration, - child: Align( - alignment: Alignment.center, - child: child ?? const SizedBox.shrink(), - ), + final Widget leading = onBack != null + ? _BackButton(onBack: onBack!) + : const SizedBox(width: _hit, height: _hit); + final Widget trailing = showAvatar + ? const _AccountAvatar() + : const SizedBox(width: _hit, height: _hit); + + final Widget content = expanded + ? _FadeKey( + // Re-key on the mode so AnimatedSwitcher cross-fades on morph. + key: const ValueKey('expanded'), + duration: _fadeDuration, + child: child ?? const SizedBox.shrink(), + ) + : Row( + key: const ValueKey('collapsed'), + children: [ + leading, + Expanded( + child: _FadeKey( + duration: _fadeDuration, + child: Align( + alignment: Alignment.center, + child: child ?? const SizedBox.shrink(), ), ), - const SizedBox(width: _hit, height: _hit), - ], - ); + ), + trailing, + ], + ); return Padding( padding: const EdgeInsets.only(bottom: NhamSpacing.sp1), @@ -118,45 +131,30 @@ class _FadeKeyState extends State<_FadeKey> } } -/// The hamburger button. 44×44 hit area, radius 6 (rounded-md), 20px Menu icon -/// in espresso `text`, with a hover@60% press fill animated over ~150ms -/// (mobile-nav.tsx:99 `transition-colors hover:bg-nham-hover/60`). When -/// onboarding is incomplete, a pulsing 8px accent dot with a 2px cream ring -/// sits at top:6 right:6 (mobile-nav.tsx:102-104 + OnboardingDot). -class _MenuButton extends ConsumerStatefulWidget { - const _MenuButton({required this.onMenu}); +/// Left-slot back chevron — 44×44 hit area, radius 6, espresso glyph. +class _BackButton extends StatefulWidget { + const _BackButton({required this.onBack}); - final VoidCallback? onMenu; + final VoidCallback onBack; @override - ConsumerState<_MenuButton> createState() => _MenuButtonState(); + State<_BackButton> createState() => _BackButtonState(); } -class _MenuButtonState extends ConsumerState<_MenuButton> { +class _BackButtonState extends State<_BackButton> { bool _pressed = false; - void _handleTap() { - final onMenu = widget.onMenu; - if (onMenu != null) { - onMenu(); - return; - } - NavDrawerScope.maybeOf(context)?.open(); - } - @override Widget build(BuildContext context) { - final onboardingIncomplete = ref.watch(onboardingResumeProvider); - return Semantics( button: true, - label: tr('app.shell.openMenu'), + label: tr('common.back'), child: GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (_) => setState(() => _pressed = true), onTapUp: (_) => setState(() => _pressed = false), onTapCancel: () => setState(() => _pressed = false), - onTap: _handleTap, + onTap: widget.onBack, child: AnimatedContainer( duration: const Duration(milliseconds: 150), curve: Curves.easeInOut, @@ -166,13 +164,10 @@ class _MenuButtonState extends ConsumerState<_MenuButton> { color: _pressed ? NhamColors.hover40 : null, borderRadius: BorderRadius.circular(NhamRadii.sm), ), - child: Stack( - alignment: Alignment.center, - children: [ - const Icon(Icons.menu, size: 20, color: NhamColors.text), - if (onboardingIncomplete) - const Positioned(top: 6, right: 6, child: _OnboardingDot()), - ], + child: const Icon( + LucideIcons.chevronLeft, + size: 22, + color: NhamColors.text, ), ), ), @@ -180,8 +175,101 @@ class _MenuButtonState extends ConsumerState<_MenuButton> { } } +/// 32px account avatar disc — a tan-gradient circle with the user's initial, +/// pushing the Settings screen (Cupertino swipe-back). When onboarding is +/// incomplete a pulsing 8px accent dot with a 2px cream ring sits at the +/// top-right (the setup indicator that lived on the old hamburger). +class _AccountAvatar extends ConsumerStatefulWidget { + const _AccountAvatar(); + + @override + ConsumerState<_AccountAvatar> createState() => _AccountAvatarState(); +} + +class _AccountAvatarState extends ConsumerState<_AccountAvatar> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final session = ref.watch(currentSessionProvider); + final initial = _deriveInitial(session?.user); + final onboardingIncomplete = ref.watch(onboardingResumeProvider); + + return Semantics( + button: true, + label: tr('app.userMenu.openMenu'), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: () => context.push('/settings'), + child: SizedBox( + width: AppHeader._hit, + height: AppHeader._hit, + child: Center( + child: AnimatedScale( + scale: _pressed ? 0.92 : 1, + duration: const Duration(milliseconds: 150), + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Container( + width: 32, + height: 32, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0x66C9A87C), // accent @ 40% + Color(0x8CE8D5B5), // border @ 55% + ], + ), + border: Border.all( + color: const Color(0x40C9A87C), // accent @ 25% + width: 1, + ), + ), + child: Text( + initial, + style: NhamTextStyles.sansBold( + fontSize: 13, + ).copyWith(color: NhamColors.btn), + ), + ), + if (onboardingIncomplete) + const Positioned(top: -1, right: -1, child: _OnboardingDot()), + ], + ), + ), + ), + ), + ), + ); + } + + static String _deriveInitial(User? user) { + final meta = user?.userMetadata; + final raw = (meta?['displayName'] ?? meta?['full_name'] ?? meta?['name']); + String source = ''; + if (raw is String && raw.trim().isNotEmpty) { + source = raw.trim(); + } else if (user?.email != null && user!.email!.contains('@')) { + source = user.email!.split('@').first; + } else { + source = user?.email ?? ''; + } + if (source.isEmpty) return '·'; + return source.characters.first.toUpperCase(); + } +} + /// 8px accent dot with a 2px cream ring, gently pulsing — the unfinished-setup -/// indicator on the hamburger (OnboardingDot, animate-pulse-dot). +/// indicator on the avatar (OnboardingDot, animate-pulse-dot). class _OnboardingDot extends StatefulWidget { const _OnboardingDot(); @@ -194,13 +282,25 @@ class _OnboardingDotState extends State<_OnboardingDot> late final AnimationController _c = AnimationController( vsync: this, duration: const Duration(milliseconds: 1400), - )..repeat(reverse: true); + ); late final Animation _opacity = Tween( begin: 1, end: 0.45, ).animate(CurvedAnimation(parent: _c, curve: Curves.easeInOut)); + @override + void initState() { + super.initState(); + if (!WidgetsBinding + .instance + .platformDispatcher + .accessibilityFeatures + .disableAnimations) { + _c.repeat(reverse: true); + } + } + @override void dispose() { _c.dispose(); diff --git a/apps/mobile-flutter/lib/shell/sidebar.dart b/apps/mobile-flutter/lib/shell/sidebar.dart deleted file mode 100644 index 85133c58..00000000 --- a/apps/mobile-flutter/lib/shell/sidebar.dart +++ /dev/null @@ -1,703 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; - -import '../data/session_provider.dart'; -import '../features/onboarding/providers/onboarding_providers.dart'; -import '../features/onboarding/widgets/onboarding_dialog.dart'; -import '../theme/nham_colors.dart'; -import '../theme/nham_theme.dart'; -import '../theme/nham_typography.dart'; - -/// One drawer nav destination — mirrors the web `NavItemConfig` -/// (`components/app/nav-items.ts`): href, i18n label key, Lucide icon, and the -/// admin-only gate. -/// -/// lucide-react → Material icon mapping (web uses single, non-filled glyphs; -/// active state only changes color, never the glyph): -/// LayoutDashboard → Icons.grid_view -/// Activity → Icons.show_chart (pulse/activity line) -/// UtensilsCrossed → Icons.restaurant -/// Users2 → Icons.group -/// ShieldCheck → Icons.verified_user -class _NavItem { - const _NavItem({ - required this.href, - required this.labelKey, - required this.icon, - this.adminOnly = false, - }); - - final String href; - final String labelKey; - final IconData icon; - final bool adminOnly; -} - -// Order + membership mirror `NAV_ITEMS` exactly: -// dashboard, nutrition, logging, groups, admin (admin-only). -const List<_NavItem> _navItems = [ - _NavItem( - href: '/dashboard', - labelKey: 'app.mainSidebar.dashboard', - icon: Icons.grid_view, - ), - _NavItem( - href: '/nutrition', - labelKey: 'app.mainSidebar.nutrition', - icon: Icons.show_chart, - ), - _NavItem( - href: '/logging', - labelKey: 'app.mainSidebar.logging', - icon: Icons.restaurant, - ), - _NavItem( - href: '/groups', - labelKey: 'app.mainSidebar.groups', - icon: Icons.group, - ), - _NavItem( - href: '/admin', - labelKey: 'app.mainSidebar.admin', - icon: Icons.verified_user, - adminOnly: true, - ), -]; - -bool _isActiveRoute(String location, String href) { - if (location == href) return true; - return location.startsWith('$href/'); -} - -/// Left slide-in navigation panel — the Flutter equivalent of the web mobile -/// `MobileNav` Sheet (`components/app/mobile-nav.tsx`). -/// -/// Layout (top → bottom): -/// • Header: display name (15px) + email (11.5px), bottom hairline. -/// • Scrollable nav list (px-3 py-3, gap-1 between rows). -/// • Pinned footer: optional onboarding nudge, account card, Settings row, -/// Sign-out row. -/// -/// The panel chrome (width 88vw≤320, slide animation, scrim) is owned by -/// [NavDrawer] in `tab_scaffold.dart`; this widget is purely the content. -class Sidebar extends ConsumerWidget { - const Sidebar({required this.onClose, super.key}); - - /// Closes the drawer (used after a nav tap / sign-out). - final VoidCallback onClose; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final session = ref.watch(currentSessionProvider); - final user = session?.user; - final email = user?.email; - final displayName = _deriveName(user); - final label = displayName ?? _accountFallback(); - - final onboardingIncomplete = ref.watch(onboardingResumeProvider); - - // Admin gating isn't surfaced in the mobile data layer yet; match the web - // default (non-admin) until an isAdmin provider exists. - const isAdmin = false; - final items = _navItems - .where((i) => isAdmin || !i.adminOnly) - .toList(growable: false); - - final location = GoRouterState.of(context).matchedLocation; - final bottomInset = MediaQuery.of(context).padding.bottom; - - return Material( - color: NhamColors.surface, - // Top inset only — the footer applies its own bottom safe-area padding. - child: SafeArea( - bottom: false, - child: Column( - children: [ - // ── Header: name + email ────────────────────────────────────── - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp4, - vertical: NhamSpacing.sp4, - ), - decoration: const BoxDecoration( - border: Border( - bottom: BorderSide( - color: NhamColors.borderBiscotti40, - width: 1, - ), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: NhamTextStyles.sansRegular( - fontSize: 15, - ).copyWith(color: NhamColors.text), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (email != null) ...[ - const SizedBox(height: 4), - Text( - email, - style: NhamTextStyles.sansRegular( - fontSize: 11.5, - ).copyWith(color: NhamColors.textMuted), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - - // ── Scrollable nav list ─────────────────────────────────────── - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: NhamSpacing.sp3, - ), - child: Column( - children: [ - for (var i = 0; i < items.length; i++) ...[ - if (i > 0) const SizedBox(height: 4), - _NavRow( - item: items[i], - active: _isActiveRoute(location, items[i].href), - onTap: () { - onClose(); - context.go(items[i].href); - }, - ), - ], - ], - ), - ), - ), - - // ── Pinned footer ───────────────────────────────────────────── - Container( - width: double.infinity, - decoration: const BoxDecoration( - color: Color(0x66FFFFFF), // white @ 40% - border: Border( - top: BorderSide(color: NhamColors.borderBiscotti40, width: 1), - ), - ), - padding: EdgeInsets.fromLTRB( - NhamSpacing.sp3, - NhamSpacing.sp3, - NhamSpacing.sp3, - bottomInset + NhamSpacing.sp3, - ), - child: Column( - children: [ - if (onboardingIncomplete) ...[ - _OnboardingNudge( - onResume: () { - onClose(); - showOnboardingDialog(context, ref); - }, - ), - const SizedBox(height: 12), - ], - _AccountCard(label: label, email: email), - const SizedBox(height: 8), // mt-2 - _FooterRow( - icon: Icons.settings, - label: tr('app.mainSidebar.settings'), - active: _isActiveRoute(location, '/settings'), - onTap: () { - onClose(); - context.go('/settings'); - }, - ), - const SizedBox(height: 4), // mt-1 - _SignOutRow(ref: ref, onClose: onClose), - ], - ), - ), - ], - ), - ), - ); - } - - static String? _deriveName(User? user) { - final meta = user?.userMetadata; - final raw = (meta?['displayName'] ?? meta?['full_name'] ?? meta?['name']); - if (raw is String && raw.trim().isNotEmpty) return raw.trim(); - final email = user?.email; - if (email != null && email.contains('@')) return email.split('@').first; - return email; - } - - static String _accountFallback() => tr('app.userMenu.account'); -} - -/// A primary nav row. Active = full-width umber pill, white icon+label, 8px -/// radius, subtle btn@20% shadow. Inactive = espresso text with a hover@60% -/// press fill animated over ~150ms (transition-colors). -class _NavRow extends StatefulWidget { - const _NavRow({ - required this.item, - required this.active, - required this.onTap, - }); - - final _NavItem item; - final bool active; - final VoidCallback onTap; - - @override - State<_NavRow> createState() => _NavRowState(); -} - -class _NavRowState extends State<_NavRow> { - bool _pressed = false; - - @override - Widget build(BuildContext context) { - final active = widget.active; - final Color contentColor = active ? Colors.white : NhamColors.text; - final Color? fill = - active ? NhamColors.btn : (_pressed ? NhamColors.hover40 : null); - - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - onTap: widget.onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - curve: Curves.easeInOut, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: fill, - borderRadius: BorderRadius.circular(NhamRadii.md), // rounded-lg = 8 - boxShadow: - active - ? const [ - BoxShadow( - color: Color(0x33695E4E), // btn @ 20% - blurRadius: 8, - offset: Offset(0, 2), - ), - ] - : null, - ), - child: Row( - children: [ - Icon(widget.item.icon, size: 20, color: contentColor), - const SizedBox(width: 12), // gap-3 - Text( - tr(widget.item.labelKey), - style: NhamTextStyles.sansMedium( - fontSize: 14, - ).copyWith(color: contentColor), - ), - ], - ), - ), - ); - } -} - -/// Footer account card — white fill, radius 12, 36px gradient avatar w/ ring, -/// bold initial, name + email column (both truncated). -class _AccountCard extends StatelessWidget { - const _AccountCard({required this.label, required this.email}); - - final String label; - final String? email; - - @override - Widget build(BuildContext context) { - final initial = _deriveInitial(label, email); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular( - NhamRadii.buttonXl, - ), // rounded-xl=12 - ), - child: Row( - children: [ - Container( - width: 36, - height: 36, - alignment: Alignment.center, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Color(0x66C9A87C), // accent @ 40% - Color(0x8CE8D5B5), // border @ 55% - ], - ), - border: Border.all( - color: const Color(0x40C9A87C), // accent @ 25% - width: 1, - ), - ), - child: Text( - initial, - style: NhamTextStyles.sansBold( - fontSize: 13, - ).copyWith(color: NhamColors.btn), - ), - ), - const SizedBox(width: 12), // gap-3 - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: NhamTextStyles.sansMedium( - fontSize: 13, - ).copyWith(color: NhamColors.text), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (email != null) - Text( - email!, - style: NhamTextStyles.sansRegular( - fontSize: 11, - ).copyWith(color: NhamColors.textMuted), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ); - } - - static String _deriveInitial(String label, String? email) { - final source = label.trim().isNotEmpty ? label.trim() : (email ?? ''); - if (source.isEmpty) return '·'; - return source.characters.first.toUpperCase(); - } -} - -/// Footer Settings row — radius 12, 16px icon, 13px medium label; active = -/// umber fill + white; inactive = espresso text w/ hover@60% press fill. -class _FooterRow extends StatefulWidget { - const _FooterRow({ - required this.icon, - required this.label, - required this.active, - required this.onTap, - }); - - final IconData icon; - final String label; - final bool active; - final VoidCallback onTap; - - @override - State<_FooterRow> createState() => _FooterRowState(); -} - -class _FooterRowState extends State<_FooterRow> { - bool _pressed = false; - - @override - Widget build(BuildContext context) { - final active = widget.active; - final Color contentColor = active ? Colors.white : NhamColors.text; - final Color? fill = - active ? NhamColors.btn : (_pressed ? NhamColors.hover40 : null); - - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - onTap: widget.onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - curve: Curves.easeInOut, - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: fill, - borderRadius: BorderRadius.circular(NhamRadii.buttonXl), // 12 - ), - child: Row( - children: [ - Icon(widget.icon, size: 16, color: contentColor), - const SizedBox(width: 12), - Text( - widget.label, - style: NhamTextStyles.sansMedium( - fontSize: 13, - ).copyWith(color: contentColor), - ), - ], - ), - ), - ); - } -} - -/// Footer Sign-out row — danger text, danger@10% press fill, opacity 60% + -/// disabled while the request is in flight. -class _SignOutRow extends StatefulWidget { - const _SignOutRow({required this.ref, required this.onClose}); - - final WidgetRef ref; - final VoidCallback onClose; - - @override - State<_SignOutRow> createState() => _SignOutRowState(); -} - -class _SignOutRowState extends State<_SignOutRow> { - bool _pressed = false; - bool _signingOut = false; - - Future _signOut() async { - if (_signingOut) return; - setState(() => _signingOut = true); - try { - await widget.ref.read(authControllerProvider).signOut(); - if (!mounted) return; - // Close the drawer, then route to /sign-in explicitly. The router's auth - // refresh also redirects on the cleared session, but the explicit nav is - // a backstop so a missed redirect can't strand the user in the app. - widget.onClose(); - context.go('/sign-in'); - } catch (error) { - // Don't swallow it: reset so the row is tappable again and tell the user. - debugPrint('Sign-out failed: $error'); - if (!mounted) return; - setState(() => _signingOut = false); - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - SnackBar(content: Text(tr('app.userMenu.signOutError'))), - ); - } - } - - @override - Widget build(BuildContext context) { - final Color? fill = - _pressed ? const Color(0x1AD37B69) : null; // danger @ 10% - - return Opacity( - opacity: _signingOut ? 0.6 : 1.0, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: _signingOut ? null : (_) => setState(() => _pressed = true), - onTapUp: _signingOut ? null : (_) => setState(() => _pressed = false), - onTapCancel: - _signingOut ? null : () => setState(() => _pressed = false), - onTap: _signingOut ? null : _signOut, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - curve: Curves.easeInOut, - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: fill, - borderRadius: BorderRadius.circular(NhamRadii.buttonXl), // 12 - ), - child: Row( - children: [ - const Icon(Icons.logout, size: 16, color: NhamColors.danger), - const SizedBox(width: 12), - Text( - tr('app.userMenu.signOut'), - style: NhamTextStyles.sansMedium( - fontSize: 13, - ).copyWith(color: NhamColors.danger), - ), - ], - ), - ), - ), - ); - } -} - -/// Mobile onboarding nudge — gradient surface (accent/10 → surface → hover/55), -/// 16px radius, accent@25 ring, p-16, step counter, title (12px), description -/// (11px), 1px progress bar (accent over border/40), umber CTA. -class _OnboardingNudge extends ConsumerWidget { - const _OnboardingNudge({required this.onResume}); - - final VoidCallback onResume; - - static const int _total = 3; // ONBOARDING_TOTAL_STEPS - - @override - Widget build(BuildContext context, WidgetRef ref) { - final rawStep = ref.watch(onboardingResumeStepProvider); // 1-based - final safeStep = rawStep.clamp(1, _total); - final completed = (safeStep - 1).clamp(0, _total); - final progressPct = completed / _total; - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(NhamSpacing.sp4), // p-4 = 16 - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(NhamRadii.xxl), // rounded-2xl = 18 - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Color(0x1AC9A87C), // accent @ 10% - NhamColors.surface, - Color(0x8CF0EAE0), // hover @ 55% - ], - ), - border: Border.all( - color: const Color(0x40C9A87C), // accent @ 25% - width: 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Step counter row (sparkle chip + counter). - Row( - children: [ - Container( - width: 20, - height: 20, - alignment: Alignment.center, - decoration: BoxDecoration( - color: const Color(0x33C9A87C), // accent @ 20% - borderRadius: BorderRadius.circular(NhamRadii.md), - ), - child: const Icon( - Icons.auto_awesome, - size: 12, - color: NhamColors.accent, - ), - ), - const SizedBox(width: 6), // gap-1.5 - Text( - tr( - 'app.onboardingNudge.stepCounter', - namedArgs: {'current': '$safeStep', 'total': '$_total'}, - ).toUpperCase(), - style: NhamTextStyles.sansMedium(fontSize: 10).copyWith( - color: NhamColors.textMuted, - letterSpacing: 0.6, // 0.06em of 10px - ), - ), - ], - ), - const SizedBox(height: 8), // mb-2 - Text( - tr('app.onboardingNudge.title'), - style: NhamTextStyles.sansMedium( - fontSize: 12, - height: NhamLeading.snug, - ).copyWith(color: NhamColors.text), - ), - const SizedBox(height: 4), // mb-1 - Text( - tr('app.onboardingNudge.description'), - style: NhamTextStyles.sansRegular( - fontSize: 11, - height: NhamLeading.relaxed, - ).copyWith(color: NhamColors.textMuted), - ), - const SizedBox(height: 12), // mb-3 - // Progress bar: 1px high track (border/40), accent fill, 500ms ease-out. - ClipRRect( - borderRadius: BorderRadius.circular(NhamRadii.pill), - child: Container( - height: 4, // h-1 = 4px - color: NhamColors.borderBiscotti40, - child: LayoutBuilder( - builder: (context, constraints) { - return AnimatedContainer( - duration: const Duration(milliseconds: 500), - curve: Curves.easeOut, - width: constraints.maxWidth * progressPct, - decoration: BoxDecoration( - color: NhamColors.accent, - borderRadius: BorderRadius.circular(NhamRadii.pill), - ), - ); - }, - ), - ), - ), - const SizedBox(height: 12), // mb-3 - _NudgeCta(onTap: onResume), - ], - ), - ); - } -} - -/// Umber CTA inside the nudge — full-width, radius 8, 11px medium white label, -/// btn→btnHover press shift over ~150ms. -class _NudgeCta extends StatefulWidget { - const _NudgeCta({required this.onTap}); - - final VoidCallback onTap; - - @override - State<_NudgeCta> createState() => _NudgeCtaState(); -} - -class _NudgeCtaState extends State<_NudgeCta> { - bool _pressed = false; - - @override - Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - onTap: widget.onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - curve: Curves.easeInOut, - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - alignment: Alignment.center, - decoration: BoxDecoration( - color: _pressed ? NhamColors.btnHover : NhamColors.btn, - borderRadius: BorderRadius.circular(NhamRadii.md), // rounded-lg = 8 - boxShadow: const [ - BoxShadow( - color: Color(0x33695E4E), // btn @ 20% - blurRadius: 8, - offset: Offset(0, 2), - ), - ], - ), - child: Text( - tr('app.onboardingNudge.cta'), - style: NhamTextStyles.sansMedium( - fontSize: 11, - ).copyWith(color: Colors.white), - ), - ), - ); - } -} diff --git a/apps/mobile-flutter/lib/shell/tab_scaffold.dart b/apps/mobile-flutter/lib/shell/tab_scaffold.dart index 60845195..cf5a2877 100644 --- a/apps/mobile-flutter/lib/shell/tab_scaffold.dart +++ b/apps/mobile-flutter/lib/shell/tab_scaffold.dart @@ -1,156 +1,188 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../theme/nham_colors.dart'; -import 'sidebar.dart'; +import '../theme/nham_typography.dart'; /// App shell for the primary surfaces. /// -/// The web mobile view has NO bottom tab bar (`components/app/mobile-nav.tsx`): -/// navigation is a hamburger (in [AppHeader]) that opens a LEFT slide-in Sheet -/// drawer. This scaffold reproduces that — a cream body hosting the active -/// branch, plus a custom left drawer ([_NavDrawer]) matching the shadcn Sheet -/// behavior (88vw≤320px, dim black/50 scrim, asymmetric 500ms-open / 300ms-close -/// ease-in-out slide, tap-scrim / swipe-left to close). +/// Three-tab cream bottom bar (HIG-native), replacing the web hamburger drawer: +/// **Today** (dashboard) / **Log** (logging, center hero) / **Patterns** +/// (nutrition). A hairline top border, no elevation/shadow. Active = espresso +/// icon + 10px DM Sans label; inactive = muted. `selectionClick` haptic on +/// switch; branch swaps are instant (no cross-page transition). The bar hides +/// when the keyboard is open and respects the bottom safe area. /// -/// go_router's [StatefulNavigationShell] still backs the branches so each -/// destination keeps its state/scroll across switches; only the bottom bar is -/// gone. [AppHeader]'s hamburger opens the drawer via [NavDrawerScope]. -class TabScaffold extends StatefulWidget { +/// Settings/account moved to a 32px avatar disc in the header's right slot +/// ([AppHeader]). Groups + Admin stay reachable as routes but are off the bar +/// (their feature screens aren't built yet). +/// +/// go_router's [StatefulNavigationShell] backs the branches so each destination +/// keeps its state/scroll across switches. +class TabScaffold extends StatelessWidget { const TabScaffold({required this.navigationShell, super.key}); final StatefulNavigationShell navigationShell; - @override - State createState() => _TabScaffoldState(); -} - -class _TabScaffoldState extends State - with SingleTickerProviderStateMixin { - // Sheet: open over 500ms, close over 300ms, ease-in-out (sheet.tsx:63). - static const Duration _openDuration = Duration(milliseconds: 500); - static const Duration _closeDuration = Duration(milliseconds: 300); - - late final AnimationController _controller = AnimationController( - vsync: this, - duration: _openDuration, - reverseDuration: _closeDuration, - ); - - void _open() { - _controller.forward(); - } - - void _close() { - _controller.reverse(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); + // Branch indices in the router's StatefulShellRoute (declaration order): + // 0 dashboard · 1 nutrition · 2 logging · 3 groups · 4 admin · 5 settings. + static const int _branchDashboard = 0; + static const int _branchNutrition = 1; + static const int _branchLogging = 2; + + // Tab order on the bar (Today / Log / Patterns) → branch index. + static const List<_TabSpec> _tabs = [ + _TabSpec( + branch: _branchDashboard, + icon: LucideIcons.layoutDashboard, + labelKey: 'app.tabBar.today', + ), + _TabSpec( + branch: _branchLogging, + icon: LucideIcons.utensilsCrossed, + labelKey: 'app.tabBar.log', + ), + _TabSpec( + branch: _branchNutrition, + icon: LucideIcons.activity, + labelKey: 'app.tabBar.patterns', + ), + ]; + + void _onTap(int branch) { + if (branch != navigationShell.currentIndex) { + HapticFeedback.selectionClick(); + } + // goBranch with initialLocation: false preserves the branch's own stack. + navigationShell.goBranch( + branch, + initialLocation: branch == navigationShell.currentIndex, + ); } @override Widget build(BuildContext context) { - return NavDrawerScope( - open: _open, + final current = navigationShell.currentIndex; + // Hide the bar when the keyboard is open (composer focused) and on the + // off-bar branches (Groups/Admin/Settings reached via header/route). + final keyboardOpen = MediaQuery.of(context).viewInsets.bottom > 0; + final onBar = _tabs.any((t) => t.branch == current); + final showBar = onBar && !keyboardOpen; + + return PopScope( + // System back: from a secondary tab, return to Today rather than exiting. + canPop: current == _branchDashboard, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + _onTap(_branchDashboard); + }, child: Scaffold( backgroundColor: NhamColors.surface, - body: Stack( - children: [ - widget.navigationShell, - _NavDrawer(controller: _controller, onClose: _close), - ], - ), + body: navigationShell, + bottomNavigationBar: showBar + ? _BottomBar(tabs: _tabs, currentBranch: current, onTap: _onTap) + : null, ), ); } } -/// Exposes the drawer-open callback down the tree so [AppHeader]'s hamburger -/// can trigger it without any feature screen passing a handler. -class NavDrawerScope extends InheritedWidget { - const NavDrawerScope({required this.open, required super.child, super.key}); +class _TabSpec { + const _TabSpec({ + required this.branch, + required this.icon, + required this.labelKey, + }); + + final int branch; + final IconData icon; + final String labelKey; +} - final VoidCallback open; +/// The cream tab bar: a hairline top border, no elevation, safe-area padding. +class _BottomBar extends StatelessWidget { + const _BottomBar({ + required this.tabs, + required this.currentBranch, + required this.onTap, + }); - static NavDrawerScope? maybeOf(BuildContext context) => - context.dependOnInheritedWidgetOfExactType(); + final List<_TabSpec> tabs; + final int currentBranch; + final ValueChanged onTap; @override - bool updateShouldNotify(NavDrawerScope oldWidget) => open != oldWidget.open; -} + Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).padding.bottom; -/// The left slide-in panel + scrim, driven by [controller] (0 = closed, -/// 1 = open). -class _NavDrawer extends StatelessWidget { - const _NavDrawer({required this.controller, required this.onClose}); + return DecoratedBox( + decoration: const BoxDecoration( + color: NhamColors.surface, + border: Border( + top: BorderSide(color: NhamColors.borderSoft, width: 1), + ), + ), + child: Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: SizedBox( + height: 56, + child: Row( + children: [ + for (final tab in tabs) + Expanded( + child: _TabItem( + spec: tab, + active: tab.branch == currentBranch, + onTap: () => onTap(tab.branch), + ), + ), + ], + ), + ), + ), + ); + } +} - final AnimationController controller; - final VoidCallback onClose; +class _TabItem extends StatelessWidget { + const _TabItem({ + required this.spec, + required this.active, + required this.onTap, + }); - // Sheet easing — `transition ease-in-out` (sheet.tsx:63). - static const Curve _curve = Curves.easeInOut; + final _TabSpec spec; + final bool active; + final VoidCallback onTap; @override Widget build(BuildContext context) { - final media = MediaQuery.of(context); - // w-[88vw] max-w-[320px] (mobile-nav.tsx:111). - final panelWidth = (media.size.width * 0.88).clamp(0.0, 320.0).toDouble(); - - return AnimatedBuilder( - animation: controller, - builder: (context, _) { - final t = _curve.transform(controller.value); - if (t == 0) return const SizedBox.shrink(); - - return Stack( + final color = active ? NhamColors.text : NhamColors.textMuted; + return Semantics( + button: true, + selected: active, + label: tr(spec.labelKey), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - // Scrim — black @ 50%, fades with the slide; tap to close. - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onClose, - child: ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.5 * t)), - ), - ), - // Panel — full height, slides in from the left edge. - Positioned( - top: 0, - bottom: 0, - left: -panelWidth * (1 - t), - width: panelWidth, - // Swipe-left to dismiss. - child: GestureDetector( - onHorizontalDragUpdate: (details) { - controller.value += details.primaryDelta! / panelWidth; - }, - onHorizontalDragEnd: (details) { - final v = details.primaryVelocity ?? 0; - if (v < -200 || controller.value < 0.5) { - onClose(); - } else { - controller.forward(); - } - }, - child: DecoratedBox( - decoration: const BoxDecoration( - color: NhamColors.surface, - border: Border( - right: BorderSide( - color: NhamColors.borderSoft, // border @ 60% - width: 1, - ), - ), - ), - child: Sidebar(onClose: onClose), - ), + Icon(spec.icon, size: 22, color: color), + const SizedBox(height: 4), + Text( + tr(spec.labelKey), + style: NhamTextStyles.sansMedium(fontSize: 10).copyWith( + color: color, + fontWeight: active ? FontWeight.w600 : FontWeight.w500, ), ), ], - ); - }, + ), + ), ); } } From 43b8a6b755bf3129fa5be1d3f916a7b772b9c10c Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Thu, 11 Jun 2026 23:02:53 +0700 Subject: [PATCH 06/57] feat(mobile): localized time-of-day greeting on the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hardcoded, unlocalized English "Hello" — the one Lora-per-screen moment — with a localized time-of-day greeting driven by the device clock and app locale (Chào buổi sáng / Good morning, etc.). A greeting, not an interpretation. Adds dashboard.greeting.* keys to en + vi. Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 6 ++++++ apps/mobile-flutter/assets/l10n/vi.json | 6 ++++++ .../features/dashboard/screens/dashboard_screen.dart | 12 +++++++++++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index d40f9671..45469d43 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -852,6 +852,12 @@ }, "dashboard": { "title": "Dashboard", + "greeting": { + "morning": "Good morning", + "afternoon": "Good afternoon", + "evening": "Good evening", + "night": "Good evening" + }, "today": "Today", "thisWeek": "This Week", "caloriesConsumed": "Calories consumed", diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index 22cc505e..0b15fbb2 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -851,6 +851,12 @@ }, "dashboard": { "title": "Tổng quan", + "greeting": { + "morning": "Chào buổi sáng", + "afternoon": "Chào buổi chiều", + "evening": "Chào buổi tối", + "night": "Chào buổi tối" + }, "today": "Hôm nay", "thisWeek": "Tuần này", "caloriesConsumed": "Calo đã nạp", diff --git a/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart b/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart index af08a97d..a98b11a9 100644 --- a/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart +++ b/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart @@ -68,7 +68,7 @@ class DashboardScreen extends ConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: NhamSpacing.sp3), child: AppHeader( showAvatar: true, - child: Text('Hello', style: dashHeadline()), + child: Text(_greeting().tr(), style: dashHeadline()), ), ), Expanded( @@ -113,6 +113,16 @@ class DashboardScreen extends ConsumerWidget { } } +/// The l10n key for a time-of-day greeting, driven by the device clock. A +/// greeting, not an interpretation — the only Lora moment on the screen. +String _greeting() { + final hour = DateTime.now().hour; + if (hour < 12) return 'dashboard.greeting.morning'; + if (hour < 17) return 'dashboard.greeting.afternoon'; + if (hour < 21) return 'dashboard.greeting.evening'; + return 'dashboard.greeting.night'; +} + class _Content extends StatefulWidget { const _Content({ required this.args, From 3516148c49842c922e45b0aae93e5fef5dce8164 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Thu, 11 Jun 2026 23:08:46 +0700 Subject: [PATCH 07/57] feat(mobile): honest over-target state + consumed-fills rings + locale formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop censoring over-target days. The dashboard hero now shows the honest number ("184 over") with the eyebrow flipping to OVER TARGET in espresso ink — never red, never a pill, never an icon. The clamp(0) that made a 2,340-kcal day read as a neutral "0 / 2,000" is gone. Unify ring semantics: both calorie rings (dashboard + logging) now fill with CONSUMED calories (fill up as you eat), matching the week strip and heatmap framing — instead of the hero ring draining from remaining. When over target, the base arc completes in tan and an overflow arc continues past 12 o'clock in ~40%-alpha terracotta. The in-ring label flips "left" → "over". Locale-aware number formatting via intl (en "2,000" / vi "2.000") across the ring, hero, and logging summary; localize the heatmap weekday initials and the ring "left"/"over" labels. Adds dashboard.left/over/caloriesOverTarget keys. Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 3 + apps/mobile-flutter/assets/l10n/vi.json | 3 + .../dashboard/logic/dashboard_format.dart | 7 ++ .../dashboard/widgets/adherence_heatmap.dart | 17 ++- .../dashboard/widgets/calorie_ring.dart | 91 +++++++++------- .../dashboard/widgets/today_section.dart | 42 ++++---- .../logging/widgets/calorie_ring.dart | 102 ++++++++++-------- .../features/logging/widgets/feed_area.dart | 3 +- 8 files changed, 165 insertions(+), 103 deletions(-) diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index 45469d43..0bd8094c 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -862,6 +862,9 @@ "thisWeek": "This Week", "caloriesConsumed": "Calories consumed", "caloriesRemaining": "Calories remaining", + "caloriesOverTarget": "Over target", + "left": "left", + "over": "over", "protein": "Protein", "carbs": "Carbs", "fat": "Fat", diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index 0b15fbb2..2e748fce 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -861,6 +861,9 @@ "thisWeek": "Tuần này", "caloriesConsumed": "Calo đã nạp", "caloriesRemaining": "Calo còn lại", + "caloriesOverTarget": "Vượt mục tiêu", + "left": "còn lại", + "over": "vượt", "protein": "Đạm", "carbs": "Carb", "fat": "Chất béo", diff --git a/apps/mobile-flutter/lib/features/dashboard/logic/dashboard_format.dart b/apps/mobile-flutter/lib/features/dashboard/logic/dashboard_format.dart index fb62d198..b7a4603a 100644 --- a/apps/mobile-flutter/lib/features/dashboard/logic/dashboard_format.dart +++ b/apps/mobile-flutter/lib/features/dashboard/logic/dashboard_format.dart @@ -4,9 +4,16 @@ /// `todayDateString` helper used by the dashboard screen. library; +import 'package:intl/intl.dart'; + /// Rounds to a whole number, mapping null to 0. Mirrors web `round0`. int round0(num? n) => n == null ? 0 : n.round(); +/// Locale-aware thousands grouping (en → "2,000", vi → "2.000"). Mirrors the +/// web's `toLocaleString()` instead of the hardcoded comma grouping. +String formatCount(int n, String locale) => + NumberFormat.decimalPattern(locale).format(n); + /// Local `YYYY-MM-DD` for [date] (defaults to now). Matches the web/RN /// `todayDateString` — uses LOCAL date components, not UTC. String todayDateString([DateTime? date]) { diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart index b93fa68f..d2b90c5c 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart @@ -19,7 +19,17 @@ import '../data/dashboard_providers.dart'; import '../logic/heatmap_colors.dart'; import 'dashboard_tokens.dart'; -const List _dayLabels = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; +/// Monday-first narrow weekday initials for [locale] (en → M T W T F S S; vi → +/// the localized initials). Anchored on a known Monday so DST/locale offsets +/// can't shift the order. +List _weekdayInitials(String locale) { + // 2024-01-01 is a Monday. + final monday = DateTime(2024, 1, 1); + final fmt = DateFormat('EEEEE', locale); // narrow weekday + return [ + for (var i = 0; i < 7; i++) fmt.format(monday.add(Duration(days: i))), + ]; +} const double _gap90d = 2; // GAP['90d'] const double _dayLabelWidth = 16; const double _dayLabelGutter = NhamSpacing.sp1; // gap-1 (4px) @@ -147,6 +157,7 @@ class _HeatmapBodyState extends State<_HeatmapBody> Widget build(BuildContext context) { final data = widget.data; final numWeeks = _numWeeks; + final dayLabels = _weekdayInitials(context.locale.toString()); return Container( padding: const EdgeInsets.all(NhamSpacing.sp4), @@ -189,7 +200,7 @@ class _HeatmapBodyState extends State<_HeatmapBody> child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (var i = 0; i < _dayLabels.length; i++) + for (var i = 0; i < dayLabels.length; i++) Container( height: sq, margin: EdgeInsets.only( @@ -200,7 +211,7 @@ class _HeatmapBodyState extends State<_HeatmapBody> padding: const EdgeInsets.only(right: 4), alignment: Alignment.centerRight, child: Text( - _dayLabels[i], + dayLabels[i], style: dashEyebrow( color: kInkSecondary, weight: FontWeight.w600), diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/calorie_ring.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/calorie_ring.dart index ddebdaad..95fdcc22 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/calorie_ring.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/calorie_ring.dart @@ -1,15 +1,25 @@ -/// CalorieRing — RN port of `components/logging/calorie-ring.tsx`. +/// CalorieRing — the dashboard/logging calorie ring. /// -/// Shows REMAINING calories as the fill fraction. The progress arc animates its -/// sweep (the RN `strokeDashoffset` tween) over 1000ms with the signature -/// `cubic-bezier(0.16, 1, 0.3, 1)` ease. The dashboard passes a [center] node -/// (a flame icon) to override the default in-ring remaining/“left” content. +/// The arc fills with CONSUMED calories (`current / target`) — it fills up as +/// you eat, matching the week-strip rings and the heatmap framing. The progress +/// arc animates its sweep over 1000ms with the signature +/// `cubic-bezier(0.16, 1, 0.3, 1)` ease. +/// +/// Over target: the base arc completes a full circle in tan, then an overflow +/// arc continues past 12 o'clock in ~40%-alpha terracotta for the overshoot +/// fraction — an honest, judgment-free visual. Never red, never a pill, never +/// an icon. +/// +/// The dashboard passes a [center] node (a flame icon) to override the default +/// in-ring remaining/over content. library; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_typography.dart'; +import '../logic/dashboard_format.dart'; class CalorieRing extends StatefulWidget { const CalorieRing({ @@ -42,16 +52,13 @@ class _CalorieRingState extends State // cubic-bezier(0.16, 1, 0.3, 1) — the signature "ease-out-expo-ish" curve. static const Curve _ease = Cubic(0.16, 1, 0.3, 1); - double get _pct { - final remaining = (widget.target - widget.current).clamp(0, double.infinity); - return widget.target > 0 - ? (remaining / widget.target).clamp(0, 1).toDouble() - : 0; - } + /// Consumed fraction of the target — can exceed 1 when over target. The + /// painter splits the base (0..1, tan) from the overflow (>1, terracotta). + double get _consumedRatio => + widget.target > 0 ? (widget.current / widget.target) : 0; - Animation _build() => Tween(begin: 0, end: _pct).animate( - CurvedAnimation(parent: _controller, curve: _ease), - ); + Animation _build() => Tween(begin: 0, end: _consumedRatio) + .animate(CurvedAnimation(parent: _controller, curve: _ease)); @override void initState() { @@ -79,7 +86,7 @@ class _CalorieRingState extends State @override Widget build(BuildContext context) { - final remaining = (widget.target - widget.current).clamp(0, double.infinity); + final remaining = widget.target - widget.current; return SizedBox( width: widget.size, height: widget.size, @@ -91,12 +98,12 @@ class _CalorieRingState extends State builder: (context, _) => CustomPaint( size: Size(widget.size, widget.size), painter: _RingPainter( - fraction: _fill.value, + ratio: _fill.value, strokeWidth: widget.strokeWidth, ), ), ), - widget.center ?? _DefaultCenter(remaining: remaining.toDouble()), + widget.center ?? _DefaultCenter(remaining: remaining), ], ), ); @@ -106,21 +113,25 @@ class _CalorieRingState extends State class _DefaultCenter extends StatelessWidget { const _DefaultCenter({required this.remaining}); + /// target − consumed: positive = calories left, negative = over target. final double remaining; @override Widget build(BuildContext context) { + final locale = context.locale.toString(); + final over = remaining < 0; + final value = remaining.abs().round(); return Column( mainAxisSize: MainAxisSize.min, children: [ Text( - remaining.round().toString(), + formatCount(value, locale), style: NhamTextStyles.serifSemiBold(fontSize: 17, height: 1) .copyWith(color: NhamColors.text, letterSpacing: 0), ), const SizedBox(height: 2), Text( - 'LEFT', + (over ? tr('dashboard.over') : tr('dashboard.left')).toUpperCase(), style: NhamTextStyles.sansBold(fontSize: 8) .copyWith(letterSpacing: 1.2, color: NhamColors.stone), ), @@ -129,20 +140,24 @@ class _DefaultCenter extends StatelessWidget { } } -/// Draws the track ring + a rounded progress arc starting at 12 o'clock -/// (`rotate(-90)`), sweeping clockwise for [fraction] of the circle. Stroke -/// width is constant in pixels (RN `vectorEffect="non-scaling-stroke"`). +/// Draws the track ring, the consumed arc (tan, 0..1 of the circle, from 12 +/// o'clock clockwise) and — when [ratio] > 1 — an overflow arc continuing past +/// 12 o'clock in ~40%-alpha terracotta for the overshoot. class _RingPainter extends CustomPainter { - _RingPainter({required this.fraction, required this.strokeWidth}); + _RingPainter({required this.ratio, required this.strokeWidth}); - final double fraction; + final double ratio; final double strokeWidth; + static const double _tau = 2 * 3.141592653589793; + static const double _start = -3.141592653589793 / 2; // -90° (12 o'clock). + @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); // RN viewBox 0 0 100 100, r=46, center 50 → radius scales with size. final radius = (size.width / 2) * (46 / 50); + final rect = Rect.fromCircle(center: center, radius: radius); final track = Paint() ..style = PaintingStyle.stroke @@ -150,26 +165,28 @@ class _RingPainter extends CustomPainter { ..color = NhamColors.track; canvas.drawCircle(center, radius, track); - if (fraction <= 0) return; + if (ratio <= 0) return; - final arc = Paint() + final base = Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round ..color = NhamColors.accent; - - const start = -3.141592653589793 / 2; // -90° (12 o'clock). - final sweep = 2 * 3.141592653589793 * fraction; - canvas.drawArc( - Rect.fromCircle(center: center, radius: radius), - start, - sweep, - false, - arc, - ); + canvas.drawArc(rect, _start, _tau * ratio.clamp(0, 1).toDouble(), false, base); + + // Over target — the overflow arc continues in terracotta @ ~40% alpha. + if (ratio > 1) { + final overflow = (ratio - 1).clamp(0, 1).toDouble(); + final over = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round + ..color = NhamColors.danger.withValues(alpha: 0.4); + canvas.drawArc(rect, _start, _tau * overflow, false, over); + } } @override bool shouldRepaint(_RingPainter old) => - old.fraction != fraction || old.strokeWidth != strokeWidth; + old.ratio != ratio || old.strokeWidth != strokeWidth; } diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart index da7a628c..64609189 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart @@ -77,6 +77,7 @@ class _Dock extends StatelessWidget { @override Widget build(BuildContext context) { + final locale = context.locale.toString(); final meals = [...day.persistedMeals] ..sort((a, b) => a.loggedAt.compareTo(b.loggedAt)); @@ -91,9 +92,9 @@ class _Dock extends StatelessWidget { totalFat += m.nutrition.fatG ?? 0; } final calories = round0(totalCalories); - final remaining = (targets.calorieTarget - calories) - .clamp(0, double.infinity) - .round(); + // Honest signed remaining — negative when over target (no censoring clamp). + final remaining = (targets.calorieTarget - calories).round(); + final overTarget = remaining < 0; final macroBars = <_MacroBarData>[ _MacroBarData(tr('dashboard.protein'), round0(totalProtein), @@ -124,8 +125,14 @@ class _Dock extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - tr('dashboard.caloriesRemaining').toUpperCase(), - style: dashEyebrow(), + (overTarget + ? tr('dashboard.caloriesOverTarget') + : tr('dashboard.caloriesRemaining')) + .toUpperCase(), + // OVER TARGET flips to espresso ink — never red. + style: dashEyebrow( + color: overTarget ? kInk : kInkSecondary, + ), ), const SizedBox(height: NhamSpacing.sp1), Row( @@ -133,19 +140,24 @@ class _Dock extends StatelessWidget { textBaseline: TextBaseline.alphabetic, children: [ Flexible( - child: Text(_fmt(remaining), - style: dashHero(), maxLines: 1), + child: Text( + _fmt(remaining.abs(), locale), + style: dashHero(), + maxLines: 1, + ), ), const SizedBox(width: 6), Text( - '/ ${_fmt(targets.calorieTarget.round())}', + overTarget + ? tr('dashboard.over') + : '/ ${_fmt(targets.calorieTarget.round(), locale)}', style: dashBody(color: kInkSecondary), ), ], ), const SizedBox(height: NhamSpacing.sp1), Text( - '${_fmt(calories)} ${tr('dashboard.caloriesLogged')}', + '${_fmt(calories, locale)} ${tr('dashboard.caloriesLogged')}', style: dashMeta(color: kInkDisabled), ), ], @@ -180,16 +192,8 @@ class _Dock extends StatelessWidget { ); } - static String _fmt(int n) { - // toLocaleString() → group thousands with commas (en default). - final s = n.abs().toString(); - final buf = StringBuffer(); - for (var i = 0; i < s.length; i++) { - if (i > 0 && (s.length - i) % 3 == 0) buf.write(','); - buf.write(s[i]); - } - return n < 0 ? '-$buf' : buf.toString(); - } + // Locale-aware thousands grouping (en → "2,000", vi → "2.000"). + static String _fmt(int n, String locale) => formatCount(n, locale); } /// A hairline divider between the card's zones (hero · macros · meals). diff --git a/apps/mobile-flutter/lib/features/logging/widgets/calorie_ring.dart b/apps/mobile-flutter/lib/features/logging/widgets/calorie_ring.dart index d9f2f5d7..544f7f09 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/calorie_ring.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/calorie_ring.dart @@ -1,18 +1,21 @@ import 'dart:math' as math; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import '../../../features/dashboard/logic/dashboard_format.dart' show formatCount; import '../../../shared/widgets/nham_text.dart'; import '../../../theme/nham_colors.dart'; -/// The calorie ring shows REMAINING calories as the fill fraction (matches -/// web's shared/calorie-ring). The progress arc animates its -/// `strokeDashoffset` over 1000ms with `cubic-bezier(0.16, 1, 0.3, 1)`. +/// The calorie ring fills with CONSUMED calories (`current / target`) — it fills +/// up as you eat, matching the dashboard ring, the week strip, and the heatmap +/// framing. The progress arc animates its sweep over 1000ms with +/// `cubic-bezier(0.16, 1, 0.3, 1)`. /// -/// RN geometry: a 100-unit viewbox, radius 46, centered, with a -/// `non-scaling-stroke` of [strokeWidth] px at the rendered [size]. [center] -/// overrides the default in-ring content (the remaining number + "left" -/// eyebrow), mirroring the RN `center` prop. +/// Over target: the base arc completes in tan, then an overflow arc continues +/// past 12 o'clock in ~40%-alpha terracotta — never red, never a pill. The +/// default in-ring content shows the calories `left`, flipping to `over` when +/// past target. [center] overrides that content. class CalorieRing extends StatefulWidget { const CalorieRing({ super.key, @@ -43,19 +46,19 @@ class _CalorieRingState extends State duration: const Duration(milliseconds: 1000), ); late Animation _animation; - double _fromPct = 0; - late double _pct; + double _fromRatio = 0; + late double _ratio; - double get _remaining => - math.max(0, widget.target - widget.current); - double _computePct() => - widget.target > 0 ? math.min(_remaining / widget.target, 1) : 0; + double get _remaining => widget.target - widget.current; + + /// Consumed fraction — may exceed 1 (the painter splits base vs. overflow). + double _computeRatio() => widget.target > 0 ? widget.current / widget.target : 0; @override void initState() { super.initState(); - _pct = _computePct(); - _animation = Tween(begin: 0, end: _pct) + _ratio = _computeRatio(); + _animation = Tween(begin: 0, end: _ratio) .chain(CurveTween(curve: _curve)) .animate(_controller); _controller.forward(); @@ -64,11 +67,11 @@ class _CalorieRingState extends State @override void didUpdateWidget(CalorieRing oldWidget) { super.didUpdateWidget(oldWidget); - final nextPct = _computePct(); - if (nextPct != _pct) { - _fromPct = _animation.value; - _pct = nextPct; - _animation = Tween(begin: _fromPct, end: _pct) + final nextRatio = _computeRatio(); + if (nextRatio != _ratio) { + _fromRatio = _animation.value; + _ratio = nextRatio; + _animation = Tween(begin: _fromRatio, end: _ratio) .chain(CurveTween(curve: _curve)) .animate(_controller); _controller @@ -85,7 +88,6 @@ class _CalorieRingState extends State @override Widget build(BuildContext context) { - final remainingLabel = _remaining.round().toString(); return SizedBox( width: widget.size, height: widget.size, @@ -97,13 +99,12 @@ class _CalorieRingState extends State builder: (context, _) => CustomPaint( size: Size(widget.size, widget.size), painter: _RingPainter( - pct: _animation.value, + ratio: _animation.value, strokeWidth: widget.strokeWidth, ), ), ), - widget.center ?? - _DefaultCenter(remainingLabel: remainingLabel), + widget.center ?? _DefaultCenter(remaining: _remaining), ], ), ); @@ -111,16 +112,21 @@ class _CalorieRingState extends State } class _DefaultCenter extends StatelessWidget { - const _DefaultCenter({required this.remainingLabel}); - final String remainingLabel; + const _DefaultCenter({required this.remaining}); + + /// target − consumed: positive = calories left, negative = over target. + final double remaining; @override Widget build(BuildContext context) { + final locale = context.locale.toString(); + final over = remaining < 0; + final value = remaining.abs().round(); return Column( mainAxisSize: MainAxisSize.min, children: [ NhamText( - remainingLabel, + formatCount(value, locale), variant: NhamTextVariant.numDisplay, style: const TextStyle( fontSize: 17, @@ -129,10 +135,10 @@ class _DefaultCenter extends StatelessWidget { ), ), const SizedBox(height: 2), - const NhamText( - 'left', + NhamText( + over ? tr('dashboard.over') : tr('dashboard.left'), variant: NhamTextVariant.eyebrow, - style: TextStyle(fontSize: 8, letterSpacing: 1.2), + style: const TextStyle(fontSize: 8, letterSpacing: 1.2), ), ], ); @@ -140,16 +146,19 @@ class _DefaultCenter extends StatelessWidget { } class _RingPainter extends CustomPainter { - _RingPainter({required this.pct, required this.strokeWidth}); + _RingPainter({required this.ratio, required this.strokeWidth}); - final double pct; + final double ratio; final double strokeWidth; + static const double _start = -math.pi / 2; // 12 o'clock. + @override void paint(Canvas canvas, Size size) { // RN: radius 46 in a 100-unit viewbox → scale to the rendered size. final center = Offset(size.width / 2, size.height / 2); final radius = (46 / 100) * size.width; + final rect = Rect.fromCircle(center: center, radius: radius); final track = Paint() ..style = PaintingStyle.stroke @@ -157,27 +166,34 @@ class _RingPainter extends CustomPainter { ..color = NhamColors.track; canvas.drawCircle(center, radius, track); - if (pct <= 0) return; + if (ratio <= 0) return; - final arc = Paint() + final base = Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round ..color = NhamColors.accent; - - // Start at -90° (12 o'clock), sweep clockwise by pct of the full circle. - const startAngle = -math.pi / 2; - final sweepAngle = 2 * math.pi * pct; canvas.drawArc( - Rect.fromCircle(center: center, radius: radius), - startAngle, - sweepAngle, + rect, + _start, + 2 * math.pi * ratio.clamp(0, 1).toDouble(), false, - arc, + base, ); + + // Over target — the overflow arc continues in terracotta @ ~40% alpha. + if (ratio > 1) { + final overflow = (ratio - 1).clamp(0, 1).toDouble(); + final over = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round + ..color = NhamColors.danger.withValues(alpha: 0.4); + canvas.drawArc(rect, _start, 2 * math.pi * overflow, false, over); + } } @override bool shouldRepaint(_RingPainter old) => - old.pct != pct || old.strokeWidth != strokeWidth; + old.ratio != ratio || old.strokeWidth != strokeWidth; } diff --git a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart index cc789bf5..e706d8b8 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart @@ -15,6 +15,7 @@ import '../../../theme/nham_typography.dart'; import '../data/logging_keys.dart'; import '../data/logging_models.dart'; import '../data/logging_providers.dart'; +import '../../dashboard/logic/dashboard_format.dart' show formatCount; import '../data/stream_analysis_controller.dart'; import '../logic/format.dart'; import 'calorie_ring.dart'; @@ -183,7 +184,7 @@ class _FeedAreaState extends ConsumerState { ), const SizedBox(height: 4), // gap-1 NhamText( - '$dailyCalories / ${profile.calorieTarget} kcal', + '${formatCount(dailyCalories, context.locale.toString())} / ${formatCount(profile.calorieTarget, context.locale.toString())} kcal', variant: NhamTextVariant.numCaption, ), ], From 6074f40b54122dd0088cec90ac46009c66eb20e4 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Thu, 11 Jun 2026 23:15:47 +0700 Subject: [PATCH 08/57] =?UTF-8?q?feat(mobile):=20never=20destroy=20typed?= =?UTF-8?q?=20meal=20on=20failure=20=E2=80=94=20retry=20card=20+=20reassur?= =?UTF-8?q?ance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed analysis no longer wipes the user's words. On error the raw text is restored into the composer AND the failed attempt renders as a feed card: the input as a Lora quote, a terracotta one-liner, "Try again" as the primary action (re-runs the same meal), and a quiet Discard — wiring the previously unused logging.discard string. Keep the composer editable during analysis (the requestId-supersede mechanism already handles overlap): MealInput's `disabled` becomes `analyzing`, which only swaps Submit→Stop while leaving the field live. Add a ~20s reassurance line to the streaming card so a slow pipeline doesn't read as a stall. Adds logging.failedAttempt.* and logging.streaming.stillWorking to en + vi. Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 7 +- apps/mobile-flutter/assets/l10n/vi.json | 7 +- .../features/logging/widgets/feed_area.dart | 237 +++++++++++++++++- .../features/logging/widgets/meal_input.dart | 64 ++--- .../logging/widgets/streaming_entry.dart | 28 +++ 5 files changed, 301 insertions(+), 42 deletions(-) diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index 0bd8094c..021b1d6e 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -1018,7 +1018,12 @@ "matching": "Matching ingredients...", "estimating": "Estimating nutrition...", "assembling": "Putting it all together...", - "analyzing": "Analyzing..." + "analyzing": "Analyzing...", + "stillWorking": "Still working on it — almost there." + }, + "failedAttempt": { + "message": "We couldn't read this one. Your note is safe — give it another try.", + "tryAgain": "Try again" }, "mealEntry": { "edit": "Edit", diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index 2e748fce..5b824534 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -1017,7 +1017,12 @@ "matching": "Đang khớp nguyên liệu...", "estimating": "Đang ước tính dinh dưỡng...", "assembling": "Đang tổng hợp kết quả...", - "analyzing": "Đang phân tích..." + "analyzing": "Đang phân tích...", + "stillWorking": "Vẫn đang xử lý — sắp xong rồi." + }, + "failedAttempt": { + "message": "Chưa đọc được món này. Ghi chú của bạn vẫn còn — thử lại nhé.", + "tryAgain": "Thử lại" }, "mealEntry": { "edit": "Sửa", diff --git a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart index e706d8b8..3bc138d6 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart @@ -46,13 +46,26 @@ class FeedArea extends ConsumerStatefulWidget { class _FeedAreaState extends ConsumerState { final MealInputController _inputController = MealInputController(); + + /// The text of the run currently in flight — restored to the composer if it + /// fails, so a failed analysis never destroys what the user typed. + String? _inFlightText; + + /// A failed attempt, rendered as a feed card with "Try again" (terracotta). + String? _failedText; + + /// Inline error for a failed confirm (saving a meal) — not analysis errors, + /// which surface as the failed-attempt card. String? _errorText; LoggingDayArgs get _dayArgs => LoggingDayArgs(widget.profile.userId, widget.date); void _submit(String text) { - setState(() => _errorText = null); + setState(() { + _failedText = null; + _inFlightText = text; + }); _inputController.clear(); ref .read(streamAnalysisProvider.notifier) @@ -65,6 +78,12 @@ class _FeedAreaState extends ConsumerState { ); } + void _retry() { + final text = _failedText; + if (text == null) return; + _submit(text); + } + void _handleSuggestion(String s) { _inputController.setText(s); _inputController.focus(); @@ -74,13 +93,22 @@ class _FeedAreaState extends ConsumerState { // On completion: refetch the day + meal-dates so the stored analysis shows // as a confirmable card, then clear the local stream. if (next.status == StreamStatus.done && next.analysisId != null) { + _inFlightText = null; ref.invalidate(loggingDayProvider(_dayArgs)); ref.invalidate(mealDatesProvider(widget.profile.userId)); ref.read(streamAnalysisProvider.notifier).reset(); } - // On error: surface the message then reset. + // On error: never destroy the typed meal. Restore the raw text into the + // composer AND render the failed attempt as a feed card (Try again). if (next.status == StreamStatus.error) { - setState(() => _errorText = next.error ?? 'errors.internal'.tr()); + final text = _inFlightText; + setState(() { + _failedText = text; + _inFlightText = null; + }); + if (text != null && _inputController.getText().trim().isEmpty) { + _inputController.setText(text); + } ref.read(streamAnalysisProvider.notifier).reset(); } } @@ -126,13 +154,17 @@ class _FeedAreaState extends ConsumerState { persistedMeals.fold(0, (s, m) => s + (m.nutrition.fatG ?? 0)), ); + final hasFailedAttempt = _failedText != null; + final isEmpty = !isLoading && persistedMeals.isEmpty && pendingConfirmations.isEmpty && - !isStreaming; + !isStreaming && + !hasFailedAttempt; - final hasFooterItems = pendingConfirmations.isNotEmpty || isStreaming; + final hasFooterItems = + pendingConfirmations.isNotEmpty || isStreaming || hasFailedAttempt; final macroBars = [ _MacroBarData( @@ -220,6 +252,9 @@ class _FeedAreaState extends ConsumerState { stream: stream, hasFooterItems: hasFooterItems, confirmPending: confirmPending, + failedText: _failedText, + onRetry: _retry, + onDiscardFailed: () => setState(() => _failedText = null), ), ), @@ -249,7 +284,7 @@ class _FeedAreaState extends ConsumerState { controller: _inputController, onSubmit: _submit, onCancel: () => ref.read(streamAnalysisProvider.notifier).cancel(), - disabled: stream.isAnalyzing, + analyzing: stream.isAnalyzing, ), ), ], @@ -266,6 +301,9 @@ class _FeedAreaState extends ConsumerState { required StreamAnalysisState stream, required bool hasFooterItems, required bool confirmPending, + required String? failedText, + required VoidCallback onRetry, + required VoidCallback onDiscardFailed, }) { // Day fetch error → red alert card with retry (LoggingDayErrorState). if (hasError && persistedMeals.isEmpty && !hasFooterItems) { @@ -304,6 +342,9 @@ class _FeedAreaState extends ConsumerState { stream: stream, confirmPending: confirmPending, onConfirm: _confirm, + failedText: failedText, + onRetry: onRetry, + onDiscardFailed: onDiscardFailed, ), ); } @@ -359,6 +400,9 @@ class _FeedAreaState extends ConsumerState { stream: stream, confirmPending: confirmPending, onConfirm: _confirm, + failedText: failedText, + onRetry: onRetry, + onDiscardFailed: onDiscardFailed, ); }, ); @@ -398,6 +442,9 @@ class _Footer extends StatelessWidget { required this.stream, required this.confirmPending, required this.onConfirm, + required this.failedText, + required this.onRetry, + required this.onDiscardFailed, }); final List pendingConfirmations; @@ -406,9 +453,13 @@ class _Footer extends StatelessWidget { final bool confirmPending; final void Function(String analysisId, List edits) onConfirm; + final String? failedText; + final VoidCallback onRetry; + final VoidCallback onDiscardFailed; @override Widget build(BuildContext context) { + final hasFailed = failedText != null; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -418,7 +469,9 @@ class _Footer extends StatelessWidget { rawInput: pendingConfirmations[i].rawInput, parsedMeal: pendingConfirmations[i].parsedMeal, busy: confirmPending, - isLast: !isStreaming && i == pendingConfirmations.length - 1, + isLast: !isStreaming && + !hasFailed && + i == pendingConfirmations.length - 1, onConfirm: (edits) => onConfirm(pendingConfirmations[i].id, edits), ), if (isStreaming) @@ -426,13 +479,181 @@ class _Footer extends StatelessWidget { status: stream.status, items: stream.items, completedItems: stream.completedItems, - isLast: true, + isLast: !hasFailed, + ), + if (hasFailed) + _FailedAttemptCard( + rawInput: failedText!, + onRetry: onRetry, + onDiscard: onDiscardFailed, ), ], ); } } +/// A failed analysis, rendered as a feed card so the attempt is never lost: the +/// raw input as a Lora quote, a terracotta one-liner, and "Try again" as the +/// primary action (with a quiet Discard). The raw text is also restored to the +/// composer — this card is the visible record of what happened. +class _FailedAttemptCard extends StatelessWidget { + const _FailedAttemptCard({ + required this.rawInput, + required this.onRetry, + required this.onDiscard, + }); + + final String rawInput; + final VoidCallback onRetry; + final VoidCallback onDiscard; + + @override + Widget build(BuildContext context) { + return TimelineRail( + isLast: true, + // A terracotta-ringed dot marks the failed entry (never red). + dotChild: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: NhamColors.elev, + border: Border.all(color: NhamColors.danger, width: 2), + ), + ), + child: Padding( + padding: const EdgeInsets.only(bottom: NhamSpacing.sp3), + child: Container( + padding: const EdgeInsets.all(NhamSpacing.sp4), + decoration: BoxDecoration( + color: NhamColors.surface, + borderRadius: BorderRadius.circular(NhamRadii.containerLg), + border: Border.all(color: NhamColors.borderSoft), + boxShadow: const [NhamShadows.sm], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NhamText( + rawInput, + variant: NhamTextVariant.mealQuote, + style: const TextStyle(fontSize: 17, height: 28 / 17), + ), + const SizedBox(height: NhamSpacing.sp3), + NhamText( + 'logging.failedAttempt.message'.tr(), + variant: NhamTextVariant.small, + style: const TextStyle(color: NhamColors.danger), + ), + const SizedBox(height: NhamSpacing.sp4), + Row( + children: [ + Expanded( + child: _RetryButton(onTap: onRetry), + ), + const SizedBox(width: NhamSpacing.sp2), + _DiscardButton(onTap: onDiscard), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// Primary "Try again" — solid umber, mirroring the confirm button's resting +/// look (an honest re-run of the same meal). +class _RetryButton extends StatefulWidget { + const _RetryButton({required this.onTap}); + final VoidCallback onTap; + + @override + State<_RetryButton> createState() => _RetryButtonState(); +} + +class _RetryButtonState extends State<_RetryButton> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: 'logging.failedAttempt.tryAgain'.tr(), + child: GestureDetector( + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: _pressed ? NhamColors.btnHover : NhamColors.btn, + borderRadius: BorderRadius.circular(NhamRadii.xl), + boxShadow: [_pressed ? NhamShadows.md : NhamShadows.sm], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.refresh, size: 14, color: Colors.white), + const SizedBox(width: 6), + NhamText( + 'logging.failedAttempt.tryAgain'.tr(), + variant: NhamTextVariant.body, + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.xs) + .copyWith(color: Colors.white), + ), + ], + ), + ), + ), + ); + } +} + +/// Quiet "Discard" — wires the previously-unused logging.discard string. +class _DiscardButton extends StatefulWidget { + const _DiscardButton({required this.onTap}); + final VoidCallback onTap; + + @override + State<_DiscardButton> createState() => _DiscardButtonState(); +} + +class _DiscardButtonState extends State<_DiscardButton> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: 'logging.discard'.tr(), + child: GestureDetector( + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: _pressed ? NhamColors.hover : Colors.transparent, + borderRadius: BorderRadius.circular(NhamRadii.xl), + ), + child: NhamText( + 'logging.discard'.tr(), + variant: NhamTextVariant.body, + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.xs) + .copyWith(color: NhamColors.textMuted), + ), + ), + ), + ); + } +} + class _MacroBarData { const _MacroBarData(this.label, this.current, this.target, this.color); final String label; diff --git a/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart b/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart index a7d6e02b..9e401340 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart @@ -26,13 +26,17 @@ class MealInput extends StatefulWidget { required this.controller, required this.onSubmit, this.onCancel, - this.disabled = false, + this.analyzing = false, }); final MealInputController controller; final ValueChanged onSubmit; final VoidCallback? onCancel; - final bool disabled; + + /// While true the action button shows Stop (the run can be cancelled) — but + /// the field stays editable so a new meal can be typed mid-analysis (the + /// requestId-supersede mechanism handles overlap). + final bool analyzing; @override State createState() => _MealInputState(); @@ -95,7 +99,7 @@ class _MealInputState extends State ); } - bool get _canSubmit => _controller.text.trim().isNotEmpty && !widget.disabled; + bool get _canSubmit => _controller.text.trim().isNotEmpty; void _submit() { if (_canSubmit) widget.onSubmit(_controller.text); @@ -139,41 +143,37 @@ class _MealInputState extends State minHeight: _minHeight, maxHeight: _maxHeight, ), - child: Opacity( - opacity: widget.disabled ? 0.5 : 1, - child: TextField( - controller: _controller, - focusNode: _focusNode, - enabled: !widget.disabled, - maxLines: null, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.newline, - style: NhamTextStyles.sansRegular( + child: TextField( + controller: _controller, + focusNode: _focusNode, + maxLines: null, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + height: 20 / 14, // leading-5 (20px) at text-sm (14px) + ).copyWith(color: NhamColors.text), + cursorColor: NhamColors.accent, + decoration: InputDecoration( + isCollapsed: true, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + vertical: 6, + ), // py-1.5 + hintText: 'logging.placeholder'.tr(), + hintStyle: NhamTextStyles.sansRegular( fontSize: NhamFontSize.sm, - height: 20 / 14, // leading-5 (20px) at text-sm (14px) - ).copyWith(color: NhamColors.text), - cursorColor: NhamColors.accent, - decoration: InputDecoration( - isCollapsed: true, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - vertical: 6, - ), // py-1.5 - hintText: 'logging.placeholder'.tr(), - hintStyle: NhamTextStyles.sansRegular( - fontSize: NhamFontSize.sm, - height: 20 / 14, - ).copyWith(color: NhamColors.placeholderMuted40), - ), + height: 20 / 14, + ).copyWith(color: NhamColors.placeholderMuted40), ), ), ), ), const SizedBox(width: NhamSpacing.sp3), - if (widget.disabled && widget.onCancel != null) + if (widget.analyzing && widget.onCancel != null) _ActionButton( icon: Icons.stop, // lucide Square (filled) → Icons.stop iconSize: 14, diff --git a/apps/mobile-flutter/lib/features/logging/widgets/streaming_entry.dart b/apps/mobile-flutter/lib/features/logging/widgets/streaming_entry.dart index e5cc2db4..447aeb30 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/streaming_entry.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/streaming_entry.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -72,8 +74,25 @@ class _StreamingEntryState extends State CurvedAnimation(parent: _pulse, curve: const Cubic(0.4, 0, 0.6, 1)), ); + // A gentle reassurance line appears once the run passes ~20s, so a slow + // pipeline doesn't read as a stall. + bool _showReassurance = false; + late final Timer _reassuranceTimer = Timer( + const Duration(seconds: 20), + () { + if (mounted) setState(() => _showReassurance = true); + }, + ); + + @override + void initState() { + super.initState(); + _reassuranceTimer; // start the timer + } + @override void dispose() { + _reassuranceTimer.cancel(); _spin.dispose(); _pulse.dispose(); super.dispose(); @@ -191,6 +210,15 @@ class _StreamingEntryState extends State NhamText(phaseLabel, variant: NhamTextVariant.phaseLabel), ], ), + // ~20s reassurance line — appears when the pipeline runs long. + if (_showReassurance) ...[ + const SizedBox(height: NhamSpacing.sp2), + NhamText( + 'logging.streaming.stillWorking'.tr(), + variant: NhamTextVariant.small, + style: const TextStyle(color: NhamColors.textMuted), + ), + ], ], ), ), From 1fbdf792f822dca431883fd43fd913ec297da58d Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Thu, 11 Jun 2026 23:22:25 +0700 Subject: [PATCH 09/57] feat(mobile): native haptic vocabulary The app had zero HapticFeedback calls. Define and apply the contract (iOS-first): - selectionClick on tab switches (bottom bar, already), day selection (week strip + timeline picker), segment changes (nutrition range pills, settings tab strip), and stepper taps (meal-entry quantity). - lightImpact on meal submit and sheet-open (sign-out action sheet). - mediumImpact as the success cue on save (meal confirm, weight log, profile). Co-Authored-By: Claude Opus 4.8 --- .../lib/features/dashboard/widgets/compact_weight_log.dart | 1 + .../lib/features/dashboard/widgets/week_strip.dart | 6 +++++- .../lib/features/logging/widgets/feed_area.dart | 3 +++ .../lib/features/logging/widgets/meal_entry.dart | 2 ++ .../lib/features/logging/widgets/meal_input.dart | 6 +++++- .../lib/features/logging/widgets/timeline_picker.dart | 6 +++++- .../lib/features/nutrition/widgets/editorial_header.dart | 6 +++++- .../lib/features/settings/controls/tab_strip.dart | 6 +++++- .../lib/features/settings/screens/account_section.dart | 2 ++ .../lib/features/settings/widgets/profile_form.dart | 2 ++ 10 files changed, 35 insertions(+), 5 deletions(-) diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/compact_weight_log.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/compact_weight_log.dart index 22f29ff6..a0acf3df 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/compact_weight_log.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/compact_weight_log.dart @@ -141,6 +141,7 @@ class _CompactWeightLogState extends ConsumerState { _dirty = false; _pending = false; }); + HapticFeedback.mediumImpact(); // success cue on save _showFeedback( _Feedback(_FeedbackKind.success, tr('dashboard.weightCard.saved')), ); diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/week_strip.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/week_strip.dart index 1754deda..6ca3e7f7 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/week_strip.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/week_strip.dart @@ -13,6 +13,7 @@ import 'dart:math' as math; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../models/dashboard.dart'; @@ -175,7 +176,10 @@ class _DayCell extends StatelessWidget { if (isFuture) return cell0; return GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => onSelectDay(date), + onTap: () { + HapticFeedback.selectionClick(); + onSelectDay(date); + }, child: cell0, ); } diff --git a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart index 3bc138d6..c00c0897 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; @@ -427,6 +428,8 @@ class _FeedAreaState extends ConsumerState { }, ], ); + // Saved — a success haptic confirms the meal landed. + HapticFeedback.mediumImpact(); } catch (_) { // confirm() rolls the optimistic removal back on failure; surface the // error too so it isn't silently swallowed. diff --git a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart index f62e82ae..205e07e4 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../../models/meal.dart'; import '../../../shared/widgets/nham_text.dart'; @@ -54,6 +55,7 @@ class _MealEntryState extends State { } void _change(String itemId, double delta) { + HapticFeedback.selectionClick(); setState(() { _items = applyQuantityChange(_items, _original, itemId, delta); _confirmCoolingDown = true; diff --git a/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart b/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart index 9e401340..b22667a4 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; @@ -102,7 +103,10 @@ class _MealInputState extends State bool get _canSubmit => _controller.text.trim().isNotEmpty; void _submit() { - if (_canSubmit) widget.onSubmit(_controller.text); + if (_canSubmit) { + HapticFeedback.lightImpact(); + widget.onSubmit(_controller.text); + } } @override diff --git a/apps/mobile-flutter/lib/features/logging/widgets/timeline_picker.dart b/apps/mobile-flutter/lib/features/logging/widgets/timeline_picker.dart index dc9b9cad..bd043344 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/timeline_picker.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/timeline_picker.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../../shared/widgets/nham_text.dart'; import '../../../theme/nham_colors.dart'; @@ -62,7 +63,10 @@ class _TimelinePickerState extends State { _didSwipe = false; return; } - if (date != widget.selectedDate) widget.onSelectDate(date); + if (date != widget.selectedDate) { + HapticFeedback.selectionClick(); + widget.onSelectDate(date); + } widget.onExpandedChange(false); } diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/editorial_header.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/editorial_header.dart index f7602459..79a23219 100644 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/editorial_header.dart +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/editorial_header.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../../models/nutrition.dart'; import '../../../shared/widgets/section_eyebrow.dart'; @@ -102,7 +103,10 @@ class EditorialHeader extends StatelessWidget { label: tr('nutrition.range.${_ranges[i]}'), active: resolvedRange == _ranges[i], disabled: disabled, - onTap: () => onRangeChange(_inputFor(_ranges[i])), + onTap: () { + HapticFeedback.selectionClick(); + onRangeChange(_inputFor(_ranges[i])); + }, ), ], ], diff --git a/apps/mobile-flutter/lib/features/settings/controls/tab_strip.dart b/apps/mobile-flutter/lib/features/settings/controls/tab_strip.dart index a55fe155..9f201c52 100644 --- a/apps/mobile-flutter/lib/features/settings/controls/tab_strip.dart +++ b/apps/mobile-flutter/lib/features/settings/controls/tab_strip.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; @@ -42,7 +43,10 @@ class TabStrip extends StatelessWidget { Expanded(child: _TabButton( tab: tabs[i], active: tabs[i].id == active, - onTap: () => onChange(tabs[i].id), + onTap: () { + HapticFeedback.selectionClick(); + onChange(tabs[i].id); + }, )), ], ], diff --git a/apps/mobile-flutter/lib/features/settings/screens/account_section.dart b/apps/mobile-flutter/lib/features/settings/screens/account_section.dart index bdfe5a14..f599c29e 100644 --- a/apps/mobile-flutter/lib/features/settings/screens/account_section.dart +++ b/apps/mobile-flutter/lib/features/settings/screens/account_section.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -50,6 +51,7 @@ class _AccountSectionState extends ConsumerState { } Future _confirmSignOut() async { + HapticFeedback.lightImpact(); // sheet-open cue final confirmed = await showCupertinoModalPopup( context: context, builder: diff --git a/apps/mobile-flutter/lib/features/settings/widgets/profile_form.dart b/apps/mobile-flutter/lib/features/settings/widgets/profile_form.dart index 13986055..c8fdb807 100644 --- a/apps/mobile-flutter/lib/features/settings/widgets/profile_form.dart +++ b/apps/mobile-flutter/lib/features/settings/widgets/profile_form.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../theme/nham_colors.dart'; @@ -118,6 +119,7 @@ class _ProfileFormState extends ConsumerState { final ok = await ref.read(saveProfileProvider.notifier).save(payload); if (!mounted) return; if (ok) { + HapticFeedback.mediumImpact(); // success cue on save _controller.markSaved(); } else { setState(() => _errorText = tr('settings.profilePanel.saveError')); From 8c6c4c60741b2d6a1aab149aca5f900a6a731094 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 13:04:28 +0700 Subject: [PATCH 10/57] =?UTF-8?q?feat(mobile):=20streaming-reveal=20morph?= =?UTF-8?q?=20=E2=80=94=20answer=20lands=20in=20place,=20no=20vanish-then-?= =?UTF-8?q?pop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On completion the streaming card no longer unmounts and waits for a refetch to pop a fresh pending card — the emotional peak of the analysis. Instead the run is held in state (status done + the locally-held stream.result, previously unused) and the card morphs in place: per-row macros are already real, the totals row counts up ~600ms (new CountUpText), and the spinner row slides out as the Edit/Confirm CTA slides up into the slot it vacated. One lightImpact marks the landing. A ScrollController brings the answer into view on submit and on done (nothing scrolled it before). The reveal card carries the user's own raw words as its Lora quote, and on confirm it tears down the stream so it hands off to the refetched persisted card — one continuous object from typed words to saved meal. The refetch/reset now fires only on confirm, not on done. Co-Authored-By: Claude Opus 4.8 --- .../features/logging/widgets/count_up.dart | 92 +++++++++++++ .../features/logging/widgets/feed_area.dart | 123 ++++++++++++++++-- .../features/logging/widgets/meal_entry.dart | 43 ++++-- 3 files changed, 240 insertions(+), 18 deletions(-) create mode 100644 apps/mobile-flutter/lib/features/logging/widgets/count_up.dart diff --git a/apps/mobile-flutter/lib/features/logging/widgets/count_up.dart b/apps/mobile-flutter/lib/features/logging/widgets/count_up.dart new file mode 100644 index 00000000..0dec1230 --- /dev/null +++ b/apps/mobile-flutter/lib/features/logging/widgets/count_up.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import '../../../shared/widgets/nham_text.dart'; + +/// Counts a number up from [from] to [value] over [duration] on first build +/// (and re-counts whenever [value] changes), formatting each frame with [format]. +/// +/// Used by the streaming-reveal morph so the totals row settles into place +/// instead of popping — the emotional peak of the analysis landing. +class CountUpText extends StatefulWidget { + const CountUpText({ + super.key, + required this.value, + required this.format, + this.from = 0, + this.duration = const Duration(milliseconds: 600), + this.curve = Curves.easeOut, + this.variant = NhamTextVariant.body, + this.style, + this.enabled = true, + }); + + final double value; + final String Function(double) format; + final double from; + final Duration duration; + final Curve curve; + final NhamTextVariant variant; + final TextStyle? style; + + /// When false the value renders directly with no animation (reduced motion / + /// non-reveal contexts). + final bool enabled; + + @override + State createState() => _CountUpTextState(); +} + +class _CountUpTextState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _c = + AnimationController(vsync: this, duration: widget.duration); + late Animation _anim = _build(widget.from, widget.value); + + Animation _build(double from, double to) => + Tween(begin: from, end: to) + .chain(CurveTween(curve: widget.curve)) + .animate(_c); + + @override + void initState() { + super.initState(); + if (widget.enabled) { + _c.forward(); + } else { + _c.value = 1; + } + } + + @override + void didUpdateWidget(CountUpText old) { + super.didUpdateWidget(old); + if (old.value != widget.value) { + _anim = _build(_anim.value, widget.value); + _c + ..reset() + ..forward(); + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!widget.enabled) { + return NhamText(widget.format(widget.value), + variant: widget.variant, style: widget.style); + } + return AnimatedBuilder( + animation: _anim, + builder: (context, _) => NhamText( + widget.format(_anim.value), + variant: widget.variant, + style: widget.style, + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart index c00c0897..3b5a9242 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart @@ -48,6 +48,9 @@ class FeedArea extends ConsumerStatefulWidget { class _FeedAreaState extends ConsumerState { final MealInputController _inputController = MealInputController(); + /// Scrolls the freshly-revealed answer into view (nothing scrolled it before). + final ScrollController _scrollController = ScrollController(); + /// The text of the run currently in flight — restored to the composer if it /// fails, so a failed analysis never destroys what the user typed. String? _inFlightText; @@ -55,6 +58,11 @@ class _FeedAreaState extends ConsumerState { /// A failed attempt, rendered as a feed card with "Try again" (terracotta). String? _failedText; + /// The raw text of the just-revealed answer — shown as the morph card's Lora + /// quote so the confirmable card carries the user's own words, not a derived + /// meal name (the streaming→reveal→persisted object stays continuous). + String? _revealRawInput; + /// Inline error for a failed confirm (saving a meal) — not analysis errors, /// which surface as the failed-attempt card. String? _errorText; @@ -62,12 +70,32 @@ class _FeedAreaState extends ConsumerState { LoggingDayArgs get _dayArgs => LoggingDayArgs(widget.profile.userId, widget.date); + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + /// Bring the footer (streaming card / revealed answer) into view. + void _scrollToAnswer() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_scrollController.hasClients) return; + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 400), + curve: const Cubic(0.16, 1, 0.3, 1), + ); + }); + } + void _submit(String text) { setState(() { _failedText = null; + _revealRawInput = null; _inFlightText = text; }); _inputController.clear(); + _scrollToAnswer(); ref .read(streamAnalysisProvider.notifier) .analyze( @@ -91,13 +119,18 @@ class _FeedAreaState extends ConsumerState { } void _onStreamChange(StreamAnalysisState? prev, StreamAnalysisState next) { - // On completion: refetch the day + meal-dates so the stored analysis shows - // as a confirmable card, then clear the local stream. - if (next.status == StreamStatus.done && next.analysisId != null) { + // On completion: hold the stream alive and let the streaming card morph in + // place into a confirmable answer (the reveal — per-row macros already real, + // totals count up, spinner row swaps for Edit/Confirm). One light impact + // marks the moment the answer lands; nothing unmounts. The refetch + reset + // happens only once the user confirms (_confirmReveal). + if (next.status == StreamStatus.done && + next.analysisId != null && + prev?.status != StreamStatus.done) { + _revealRawInput = _inFlightText; _inFlightText = null; - ref.invalidate(loggingDayProvider(_dayArgs)); - ref.invalidate(mealDatesProvider(widget.profile.userId)); - ref.read(streamAnalysisProvider.notifier).reset(); + HapticFeedback.lightImpact(); + _scrollToAnswer(); } // On error: never destroy the typed meal. Restore the raw text into the // composer AND render the failed attempt as a feed card (Try again). @@ -136,6 +169,13 @@ class _FeedAreaState extends ConsumerState { stream.status != StreamStatus.done && stream.status != StreamStatus.error; + // The completed-but-not-yet-confirmed answer, held in place as a morph of + // the streaming card (built from the locally-held stream.result). + final isRevealing = + stream.status == StreamStatus.done && + stream.result != null && + stream.analysisId != null; + final dailyCalories = round0( persistedMeals.fold( 0, @@ -162,10 +202,13 @@ class _FeedAreaState extends ConsumerState { persistedMeals.isEmpty && pendingConfirmations.isEmpty && !isStreaming && + !isRevealing && !hasFailedAttempt; - final hasFooterItems = - pendingConfirmations.isNotEmpty || isStreaming || hasFailedAttempt; + final hasFooterItems = pendingConfirmations.isNotEmpty || + isStreaming || + isRevealing || + hasFailedAttempt; final macroBars = [ _MacroBarData( @@ -250,6 +293,7 @@ class _FeedAreaState extends ConsumerState { persistedMeals: persistedMeals, pendingConfirmations: pendingConfirmations, isStreaming: isStreaming, + isRevealing: isRevealing, stream: stream, hasFooterItems: hasFooterItems, confirmPending: confirmPending, @@ -299,6 +343,7 @@ class _FeedAreaState extends ConsumerState { required List persistedMeals, required List pendingConfirmations, required bool isStreaming, + required bool isRevealing, required StreamAnalysisState stream, required bool hasFooterItems, required bool confirmPending, @@ -330,6 +375,7 @@ class _FeedAreaState extends ConsumerState { // the footer still renders with the gutter padding. if (hasFooterItems) { return SingleChildScrollView( + controller: _scrollController, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.only( top: NhamSpacing.sp3, @@ -340,9 +386,12 @@ class _FeedAreaState extends ConsumerState { child: _Footer( pendingConfirmations: pendingConfirmations, isStreaming: isStreaming, + isRevealing: isRevealing, stream: stream, confirmPending: confirmPending, onConfirm: _confirm, + onConfirmReveal: _confirmReveal, + revealRawInput: _revealRawInput, failedText: failedText, onRetry: onRetry, onDiscardFailed: onDiscardFailed, @@ -375,6 +424,7 @@ class _FeedAreaState extends ConsumerState { } return ListView.separated( + controller: _scrollController, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.only( top: NhamSpacing.sp3, @@ -398,9 +448,12 @@ class _FeedAreaState extends ConsumerState { return _Footer( pendingConfirmations: pendingConfirmations, isStreaming: isStreaming, + isRevealing: isRevealing, stream: stream, confirmPending: confirmPending, onConfirm: _confirm, + onConfirmReveal: _confirmReveal, + revealRawInput: _revealRawInput, failedText: failedText, onRetry: onRetry, onDiscardFailed: onDiscardFailed, @@ -436,15 +489,50 @@ class _FeedAreaState extends ConsumerState { if (mounted) setState(() => _errorText = 'errors.internal'.tr()); } } + + /// Confirm straight from the revealed answer (the morph card). The analysis is + /// already stored server-side (analysis_complete); confirming persists it. On + /// success we tear down the local stream so the revealed card hands off to the + /// refetched persisted card — one continuous object from typed words to saved + /// meal. On failure the stream stays so the user can retry the confirm. + Future _confirmReveal( + String analysisId, List edits) async { + try { + await ref + .read(confirmMealProvider(widget.profile.userId).notifier) + .confirm( + analysisId: analysisId, + mealId: _uuid.v4(), + originDate: widget.date, + edits: edits.isEmpty + ? null + : [ + for (final e in edits) + { + 'mealItemOrder': e.mealItemOrder, + 'newGrams': e.newGrams, + }, + ], + ); + HapticFeedback.mediumImpact(); + _revealRawInput = null; + ref.read(streamAnalysisProvider.notifier).reset(); + } catch (_) { + if (mounted) setState(() => _errorText = 'errors.internal'.tr()); + } + } } class _Footer extends StatelessWidget { const _Footer({ required this.pendingConfirmations, required this.isStreaming, + required this.isRevealing, required this.stream, required this.confirmPending, required this.onConfirm, + required this.onConfirmReveal, + required this.revealRawInput, required this.failedText, required this.onRetry, required this.onDiscardFailed, @@ -452,10 +540,14 @@ class _Footer extends StatelessWidget { final List pendingConfirmations; final bool isStreaming; + final bool isRevealing; + final String? revealRawInput; final StreamAnalysisState stream; final bool confirmPending; final void Function(String analysisId, List edits) onConfirm; + final void Function(String analysisId, List edits) + onConfirmReveal; final String? failedText; final VoidCallback onRetry; final VoidCallback onDiscardFailed; @@ -473,6 +565,7 @@ class _Footer extends StatelessWidget { parsedMeal: pendingConfirmations[i].parsedMeal, busy: confirmPending, isLast: !isStreaming && + !isRevealing && !hasFailed && i == pendingConfirmations.length - 1, onConfirm: (edits) => onConfirm(pendingConfirmations[i].id, edits), @@ -484,6 +577,20 @@ class _Footer extends StatelessWidget { completedItems: stream.completedItems, isLast: !hasFailed, ), + // The completed answer, morphed in place from the streaming card: the + // per-row macros are already real, the totals count up, and the spinner + // row has swapped for Edit/Confirm. Keyed by analysisId so it's the same + // element across the streaming→done transition (no remount). + if (isRevealing) + MealEntry( + key: ValueKey('reveal-${stream.analysisId}'), + rawInput: revealRawInput ?? '', + parsedMeal: stream.result!, + busy: confirmPending, + revealing: true, + isLast: !hasFailed, + onConfirm: (edits) => onConfirmReveal(stream.analysisId!, edits), + ), if (hasFailed) _FailedAttemptCard( rawInput: failedText!, diff --git a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart index 205e07e4..197405d6 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart @@ -11,6 +11,7 @@ import '../../../theme/nham_theme.dart'; import '../../../theme/nham_typography.dart'; import '../logic/format.dart'; import '../logic/meal_utils.dart'; +import 'count_up.dart'; import 'dashed_divider.dart'; import 'entrances.dart'; import 'timeline_rail.dart'; @@ -29,6 +30,7 @@ class MealEntry extends StatefulWidget { required this.onConfirm, this.busy = false, this.isLast = false, + this.revealing = false, }); final ParsedMeal parsedMeal; @@ -37,6 +39,11 @@ class MealEntry extends StatefulWidget { final bool busy; final bool isLast; + /// True for the streaming-reveal morph's first mount: the totals row counts + /// up and the confirm CTA slides in as the spinner row slides out — the + /// continuation of the streaming card, not a fresh pop. + final bool revealing; + @override State createState() => _MealEntryState(); } @@ -47,6 +54,9 @@ class _MealEntryState extends State { bool _editing = false; bool _confirmCoolingDown = false; Timer? _confirmTimer; + // After the first totals count-up, edits should jump rather than re-animate + // from zero — only the reveal's opening frame counts up. + late bool _countUp = widget.revealing; @override void dispose() { @@ -57,6 +67,7 @@ class _MealEntryState extends State { void _change(String itemId, double delta) { HapticFeedback.selectionClick(); setState(() { + _countUp = false; // a manual edit snaps; only the reveal counts up _items = applyQuantityChange(_items, _original, itemId, delta); _confirmCoolingDown = true; }); @@ -69,6 +80,11 @@ class _MealEntryState extends State { bool get _confirmDisabled => widget.busy || (_editing && _confirmCoolingDown); + /// Wrap the confirm CTA in a slide-up entrance only on the reveal morph's + /// opening frame (the spinner row has just slid out of the same slot). + Widget _maybeReveal(Widget child) => + widget.revealing ? FadeInUp(offset: 12, child: child) : child; + @override Widget build(BuildContext context) { final totals = recalculateTotals(_items); @@ -150,8 +166,12 @@ class _MealEntryState extends State { style: const TextStyle(color: NhamColors.textMuted), ), const SizedBox(width: NhamSpacing.sp4), // gap-4 - NhamText(fmtKcal(totals.calories), - variant: NhamTextVariant.numStrong), + CountUpText( + value: totals.calories, + enabled: _countUp, + format: (v) => fmtKcal(v), + variant: NhamTextVariant.numStrong, + ), ], ), ], @@ -160,14 +180,17 @@ class _MealEntryState extends State { ), ), const SizedBox(height: NhamSpacing.sp3), // mt-3 - _ConfirmButton( - editing: _editing, - disabled: _confirmDisabled, - onTap: _confirmDisabled - ? null - : () => widget.onConfirm( - deriveQuantityEdits(_items, _original), - ), + // On reveal the CTA slides up into the slot the spinner row vacated. + _maybeReveal( + _ConfirmButton( + editing: _editing, + disabled: _confirmDisabled, + onTap: _confirmDisabled + ? null + : () => widget.onConfirm( + deriveQuantityEdits(_items, _original), + ), + ), ), ], ), From fa6ecfdbc8b9600c49645d78082526ae6ff9faf6 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 13:07:38 +0700 Subject: [PATCH 11/57] =?UTF-8?q?feat(mobile):=20logging=20ergonomics=20?= =?UTF-8?q?=E2=80=94=2044pt=20taps,=20full-row=20expand,=20pull-to-refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Submit button keeps its 32pt visual but gets a 44pt tap target; edit steppers keep their 28pt visual inside a 40pt target (under 44 so two steppers + the count still fit a narrow row). - The whole persisted-card header row toggles expand, not just the ~24px chevron. - Pull-to-refresh (RefreshIndicator.adaptive → Cupertino spinner on iOS) on the day list, refetching the day + the meal-dates strip — none existed anywhere. - The streaming skeleton no longer pads every meal to 3 ghost dishes: anonymous rows fill in only before any names arrive, and just one (logging one coffee no longer implies over-detection). Co-Authored-By: Claude Opus 4.8 --- .../features/logging/widgets/feed_area.dart | 14 +++++- .../features/logging/widgets/meal_entry.dart | 32 ++++++++----- .../features/logging/widgets/meal_input.dart | 45 +++++++++++-------- .../logging/widgets/persisted_meal_card.dart | 41 +++++++++-------- .../logging/widgets/streaming_entry.dart | 9 ++-- 5 files changed, 87 insertions(+), 54 deletions(-) diff --git a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart index 3b5a9242..ec8f91e1 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart @@ -113,6 +113,13 @@ class _FeedAreaState extends ConsumerState { _submit(text); } + /// Pull-to-refresh: refetch the day + the meal-dates strip. Awaited so the + /// platform refresh control holds its spinner until the data settles. + Future _refresh() async { + ref.invalidate(mealDatesProvider(widget.profile.userId)); + await ref.read(loggingDayProvider(_dayArgs).notifier).refresh(); + } + void _handleSuggestion(String s) { _inputController.setText(s); _inputController.focus(); @@ -423,8 +430,12 @@ class _FeedAreaState extends ConsumerState { ); } - return ListView.separated( + return RefreshIndicator.adaptive( + onRefresh: _refresh, + color: NhamColors.accent, + child: ListView.separated( controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.only( top: NhamSpacing.sp3, @@ -459,6 +470,7 @@ class _FeedAreaState extends ConsumerState { onDiscardFailed: onDiscardFailed, ); }, + ), ); } diff --git a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart index 197405d6..2e280967 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart @@ -322,19 +322,27 @@ class _StepperState extends State<_Stepper> { onTapUp: tappable ? (_) => setState(() => _pressed = false) : null, onTapCancel: tappable ? () => setState(() => _pressed = false) : null, onTap: widget.onTap, - child: Opacity( - opacity: widget.disabled ? 0.4 : 1, // opacity-40 - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), // transition-colors - width: 28, - height: 28, - alignment: Alignment.center, - decoration: BoxDecoration( - color: _pressed ? NhamColors.hover : NhamColors.elev, - borderRadius: BorderRadius.circular(NhamRadii.md), - border: Border.all(color: NhamColors.borderSoft), + // 40pt tap target around the 28pt visual stepper (kept under 44 so two + // steppers + the count value still fit a narrow row without overflow). + child: SizedBox( + width: 40, + height: 40, + child: Center( + child: Opacity( + opacity: widget.disabled ? 0.4 : 1, // opacity-40 + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), // transition-colors + width: 28, + height: 28, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _pressed ? NhamColors.hover : NhamColors.elev, + borderRadius: BorderRadius.circular(NhamRadii.md), + border: Border.all(color: NhamColors.borderSoft), + ), + child: Icon(widget.icon, size: 10, color: NhamColors.textMuted), + ), ), - child: Icon(widget.icon, size: 10, color: NhamColors.textMuted), ), ), ); diff --git a/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart b/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart index b22667a4..191630fd 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart @@ -234,25 +234,32 @@ class _ActionButtonState extends State<_ActionButton> { onTapUp: tappable ? (_) => setState(() => _pressed = false) : null, onTapCancel: tappable ? () => setState(() => _pressed = false) : null, onTap: widget.onTap, - child: AnimatedScale( - scale: _pressed ? 0.95 : 1, - duration: const Duration( - milliseconds: 200, - ), // transition-all duration-200 - child: Opacity( - opacity: widget.enabled ? 1 : 0.3, - child: Container( - width: 32, - height: 32, - alignment: Alignment.center, - decoration: BoxDecoration( - color: _pressed ? NhamColors.btnHover : NhamColors.btn, - borderRadius: BorderRadius.circular(NhamRadii.md), - ), - child: Icon( - widget.icon, - size: widget.iconSize, - color: Colors.white, + // 44pt minimum tap target (HIG) around the 32pt visual button. + child: SizedBox( + width: 44, + height: 44, + child: Center( + child: AnimatedScale( + scale: _pressed ? 0.95 : 1, + duration: const Duration( + milliseconds: 200, + ), // transition-all duration-200 + child: Opacity( + opacity: widget.enabled ? 1 : 0.3, + child: Container( + width: 32, + height: 32, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _pressed ? NhamColors.btnHover : NhamColors.btn, + borderRadius: BorderRadius.circular(NhamRadii.md), + ), + child: Icon( + widget.icon, + size: widget.iconSize, + color: Colors.white, + ), + ), ), ), ), diff --git a/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart b/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart index 48f58130..00e95231 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart @@ -84,26 +84,31 @@ class _PersistedMealCardState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header — only the chevron is the toggle target. - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: NhamText( - meal.rawInput, - variant: NhamTextVariant.mealQuote, - style: const TextStyle( - fontSize: 17, - height: 28 / 17, // leading-7 (28px) + // Header — the whole row is the toggle target (not just the + // ~24px chevron), so the comfortable tap area spans the quote. + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _toggle, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: NhamText( + meal.rawInput, + variant: NhamTextVariant.mealQuote, + style: const TextStyle( + fontSize: 17, + height: 28 / 17, // leading-7 (28px) + ), ), ), - ), - const SizedBox(width: NhamSpacing.sp3), // gap-3 - _ChevronToggle( - expand: _expand, - onTap: _toggle, - ), - ], + const SizedBox(width: NhamSpacing.sp3), // gap-3 + _ChevronToggle( + expand: _expand, + onTap: _toggle, + ), + ], + ), ), // Collapsed summary — fades + collapses height as it expands. diff --git a/apps/mobile-flutter/lib/features/logging/widgets/streaming_entry.dart b/apps/mobile-flutter/lib/features/logging/widgets/streaming_entry.dart index 447aeb30..687548e8 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/streaming_entry.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/streaming_entry.dart @@ -109,11 +109,12 @@ class _StreamingEntryState extends State ? _phaseKey(widget.status).tr() : 'logging.streaming.analyzing'.tr(); - // Anonymous skeleton rows pad the list up to DEFAULT_SKELETON_COUNT=3 while - // the phase is still early (streaming-meal-entry.tsx:72-75). - const defaultSkeletonCount = 3; + // Anonymous skeleton rows only fill in BEFORE any dish names have arrived — + // padding a known small meal (e.g. one coffee) up to 3 ghosts implies + // over-detection. Once names stream in, the real rows carry the count; until + // then a single ghost reads as "one thing, still resolving". final totalKnown = widget.completedItems.length + pendingNames.length; - final anonymousCount = (defaultSkeletonCount - totalKnown).clamp(0, 3); + final anonymousCount = totalKnown > 0 ? 0 : 1; final showAnonymous = widget.status != StreamStatus.assembling && widget.status != StreamStatus.done; From aaa9ebea7d5d4a0de3456f6156c2b8729f5bbdcb Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 13:09:06 +0700 Subject: [PATCH 12/57] =?UTF-8?q?feat(mobile):=20real=20week=20paging=20?= =?UTF-8?q?=E2=80=94=20replace=20the=20dead=20carousel=20transform=20with?= =?UTF-8?q?=20PageView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-rolled three-week carousel never animated: its base offset was a constant translateX(-100%), so paging swapped content with no slide and the 200ms duration was dead. Replace it with a PageView anchored so the current week sits at a fixed index and forward paging is capped at this week (itemCount). Swipe and the chevrons now both slide a full week with a selection haptic on landing; opening the strip jumps to the selected week. Co-Authored-By: Claude Opus 4.8 --- .../logging/widgets/timeline_picker.dart | 138 ++++++++---------- 1 file changed, 62 insertions(+), 76 deletions(-) diff --git a/apps/mobile-flutter/lib/features/logging/widgets/timeline_picker.dart b/apps/mobile-flutter/lib/features/logging/widgets/timeline_picker.dart index bd043344..3ea06991 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/timeline_picker.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/timeline_picker.dart @@ -35,16 +35,16 @@ class TimelinePicker extends StatefulWidget { State createState() => _TimelinePickerState(); } -// Swipe cooldown to mirror the web (mobile-timeline-picker.tsx:32). The 40px -// threshold is satisfied by Flutter's drag-end velocity gesture. -const int _kSwipeCooldownMs = 250; +// A large midpoint so the PageView can page many weeks into the past while the +// current week sits at a known index (weeks are unbounded backwards). +const int _kWeekPageBase = 5000; class _TimelinePickerState extends State { late String _visibleAnchor = _selectedAnchor; - // Suppresses the day-tap that immediately follows a horizontal swipe - // (mirrors didSwipeRef in mobile-timeline-picker.tsx). - bool _didSwipe = false; - int _lastSwipeAtMs = 0; + // The current week lives at [_kWeekPageBase]; each page back is one week + // earlier, each page forward one later (capped at the current week). + late final PageController _pageController = + PageController(initialPage: _kWeekPageBase); String get _currentAnchor => widget.today; String get _selectedAnchor => widget.selectedDate.compareTo(_currentAnchor) > 0 @@ -53,16 +53,33 @@ class _TimelinePickerState extends State { bool get _canNavigateNext => _visibleAnchor.compareTo(_currentAnchor) < 0; + String _anchorForPage(int page) => + addDays(_currentAnchor, (page - _kWeekPageBase) * 7); + + int _pageForAnchor(String anchor) { + final base = dateStringToDate(_currentAnchor); + final target = dateStringToDate(anchor); + final weeks = (target.difference(base).inDays / 7).round(); + return _kWeekPageBase + weeks; + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + void _openStrip() { setState(() => _visibleAnchor = _selectedAnchor); + // Jump the PageView to the selected week without animating the open. + final page = _pageForAnchor(_selectedAnchor); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_pageController.hasClients) _pageController.jumpToPage(page); + }); widget.onExpandedChange(true); } void _selectDay(String date) { - if (_didSwipe) { - _didSwipe = false; - return; - } if (date != widget.selectedDate) { HapticFeedback.selectionClick(); widget.onSelectDate(date); @@ -70,34 +87,27 @@ class _TimelinePickerState extends State { widget.onExpandedChange(false); } - void _navigateToAnchor(String anchor) { - final next = - anchor.compareTo(_currentAnchor) > 0 ? _currentAnchor : anchor; - if (next != _visibleAnchor) setState(() => _visibleAnchor = next); + void _onPageChanged(int page) { + final anchor = _anchorForPage(page); + if (anchor != _visibleAnchor) { + HapticFeedback.selectionClick(); + setState(() => _visibleAnchor = anchor); + } } - void _scrollPrev() => _navigateToAnchor(addDays(_visibleAnchor, -7)); + void _scrollPrev() { + _pageController.previousPage( + duration: const Duration(milliseconds: 280), + curve: const Cubic(0.16, 1, 0.3, 1), + ); + } void _scrollNext() { if (!_canNavigateNext) return; - _navigateToAnchor(addDays(_visibleAnchor, 7)); - } - - // Horizontal-swipe paging with a 40px threshold + 250ms cooldown, mirroring - // handlePointerUp in mobile-timeline-picker.tsx:294-316. A swipe also - // suppresses the subsequent day-tap via [_didSwipe]. - void _onSwipeEnd(DragEndDetails details) { - final dx = details.primaryVelocity ?? 0; - if (dx == 0) return; - final now = DateTime.now().millisecondsSinceEpoch; - if (now - _lastSwipeAtMs < _kSwipeCooldownMs) return; - _lastSwipeAtMs = now; - _didSwipe = true; - if (dx > 0) { - _scrollPrev(); - } else { - _scrollNext(); - } + _pageController.nextPage( + duration: const Duration(milliseconds: 280), + curve: const Cubic(0.16, 1, 0.3, 1), + ); } @override @@ -157,12 +167,6 @@ class _TimelinePickerState extends State { } Widget _buildStrip(String locale, Set mealDates) { - final weekStrips = [ - addDays(_visibleAnchor, -7), - _visibleAnchor, - addDays(_visibleAnchor, 7), - ].map(buildCenteredStripFromAnchor).toList(); - return Row( key: const ValueKey('strip-content'), children: [ @@ -172,43 +176,25 @@ class _TimelinePickerState extends State { color: NhamColors.textMuted, ), const SizedBox(width: 4), // gap-1 - // Three-week carousel: prev/visible/next rendered in a row offset - // translateX(-100%), paging slides 200ms ease-out - // (mobile-timeline-picker.tsx:410-452). + // Real week paging via PageView — swipe AND chevrons slide a full week + // (the old hand-rolled carousel's transform was a constant and never + // animated). Forward is capped at the current week (itemCount). Expanded( - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragEnd: _onSwipeEnd, - child: ClipRect( - child: LayoutBuilder( - builder: (context, c) { - final weekWidth = c.maxWidth; - return AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - // Base offset translateX(-100%): visible week centered. - transform: Matrix4.translationValues(-weekWidth, 0, 0), - width: weekWidth * 3, - child: Row( - children: [ - for (final week in weekStrips) - SizedBox( - width: weekWidth, - child: _WeekRow( - week: week, - today: widget.today, - selectedDate: widget.selectedDate, - mealDates: mealDates, - locale: locale, - onSelect: _selectDay, - ), - ), - ], - ), - ); - }, - ), - ), + child: PageView.builder( + controller: _pageController, + onPageChanged: _onPageChanged, + itemCount: _kWeekPageBase + 1, + itemBuilder: (context, page) { + final week = buildCenteredStripFromAnchor(_anchorForPage(page)); + return _WeekRow( + week: week, + today: widget.today, + selectedDate: widget.selectedDate, + mealDates: mealDates, + locale: locale, + onSelect: _selectDay, + ); + }, ), ), const SizedBox(width: 4), From f338602da4d76782eb2404a09e21d85d4ad2acb7 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 13:13:36 +0700 Subject: [PATCH 13/57] feat(mobile): port the partial-day + legacy-macro notices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web shows two data-completeness notices the Flutter port dropped (their strings shipped translated with zero Dart refs): - legacyMacroWarning: when any persisted meal has unknown macros the day can't be totalled, so the summary ring is replaced by a quiet muted note instead of rendering a wrong total. - partialDayNotice: a past day with real meals but under half the calorie target reads as under-logged, so a Lora-italic terracotta note (ported 1:1 from the web threshold isLikelyPartialDay = calories < 0.5 × target) says it's set aside from trends and invites folding it back in. The partialYesterdayPrompt is deferred (needs a separate yesterday-totals fetch + dismiss state). Co-Authored-By: Claude Opus 4.8 --- .../features/logging/logic/meal_utils.dart | 12 +++ .../features/logging/widgets/feed_area.dart | 99 ++++++++++++++++++- .../mobile-flutter/lib/theme/nham_colors.dart | 1 + 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/apps/mobile-flutter/lib/features/logging/logic/meal_utils.dart b/apps/mobile-flutter/lib/features/logging/logic/meal_utils.dart index 3be89343..57ec8ad9 100644 --- a/apps/mobile-flutter/lib/features/logging/logic/meal_utils.dart +++ b/apps/mobile-flutter/lib/features/logging/logic/meal_utils.dart @@ -8,6 +8,18 @@ import '../../../models/meal.dart'; /// Minimum allowed cooked weight for a grams/ml dish. const double minDishGrams = 10; +/// A day reads as under-logged when its calories fall below this fraction of +/// the target (ported from `lib/nutrition/pattern/completeness.ts`). +const double partialDayFraction = 0.5; + +/// True when a past day's logged calories are positive but below half the +/// target — likely under-logged, so the trends set it aside. +bool isLikelyPartialDay(double calories, int? calorieTarget) { + if (calorieTarget == null || calorieTarget <= 0) return false; + if (calories <= 0) return false; + return calories < partialDayFraction * calorieTarget; +} + /// Sum each item's macros into a single [MacroBreakdown]. MacroBreakdown recalculateTotals(List items) { return items.fold( diff --git a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart index ec8f91e1..79008270 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart @@ -19,6 +19,7 @@ import '../data/logging_providers.dart'; import '../../dashboard/logic/dashboard_format.dart' show formatCount; import '../data/stream_analysis_controller.dart'; import '../logic/format.dart'; +import '../logic/meal_utils.dart' show isLikelyPartialDay; import 'calorie_ring.dart'; import 'dashed_divider.dart'; import 'empty_state.dart'; @@ -171,6 +172,14 @@ class _FeedAreaState extends ConsumerState { final pendingConfirmations = day?.pendingConfirmations ?? const []; + // Legacy meals can carry unknown macros — when any do, the daily summary + // can't be totalled honestly, so we show a quiet note instead of the ring. + final hasUnknownDailyMacros = persistedMeals.any((m) => + m.nutrition.caloriesKcal == null || + m.nutrition.proteinG == null || + m.nutrition.carbohydrateG == null || + m.nutrition.fatG == null); + final isStreaming = stream.status != StreamStatus.idle && stream.status != StreamStatus.done && @@ -217,6 +226,20 @@ class _FeedAreaState extends ConsumerState { isRevealing || hasFailedAttempt; + // A past day with real meals but under half the target reads as + // under-logged; the trends set it aside, so we say so (and offer to fold it + // back in by adding what was missed). Only when nothing is mid-flight. + final isPastDay = widget.date.compareTo(todayDateString()) < 0; + final showPartialDayNotice = isPastDay && + !isLoading && + !dayAsync.hasError && + !hasUnknownDailyMacros && + persistedMeals.isNotEmpty && + pendingConfirmations.isEmpty && + !isStreaming && + !isRevealing && + isLikelyPartialDay(dailyCalories.toDouble(), profile.calorieTarget); + final macroBars = [ _MacroBarData( 'dashboard.protein'.tr(), @@ -253,9 +276,21 @@ class _FeedAreaState extends ConsumerState { NhamSpacing.sp3, NhamSpacing.sp2, ), - child: - isLoading - ? const _MacroSummarySkeleton() + child: isLoading + ? const _MacroSummarySkeleton() + : hasUnknownDailyMacros + // Some legacy meals have unknown macros — the day can't be + // totalled, so say so plainly instead of showing a wrong ring. + ? Align( + alignment: Alignment.centerLeft, + child: NhamText( + 'logging.feedArea.legacyMacroWarning'.tr(), + variant: NhamTextVariant.small, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.eyebrow + 1, + ).copyWith(color: NhamColors.textMuted80), + ), + ) : Row( children: [ Column( @@ -291,6 +326,21 @@ class _FeedAreaState extends ConsumerState { ), ), + // Under-logged past day: a quiet note that it's set aside from trends. + if (showPartialDayNotice) + Padding( + padding: const EdgeInsets.fromLTRB( + NhamSpacing.sp3, + 0, + NhamSpacing.sp3, + NhamSpacing.sp2, + ), + child: _PartialDayNotice( + calories: dailyCalories, + target: profile.calorieTarget, + ), + ), + // The card list. Expanded( child: _buildList( @@ -776,6 +826,49 @@ class _DiscardButtonState extends State<_DiscardButton> { } } +/// A past-day under-logged note: Lora-italic terracotta title + a DM Sans body +/// that names the gap and invites folding the day back in. Ported from the web +/// `PartialDayNotice` (the strings shipped translated but were never rendered). +class _PartialDayNotice extends StatelessWidget { + const _PartialDayNotice({required this.calories, required this.target}); + + final int calories; + final int target; + + @override + Widget build(BuildContext context) { + final locale = context.locale.toString(); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(NhamSpacing.sp3), + decoration: BoxDecoration( + color: NhamColors.surface, + borderRadius: BorderRadius.circular(NhamRadii.containerLg), + border: Border.all(color: NhamColors.borderSoft), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NhamText( + 'logging.feedArea.partialDayNotice.title'.tr(), + variant: NhamTextVariant.italicAccent, + style: const TextStyle(color: NhamColors.danger), + ), + const SizedBox(height: 4), // mt-1 + NhamText( + 'logging.feedArea.partialDayNotice.body'.tr(namedArgs: { + 'calories': formatCount(calories, locale), + 'target': formatCount(target, locale), + }), + variant: NhamTextVariant.small, + style: const TextStyle(color: NhamColors.textMuted), + ), + ], + ), + ); + } +} + class _MacroBarData { const _MacroBarData(this.label, this.current, this.target, this.color); final String label; diff --git a/apps/mobile-flutter/lib/theme/nham_colors.dart b/apps/mobile-flutter/lib/theme/nham_colors.dart index 93042307..aa24e1b0 100644 --- a/apps/mobile-flutter/lib/theme/nham_colors.dart +++ b/apps/mobile-flutter/lib/theme/nham_colors.dart @@ -67,6 +67,7 @@ abstract final class NhamColors { static const Color textMuted50 = Color(0x808B7355); // 50% static const Color textMuted60 = Color(0x998B7355); // 60% static const Color textMuted70 = Color(0xB38B7355); // 70% — macro-bar labels + static const Color textMuted80 = Color(0xCC8B7355); // 80% — legacy-macro note static const Color placeholderMuted40 = Color(0x668B7355); // 40% // ── Hover alpha variants ───────────────────────────────────────────── From 6884182e483e40fecd528bf143ab7ba23a99ab78 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 13:16:51 +0700 Subject: [PATCH 14/57] =?UTF-8?q?feat(mobile):=20swipe-to-remove=20a=20sav?= =?UTF-8?q?ed=20meal=20with=20undo=20=E2=80=94=20close=20the=20correction?= =?UTF-8?q?=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A confirmed meal was a tombstone: nothing could remove it, so one fat-finger confirm permanently poisoned the day's totals and trends. Add an iOS trailing-swipe on persisted cards (terracotta, never red) that removes the meal with a 5-second undo: the day heals immediately (the meal drops out of the ring/bars optimistically) and the DELETE /api/v1/meals/{id} fires only if the undo window closes — undo just restores the snapshot, a server rejection restores it too. New strings (remove/undo/mealRemoved) in both locales. Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 3 ++ apps/mobile-flutter/assets/l10n/vi.json | 3 ++ .../features/logging/widgets/feed_area.dart | 50 +++++++++++++++++++ .../logging/widgets/persisted_meal_card.dart | 46 +++++++++++++++++ 4 files changed, 102 insertions(+) diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index 021b1d6e..206b1865 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -977,6 +977,9 @@ "mealSlot": "Meal type", "confirm": "Save meal", "discard": "Discard", + "remove": "Remove", + "mealRemoved": "Meal removed", + "undo": "Undo", "edit": "Edit", "nutritionSummary": "Nutrition summary", "ingredients": "Ingredients", diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index 5b824534..a49a4a3f 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -976,6 +976,9 @@ "mealSlot": "Bữa ăn", "confirm": "Lưu bữa ăn", "discard": "Bỏ", + "remove": "Xóa", + "mealRemoved": "Đã xóa bữa ăn", + "undo": "Hoàn tác", "edit": "Sửa", "nutritionSummary": "Tóm tắt dinh dưỡng", "ingredients": "Nguyên liệu", diff --git a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart index 79008270..0d5c0088 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart @@ -114,6 +114,55 @@ class _FeedAreaState extends ConsumerState { _submit(text); } + /// Trailing-swipe removal of a saved meal: the day visibly heals (the meal + /// drops out of the totals immediately) with a 5-second undo. The DELETE only + /// fires if the undo window closes — undo just restores the snapshot. No + /// confirm modal; nothing is destroyed within the grace window. + void _removeMeal(PersistedMeal meal) { + final dayNotifier = ref.read(loggingDayProvider(_dayArgs).notifier); + final snapshot = ref.read(loggingDayProvider(_dayArgs)).valueOrNull; + if (snapshot == null) return; + + dayNotifier.removeMeal(meal.id); + + var undone = false; + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger + .showSnackBar( + SnackBar( + duration: const Duration(seconds: 5), + content: NhamText( + 'logging.mealRemoved'.tr(), + variant: NhamTextVariant.body, + style: const TextStyle(color: NhamColors.surface), + ), + action: SnackBarAction( + label: 'logging.undo'.tr(), + textColor: NhamColors.accent, + onPressed: () { + undone = true; + dayNotifier.restore(snapshot); + }, + ), + ), + ) + .closed + .then((_) async { + if (undone) return; + try { + await ref.read(apiClientProvider).delete( + '/api/v1/meals/${Uri.encodeComponent(meal.id)}', + ); + ref.invalidate(mealDatesProvider(widget.profile.userId)); + } catch (_) { + // The server rejected the delete — restore so the feed stays truthful. + dayNotifier.restore(snapshot); + if (mounted) setState(() => _errorText = 'errors.internal'.tr()); + } + }); + } + /// Pull-to-refresh: refetch the day + the meal-dates strip. Awaited so the /// platform refresh control holds its spinner until the data settles. Future _refresh() async { @@ -503,6 +552,7 @@ class _FeedAreaState extends ConsumerState { child: PersistedMealCard( meal: meal, isLast: !hasFooterItems && index == persistedMeals.length - 1, + onRemove: () => _removeMeal(meal), ), ); } diff --git a/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart b/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart index 00e95231..b24f6756 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart @@ -1,9 +1,11 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../../shared/widgets/nham_text.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; +import '../../../theme/nham_typography.dart'; import '../data/logging_models.dart'; import '../logic/format.dart'; import 'dashed_divider.dart'; @@ -20,11 +22,16 @@ class PersistedMealCard extends StatefulWidget { super.key, required this.meal, this.isLast = false, + this.onRemove, }); final PersistedMeal meal; final bool isLast; + /// iOS trailing-swipe removal (terracotta, never red) — fired when the card is + /// dismissed. Null disables the swipe. + final VoidCallback? onRemove; + @override State createState() => _PersistedMealCardState(); } @@ -54,6 +61,43 @@ class _PersistedMealCardState extends State super.dispose(); } + /// Wrap the card in a trailing-swipe-to-remove Dismissible (terracotta, never + /// red) when [onRemove] is set; otherwise return the card untouched. + Widget _maybeDismissible(Widget card) { + final onRemove = widget.onRemove; + if (onRemove == null) return card; + return Dismissible( + key: ValueKey('dismiss-${widget.meal.id}'), + direction: DismissDirection.endToStart, + onDismissed: (_) { + HapticFeedback.mediumImpact(); + onRemove(); + }, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.symmetric(horizontal: NhamSpacing.sp5), + decoration: BoxDecoration( + color: NhamColors.danger, + borderRadius: BorderRadius.circular(NhamRadii.containerLg), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.delete_outline, size: 18, color: Colors.white), + const SizedBox(width: 6), + NhamText( + 'logging.remove'.tr(), + variant: NhamTextVariant.body, + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.xs) + .copyWith(color: Colors.white), + ), + ], + ), + ), + child: card, + ); + } + @override Widget build(BuildContext context) { final meal = widget.meal; @@ -80,6 +124,7 @@ class _PersistedMealCardState extends State variant: NhamTextVariant.timeLabel, ), const SizedBox(height: NhamSpacing.sp2), // mb-2 + _maybeDismissible( _Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -159,6 +204,7 @@ class _PersistedMealCardState extends State ], ), ), + ), ], ), ), From 1e3fa0c7205ef1b8846a6cfc57ec0893fb6d40dc Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 13:18:09 +0700 Subject: [PATCH 15/57] refactor(mobile): delete the dead weight-verdict engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit weight_trend.dart and weight_chart_utils.dart had no importers anywhere (the verdict engine was amputated in the port; weight_chart.dart only references a separate loadingWeightTrend string). Per the founder design direction interpretive prose verdicts stay internal, so the dashboard.progressStatus.* strings they fed go too — both locales. Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 24 +--- apps/mobile-flutter/assets/l10n/vi.json | 24 +--- .../dashboard/logic/weight_chart_utils.dart | 69 ---------- .../dashboard/logic/weight_trend.dart | 119 ------------------ 4 files changed, 2 insertions(+), 234 deletions(-) delete mode 100644 apps/mobile-flutter/lib/features/dashboard/logic/weight_chart_utils.dart delete mode 100644 apps/mobile-flutter/lib/features/dashboard/logic/weight_trend.dart diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index 206b1865..0c1d2710 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -939,29 +939,7 @@ "farUnder": "Far under" }, "caloriesLogged": "kcal logged", - "mealReceiptsHint": "Your meal receipts will show up here", - "progressStatus": { - "insufficient": { - "label": "Tracking started", - "detail": "Log tomorrow to see your trend." - }, - "on_pace": { - "label": "On pace", - "detail": "Your current pace is matching the plan." - }, - "ahead": { - "label": "Ahead of plan", - "detail": "Progress is moving faster than expected." - }, - "behind": { - "label": "Needs attention", - "detail": "The trend is softer than the plan right now." - }, - "stable": { - "label": "Stable", - "detail": "Your weight is staying within a quiet maintenance band." - } - } + "mealReceiptsHint": "Your meal receipts will show up here" }, "logging": { "title": "What did you eat?", diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index a49a4a3f..54dfbf5d 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -938,29 +938,7 @@ "farUnder": "Thiếu nhiều" }, "caloriesLogged": "kcal đã ghi", - "mealReceiptsHint": "Những bữa ăn sẽ hiển thị ở đây", - "progressStatus": { - "insufficient": { - "label": "Đã bắt đầu theo dõi", - "detail": "Ghi cân nặng vào ngày mai để thấy xu hướng." - }, - "on_pace": { - "label": "Đúng kế hoạch", - "detail": "Tốc độ hiện tại của bạn phù hợp với kế hoạch." - }, - "ahead": { - "label": "Vượt kế hoạch", - "detail": "Tiến độ nhanh hơn dự kiến." - }, - "behind": { - "label": "Cần chú ý", - "detail": "Xu hướng hiện tại yếu hơn kế hoạch." - }, - "stable": { - "label": "Ổn định", - "detail": "Cân nặng của bạn đang ổn định ở mức duy trì." - } - } + "mealReceiptsHint": "Những bữa ăn sẽ hiển thị ở đây" }, "logging": { "title": "Bạn đã ăn gì?", diff --git a/apps/mobile-flutter/lib/features/dashboard/logic/weight_chart_utils.dart b/apps/mobile-flutter/lib/features/dashboard/logic/weight_chart_utils.dart deleted file mode 100644 index 76de393b..00000000 --- a/apps/mobile-flutter/lib/features/dashboard/logic/weight_chart_utils.dart +++ /dev/null @@ -1,69 +0,0 @@ -/// Vendored VERBATIM from the RN `lib/dashboard/logic/weight-chart-utils.ts` — -/// keep in sync. -/// -/// Provides the X-axis ticks + a label formatter: W1/W2/…/Now for 30d, -/// month-short/Now for 90d. -library; - -import 'package:intl/intl.dart'; - -import '../../../models/dashboard.dart'; - -/// Result of [buildXTicks]: the tick indices plus a label formatter that takes -/// `(value, idx)` and returns the displayed string. -class XTicks { - const XTicks(this.ticks, this.formatter); - - final List ticks; - final String Function(int value, int idx) formatter; -} - -List _uniqueTicks(List values) { - final seen = {}; - final out = []; - for (final v in values) { - if (seen.add(v)) out.add(v); - } - return out; -} - -/// Builds X-axis ticks + formatter for the weight chart. [count] is the number -/// of data points; [range] selects the labeling strategy. -XTicks buildXTicks( - int count, - WeightRange range, - String locale, - String nowLabel, - String weekPrefix, -) { - if (count < 2) { - return XTicks([0], (_, __) => nowLabel); - } - - if (range == WeightRange.d30) { - final step = count ~/ 4; - final ticks = _uniqueTicks([0, step, step * 2, step * 3, count - 1]); - return XTicks( - ticks, - (_, idx) => idx == ticks.length - 1 ? nowLabel : '$weekPrefix${idx + 1}', - ); - } - - // 90d — show months. - final today = DateTime.now(); - final monthFormatter = DateFormat.MMM(locale); - final ticks = []; - final labels = []; - for (var i = 2; i > 0; i--) { - final d = DateTime(today.year, today.month - i, today.day); - final dayIndex = count - 1 - i * 30; - ticks.add(dayIndex < 0 ? 0 : dayIndex); - labels.add(monthFormatter.format(d)); - } - ticks.add(count - 1); - labels.add(nowLabel); - return XTicks( - _uniqueTicks(ticks), - (_, idx) => idx < labels.length ? labels[idx] : '', - ); -} diff --git a/apps/mobile-flutter/lib/features/dashboard/logic/weight_trend.dart b/apps/mobile-flutter/lib/features/dashboard/logic/weight_trend.dart deleted file mode 100644 index 282a759f..00000000 --- a/apps/mobile-flutter/lib/features/dashboard/logic/weight_trend.dart +++ /dev/null @@ -1,119 +0,0 @@ -/// Vendored VERBATIM from the RN `lib/dashboard/logic/weight-trend.ts` — keep -/// in sync. Produces the weight-chart callout (status / delta / projection). -library; - -import '../../../models/dashboard.dart'; -import '../../../models/weight.dart'; - -/// Pace/trend status — drives the callout label, detail copy, and color. -/// String values match the `dashboard.progressStatus.*` i18n keys. -enum WeightTrendStatus { - insufficient, - onPace, - ahead, - behind, - stable; - - /// i18n key segment (e.g. `on_pace`) under `dashboard.progressStatus`. - String get key => switch (this) { - WeightTrendStatus.insufficient => 'insufficient', - WeightTrendStatus.onPace => 'on_pace', - WeightTrendStatus.ahead => 'ahead', - WeightTrendStatus.behind => 'behind', - WeightTrendStatus.stable => 'stable', - }; -} - -/// The computed trend summary nested in the progress card. -class WeightTrendSummary { - const WeightTrendSummary({ - required this.status, - required this.startWeight, - required this.currentWeight, - required this.expectedEndWeight, - required this.projectedEndWeight, - required this.actualWeeklyRate, - required this.expectedWeeklyRate, - required this.rangeDays, - required this.canProject, - }); - - final WeightTrendStatus status; - final double startWeight; - final double currentWeight; - final double expectedEndWeight; - final double projectedEndWeight; - final double actualWeeklyRate; - final double expectedWeeklyRate; - final int rangeDays; - final bool canProject; -} - -const Map _rangeDays = { - WeightRange.d30: 30, - WeightRange.d90: 90, -}; - -bool _isMovingWithGoal( - double actualWeeklyRate, - double expectedWeeklyRate, - double tolerance, -) => - (actualWeeklyRate - expectedWeeklyRate).abs() <= tolerance; - -/// Builds the weight-chart callout summary. Mirrors web -/// `buildWeightTrendSummary` exactly. -WeightTrendSummary buildWeightTrendSummary({ - required List weights, - required double periodStartWeight, - required double expectedEndWeight, - required WeightGoalDirection goalDirection, - required WeightRange range, - int? elapsedDays, -}) { - final rangeDays = _rangeDays[range]!; - final startWeight = weights.isNotEmpty ? weights.first : periodStartWeight; - final currentWeight = weights.isNotEmpty ? weights.last : startWeight; - final expectedWeeklyRate = - ((expectedEndWeight - periodStartWeight) / rangeDays) * 7; - final hasElapsedDays = elapsedDays != null && elapsedDays > 0; - final actualWeeklyRate = ((currentWeight - startWeight) / - (hasElapsedDays ? elapsedDays : rangeDays)) * - 7; - final canProject = - weights.length >= 3 && hasElapsedDays && elapsedDays < rangeDays; - final projectedEndWeight = canProject - ? currentWeight + actualWeeklyRate * ((rangeDays - elapsedDays) / 7) - : currentWeight; - final tolerance = (expectedWeeklyRate.abs() * 0.2).clamp(0.1, double.infinity); - - final WeightTrendStatus status; - if (weights.length < 2) { - status = WeightTrendStatus.insufficient; - } else if (goalDirection == WeightGoalDirection.flat) { - status = actualWeeklyRate.abs() <= 0.1 - ? WeightTrendStatus.stable - : WeightTrendStatus.behind; - } else if (_isMovingWithGoal(actualWeeklyRate, expectedWeeklyRate, tolerance)) { - status = WeightTrendStatus.onPace; - } else if ((goalDirection == WeightGoalDirection.down && - actualWeeklyRate < expectedWeeklyRate) || - (goalDirection == WeightGoalDirection.up && - actualWeeklyRate > expectedWeeklyRate)) { - status = WeightTrendStatus.ahead; - } else { - status = WeightTrendStatus.behind; - } - - return WeightTrendSummary( - status: status, - startWeight: startWeight, - currentWeight: currentWeight, - expectedEndWeight: expectedEndWeight, - projectedEndWeight: projectedEndWeight, - actualWeeklyRate: actualWeeklyRate, - expectedWeeklyRate: expectedWeeklyRate, - rangeDays: rangeDays, - canProject: canProject, - ); -} From 286b26943d9605be7270a426d3009d4bc40d1eac Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 13:20:13 +0700 Subject: [PATCH 16/57] =?UTF-8?q?feat(mobile):=20craft=20pass=20=E2=80=94?= =?UTF-8?q?=20Lora-never-bold,=20real=20ellipsis,=20branded=20toasts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete the serifSemiBold Lora variant (the bible's hardest type rule) and convert its three callers (display, numDisplay, the calorie-ring figure) to serifRegular so a bold-Lora regression can't recur. - Real ellipsis … replaces literal "..." across both locales (loading/streaming phase strings, placeholders). - Add a SnackBarThemeData (espresso pill, cream text, accent action, soft radius, floating) so sign-out errors and the undo toast render branded instead of the stock dark-gray Material pill. Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 20 +++++++++---------- apps/mobile-flutter/assets/l10n/vi.json | 20 +++++++++---------- .../dashboard/widgets/calorie_ring.dart | 2 +- .../lib/shared/widgets/nham_text.dart | 7 ++++--- apps/mobile-flutter/lib/theme/nham_theme.dart | 15 ++++++++++++++ .../lib/theme/nham_typography.dart | 8 ++------ 6 files changed, 42 insertions(+), 30 deletions(-) diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index 0c1d2710..71271733 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -6,7 +6,7 @@ "finish": "Finish", "save": "Save", "cancel": "Cancel", - "loading": "Loading...", + "loading": "Loading…", "error": "Something went wrong", "retry": "Try again", "search": "Search", @@ -734,7 +734,7 @@ "analysis": "AI nutrition analysis", "highConfidence": "High confidence", "totalCalories": "Total calories", - "inputPlaceholder": "Describe your meal...", + "inputPlaceholder": "Describe your meal…", "smartContext": "Smart context", "smartContextText": "Uses your region, cooking habits, and meal text to tighten estimates." } @@ -943,10 +943,10 @@ }, "logging": { "title": "What did you eat?", - "placeholder": "Describe your meal...", + "placeholder": "Describe your meal…", "submit": "Analyze", "stopAnalyzing": "Stop analyzing", - "analyzing": "Analyzing your meal...", + "analyzing": "Analyzing your meal…", "breakfast": "Breakfast", "brunch": "Brunch", "lunch": "Lunch", @@ -994,12 +994,12 @@ } }, "streaming": { - "connecting": "Connecting...", - "decomposing": "Breaking down your meal...", - "matching": "Matching ingredients...", - "estimating": "Estimating nutrition...", - "assembling": "Putting it all together...", - "analyzing": "Analyzing...", + "connecting": "Connecting…", + "decomposing": "Breaking down your meal…", + "matching": "Matching ingredients…", + "estimating": "Estimating nutrition…", + "assembling": "Putting it all together…", + "analyzing": "Analyzing…", "stillWorking": "Still working on it — almost there." }, "failedAttempt": { diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index 54dfbf5d..07c968fa 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -6,7 +6,7 @@ "finish": "Hoàn tất", "save": "Lưu", "cancel": "Hủy", - "loading": "Đang tải...", + "loading": "Đang tải…", "error": "Đã xảy ra lỗi", "retry": "Thử lại", "search": "Tìm kiếm", @@ -733,7 +733,7 @@ "analysis": "Phân tích dinh dưỡng bằng AI", "highConfidence": "Độ tin cậy cao", "totalCalories": "Tổng calo", - "inputPlaceholder": "Mô tả bữa ăn của bạn...", + "inputPlaceholder": "Mô tả bữa ăn của bạn…", "smartContext": "Ngữ cảnh thông minh", "smartContextText": "Dùng vùng miền, thói quen nấu ăn và mô tả bữa ăn để siết chặt ước tính." } @@ -942,10 +942,10 @@ }, "logging": { "title": "Bạn đã ăn gì?", - "placeholder": "Mô tả bữa ăn...", + "placeholder": "Mô tả bữa ăn…", "submit": "Phân tích", "stopAnalyzing": "Dừng phân tích", - "analyzing": "Đang phân tích bữa ăn...", + "analyzing": "Đang phân tích bữa ăn…", "breakfast": "Sáng", "brunch": "Sáng muộn", "lunch": "Trưa", @@ -993,12 +993,12 @@ } }, "streaming": { - "connecting": "Đang kết nối...", - "decomposing": "Đang tách bữa ăn của bạn...", - "matching": "Đang khớp nguyên liệu...", - "estimating": "Đang ước tính dinh dưỡng...", - "assembling": "Đang tổng hợp kết quả...", - "analyzing": "Đang phân tích...", + "connecting": "Đang kết nối…", + "decomposing": "Đang tách bữa ăn của bạn…", + "matching": "Đang khớp nguyên liệu…", + "estimating": "Đang ước tính dinh dưỡng…", + "assembling": "Đang tổng hợp kết quả…", + "analyzing": "Đang phân tích…", "stillWorking": "Vẫn đang xử lý — sắp xong rồi." }, "failedAttempt": { diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/calorie_ring.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/calorie_ring.dart index 95fdcc22..f1f73a0b 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/calorie_ring.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/calorie_ring.dart @@ -126,7 +126,7 @@ class _DefaultCenter extends StatelessWidget { children: [ Text( formatCount(value, locale), - style: NhamTextStyles.serifSemiBold(fontSize: 17, height: 1) + style: NhamTextStyles.serifRegular(fontSize: 17, height: 1) .copyWith(color: NhamColors.text, letterSpacing: 0), ), const SizedBox(height: 2), diff --git a/apps/mobile-flutter/lib/shared/widgets/nham_text.dart b/apps/mobile-flutter/lib/shared/widgets/nham_text.dart index 9abdc8a6..5b49dd31 100644 --- a/apps/mobile-flutter/lib/shared/widgets/nham_text.dart +++ b/apps/mobile-flutter/lib/shared/widgets/nham_text.dart @@ -70,8 +70,9 @@ class NhamText extends StatelessWidget { static TextStyle styleFor(NhamTextVariant variant) { switch (variant) { case NhamTextVariant.display: - // serifSemiBold, display size, display leading + tracking. - return NhamTextStyles.serifSemiBold( + // Lora regular (never bold — the bible's hardest type rule), display + // size, display leading + tracking. + return NhamTextStyles.serifRegular( fontSize: NhamFontSize.display, height: NhamLeading.display, ).copyWith( @@ -130,7 +131,7 @@ class NhamText extends StatelessWidget { height: NhamLeading.relaxed, ).copyWith(color: NhamColors.text); case NhamTextVariant.numDisplay: - return NhamTextStyles.serifSemiBold(fontSize: NhamFontSize.h2).copyWith( + return NhamTextStyles.serifRegular(fontSize: NhamFontSize.h2).copyWith( letterSpacing: NhamTracking.display, color: NhamColors.text, fontFeatures: const [_tabularNums], diff --git a/apps/mobile-flutter/lib/theme/nham_theme.dart b/apps/mobile-flutter/lib/theme/nham_theme.dart index d25f86e9..ed0d777b 100644 --- a/apps/mobile-flutter/lib/theme/nham_theme.dart +++ b/apps/mobile-flutter/lib/theme/nham_theme.dart @@ -183,6 +183,21 @@ abstract final class NhamTheme { thickness: 1, space: 0, ), + // Branded toasts: espresso pill, cream text, soft radius — not the stock + // dark-gray Material pill (sign-out errors, the undo toast, etc.). + snackBarTheme: SnackBarThemeData( + backgroundColor: NhamColors.text, // espresso + contentTextStyle: NhamTextStyles.body().copyWith( + fontSize: NhamFontSize.sm, + color: NhamColors.surface, // cream + ), + actionTextColor: NhamColors.accent, + behavior: SnackBarBehavior.floating, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(NhamRadii.xl), + ), + ), ); } } diff --git a/apps/mobile-flutter/lib/theme/nham_typography.dart b/apps/mobile-flutter/lib/theme/nham_typography.dart index ffed7ed4..62627695 100644 --- a/apps/mobile-flutter/lib/theme/nham_typography.dart +++ b/apps/mobile-flutter/lib/theme/nham_typography.dart @@ -55,12 +55,8 @@ abstract final class NhamTextStyles { height: height, ); - static TextStyle serifSemiBold({double? fontSize, double? height}) => - GoogleFonts.lora( - fontWeight: FontWeight.w600, - fontSize: fontSize, - height: height, - ); + // No serifSemiBold: Lora is never above 400 (the bible's hardest type rule). + // Deleted so a bold-Lora regression can't be reintroduced by reaching for it. static TextStyle serifItalic({double? fontSize, double? height}) => GoogleFonts.lora( From 15390972e0aa55019fe74333f7cb31d90bbf0b17 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 13:21:49 +0700 Subject: [PATCH 17/57] refactor(mobile): Lucide glyphs in the logging surface (one icon DNA) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the Material Icons in the logging widgets for their Lucide one-for-one equivalents (square, arrowUp, minus, plus, check, pencil, refreshCw, circleAlert, chevronLeft/Right/Down, calendar, trash2, utensilsCrossed) — the package was already installed and the shell uses it, so the feed no longer ships a second icon DNA. The intended mappings were already noted in code comments. Co-Authored-By: Claude Opus 4.8 --- .../lib/features/logging/widgets/empty_state.dart | 3 ++- .../lib/features/logging/widgets/feed_area.dart | 7 ++++--- .../lib/features/logging/widgets/meal_entry.dart | 9 +++++---- .../lib/features/logging/widgets/meal_input.dart | 5 +++-- .../features/logging/widgets/persisted_meal_card.dart | 5 +++-- .../lib/features/logging/widgets/timeline_picker.dart | 7 ++++--- 6 files changed, 21 insertions(+), 15 deletions(-) diff --git a/apps/mobile-flutter/lib/features/logging/widgets/empty_state.dart b/apps/mobile-flutter/lib/features/logging/widgets/empty_state.dart index be94cf07..5dd9cfc5 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/empty_state.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/empty_state.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../shared/widgets/nham_text.dart'; import '../../../theme/nham_colors.dart'; @@ -36,7 +37,7 @@ class EmptyState extends StatelessWidget { borderRadius: BorderRadius.circular(NhamRadii.xl), // rounded-xl ), child: const Icon( - Icons.restaurant, // lucide UtensilsCrossed → Icons.restaurant + LucideIcons.utensilsCrossed, // lucide UtensilsCrossed → LucideIcons.utensilsCrossed size: 20, color: NhamColors.textMuted, ), diff --git a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart index 0d5c0088..a6b40133 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; @@ -819,7 +820,7 @@ class _RetryButtonState extends State<_RetryButton> { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.refresh, size: 14, color: Colors.white), + const Icon(LucideIcons.refreshCw, size: 14, color: Colors.white), const SizedBox(width: 6), NhamText( 'logging.failedAttempt.tryAgain'.tr(), @@ -1251,7 +1252,7 @@ class _LoggingDayErrorState extends StatelessWidget { const Padding( padding: EdgeInsets.only(top: 2), // mt-0.5 child: Icon( - Icons.error_outline, // lucide AlertCircle + LucideIcons.circleAlert, // lucide AlertCircle size: 20, color: _red600, ), @@ -1309,7 +1310,7 @@ class _RetryPill extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ const Icon( - Icons.refresh, // lucide RefreshCw + LucideIcons.refreshCw, // lucide RefreshCw size: 16, color: _LoggingDayErrorState._red950, ), diff --git a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart index 2e280967..0dcf381e 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/services.dart'; import '../../../models/meal.dart'; @@ -239,7 +240,7 @@ class _ItemRow extends StatelessWidget { child: Row( children: [ _Stepper( - icon: Icons.remove, // lucide Minus + icon: LucideIcons.minus, // lucide Minus disabled: minusDisabled, onTap: minusDisabled ? null @@ -258,7 +259,7 @@ class _ItemRow extends StatelessWidget { ), const SizedBox(width: 2), _Stepper( - icon: Icons.add, // lucide Plus + icon: LucideIcons.plus, // lucide Plus onTap: () => onChange(item.id, step), ), const SizedBox(width: NhamSpacing.sp2), // gap-2 @@ -388,7 +389,7 @@ class _EditPill extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon( - editing ? Icons.check : Icons.edit_outlined, // Check / Pencil + editing ? LucideIcons.check : LucideIcons.pencil, // Check / Pencil size: 12, color: editing ? NhamColors.accent : NhamColors.textMuted, ), @@ -475,7 +476,7 @@ class _ConfirmButtonState extends State<_ConfirmButton> { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.check, size: 14, color: fg), + Icon(LucideIcons.check, size: 14, color: fg), const SizedBox(width: 6), // gap-1.5 NhamText( 'logging.confirm'.tr(), diff --git a/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart b/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart index 191630fd..01027866 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/services.dart'; import '../../../theme/nham_colors.dart'; @@ -179,14 +180,14 @@ class _MealInputState extends State const SizedBox(width: NhamSpacing.sp3), if (widget.analyzing && widget.onCancel != null) _ActionButton( - icon: Icons.stop, // lucide Square (filled) → Icons.stop + icon: LucideIcons.square, // lucide Square (filled) → LucideIcons.square iconSize: 14, label: 'common.cancel'.tr(), onTap: widget.onCancel, ) else _ActionButton( - icon: Icons.arrow_upward, // lucide ArrowUp → Icons.arrow_upward + icon: LucideIcons.arrowUp, // lucide ArrowUp → LucideIcons.arrowUp iconSize: 16, label: 'logging.submit'.tr(), enabled: _canSubmit, diff --git a/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart b/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart index b24f6756..656dc54f 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/services.dart'; import '../../../shared/widgets/nham_text.dart'; @@ -83,7 +84,7 @@ class _PersistedMealCardState extends State child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.delete_outline, size: 18, color: Colors.white), + const Icon(LucideIcons.trash2, size: 18, color: Colors.white), const SizedBox(width: 6), NhamText( 'logging.remove'.tr(), @@ -245,7 +246,7 @@ class _ChevronToggleState extends State<_ChevronToggle> { child: RotationTransition( turns: Tween(begin: 0, end: 0.5).animate(widget.expand), child: Icon( - Icons.keyboard_arrow_down, // lucide ChevronDown + LucideIcons.chevronDown, // lucide ChevronDown size: 16, color: _pressed ? NhamColors.text : NhamColors.textMuted60, ), diff --git a/apps/mobile-flutter/lib/features/logging/widgets/timeline_picker.dart b/apps/mobile-flutter/lib/features/logging/widgets/timeline_picker.dart index 3ea06991..4c733e0b 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/timeline_picker.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/timeline_picker.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/services.dart'; import '../../../shared/widgets/nham_text.dart'; @@ -171,7 +172,7 @@ class _TimelinePickerState extends State { key: const ValueKey('strip-content'), children: [ _NavButton( - icon: Icons.chevron_left, // lucide ChevronLeft + icon: LucideIcons.chevronLeft, // lucide ChevronLeft onTap: _scrollPrev, color: NhamColors.textMuted, ), @@ -199,7 +200,7 @@ class _TimelinePickerState extends State { ), const SizedBox(width: 4), _NavButton( - icon: Icons.chevron_right, // lucide ChevronRight + icon: LucideIcons.chevronRight, // lucide ChevronRight onTap: _canNavigateNext ? _scrollNext : null, color: _canNavigateNext ? NhamColors.textMuted @@ -279,7 +280,7 @@ class _ChipButtonState extends State<_ChipButton> { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.calendar_today_outlined, + const Icon(LucideIcons.calendar, size: 14, color: NhamColors.accent), // lucide Calendar const SizedBox(width: NhamSpacing.sp2), // gap-2 Flexible( From 29486767b2110542e5ac5233b5396d4a0ffd636a Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 13:31:24 +0700 Subject: [PATCH 18/57] feat(mobile): dashboard paged day-viewer + first-run collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a past day used to refetch the entire dashboard bundle (profile + 90d heatmap + weight) just to swap the meal list, swap the card for a loading line, keep "remaining" framing, show no date, and replay every mount animation. Now the Today card is a PageView synced to the week strip: - tap a strip day or swipe the card to browse (selection haptic on swipe; the strip already fired one on tap), - per-day slice fetch via the lightweight /api/v1/logging/day endpoint — today still reads the warm bundle so the first page costs no round-trip, - a date line in the card (Today / Yesterday / localized weekday-month-day), - the remaining figure counts up ~300ms on day-swap (reuses CountUpText), macro bars + ring retarget without remounting, - the pager animates its own height to the active page so the surrounding ListView reflows instead of clipping taller days. First-run: when the user has never logged anything (zero meals today AND zero historical logged/partial heatmap cells — there's no explicit has-logged-before flag, so this is the reliable gate; an existing user is never mistaken for first-run), the card collapses to one Lora "What did you eat today?" question with no ring and no "% on track". Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 5 +- apps/mobile-flutter/assets/l10n/vi.json | 5 +- .../dashboard/data/dashboard_providers.dart | 21 ++ .../dashboard/screens/dashboard_screen.dart | 240 +++++++++++++++++- .../dashboard/widgets/today_section.dart | 88 ++++++- 5 files changed, 340 insertions(+), 19 deletions(-) diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index 71271733..a45635d6 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -939,7 +939,10 @@ "farUnder": "Far under" }, "caloriesLogged": "kcal logged", - "mealReceiptsHint": "Your meal receipts will show up here" + "mealReceiptsHint": "Your meal receipts will show up here", + "firstRunQuestion": "What did you eat today?", + "firstRunHint": "Describe your first meal — Nhẩm estimates the rest.", + "yesterday": "Yesterday" }, "logging": { "title": "What did you eat?", diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index 07c968fa..1f52faa2 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -938,7 +938,10 @@ "farUnder": "Thiếu nhiều" }, "caloriesLogged": "kcal đã ghi", - "mealReceiptsHint": "Những bữa ăn sẽ hiển thị ở đây" + "mealReceiptsHint": "Những bữa ăn sẽ hiển thị ở đây", + "firstRunQuestion": "Hôm nay bạn đã ăn gì?", + "firstRunHint": "Mô tả bữa đầu tiên — Nhẩm lo phần còn lại.", + "yesterday": "Hôm qua" }, "logging": { "title": "Bạn đã ăn gì?", diff --git a/apps/mobile-flutter/lib/features/dashboard/data/dashboard_providers.dart b/apps/mobile-flutter/lib/features/dashboard/data/dashboard_providers.dart index 588e9ca0..509ed3d9 100644 --- a/apps/mobile-flutter/lib/features/dashboard/data/dashboard_providers.dart +++ b/apps/mobile-flutter/lib/features/dashboard/data/dashboard_providers.dart @@ -88,6 +88,27 @@ final loggingDayProvider = return bundle.day; }); +/// Per-day meal slice for the dashboard's paged day-viewer. +/// +/// Browsing a past day must NOT refetch the whole 90d heatmap + profile + weight +/// bundle just to swap the meal list. This hits the same lightweight +/// `GET /api/v1/logging/day` endpoint the logging surface uses and decodes only +/// the [LoggingDayData] (persistedMeals) slice the Today card renders. The +/// *anchor* day (today) still reads off the already-warm bundle so the first +/// page costs no extra round-trip; only swiping to another day fetches. +final dashboardDayProvider = + FutureProvider.family((ref, args) async { + final api = ref.watch(apiClientProvider); + final tz = localTimezoneOffsetMinutes(); + final date = Uri.encodeComponent(args.date); + return runWithRetry(() async { + final json = await api.get>( + '/api/v1/logging/day?date=$date&tz=$tz', + ); + return LoggingDayData.fromJson(json); + }); +}); + /// `useWeightSummary('30d')` → the 30-day weight summary slice. The mobile /// chart is fixed at 30d (the section header shows the passive "30 days" /// label), so this reads `bundle.weightSummary` directly. diff --git a/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart b/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart index a98b11a9..3198b909 100644 --- a/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart +++ b/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart @@ -13,12 +13,16 @@ library; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../models/dashboard.dart'; import '../../../shared/widgets/widgets.dart'; import '../../../data/session_provider.dart'; import '../../../shell/app_header.dart'; import '../../../theme/nham_theme.dart'; +import '../../logging/logic/timeline_utils.dart' hide WeekStrip; import '../data/dashboard_providers.dart'; import '../logic/dashboard_format.dart'; import '../widgets/adherence_heatmap.dart'; @@ -94,6 +98,7 @@ class DashboardScreen extends ConsumerWidget { args: args, todayDate: todayDate, targets: _targetsFor(data), + isFirstRun: _isFirstRun(data), ), ), ), @@ -102,6 +107,26 @@ class DashboardScreen extends ConsumerWidget { ); } + /// True when the user has never logged anything, ever — so the dashboard can + /// collapse to the one first-run card and suppress the "% on track" framing. + /// + /// There is no explicit "has-logged-before" flag on the bundle, so this gates + /// on "zero meals today AND zero historical logged/partial heatmap cells" — a + /// real meal on any day in the 90d window produces a logged-or-partial cell, + /// so an existing user is never mistaken for first-run. + bool _isFirstRun(DashboardBundle data) { + if (data.day.persistedMeals.isNotEmpty) return false; + for (final row in data.heatmap.cells) { + for (final cell in row) { + if (cell.status == HeatmapCellStatus.logged || + cell.status == HeatmapCellStatus.partial) { + return false; + } + } + } + return true; + } + DockTargets _targetsFor(DashboardBundle data) { final p = data.profile; return DockTargets( @@ -128,6 +153,7 @@ class _Content extends StatefulWidget { required this.args, required this.todayDate, required this.targets, + required this.isFirstRun, }); /// Today-anchored args — Progress (30d), Consistency (90d) and the week @@ -135,21 +161,67 @@ class _Content extends StatefulWidget { final DashboardArgs args; final String todayDate; final DockTargets targets; + final bool isFirstRun; @override State<_Content> createState() => _ContentState(); } class _ContentState extends State<_Content> { - // The day whose summary the Today card shows; tapping a strip day changes it - // (browse day-card only). Defaults to today. - late String _selectedDate = widget.todayDate; + /// The browsable days, oldest → today (future days aren't pageable). Today is + /// always the last page; the strip centers today at index 3, so the past half + /// (indices 0..3) is exactly today and the three days before it. + late final List _days = + buildCenteredStripFromAnchor(widget.todayDate).days.sublist(0, 4); + late final int _todayPage = _days.length - 1; + + // The day whose summary the Today card shows; tapping a strip day or swiping + // the card changes it. Defaults to today (the last page). + late int _page = _todayPage; + late final PageController _pageController = + PageController(initialPage: _todayPage); + + String get _selectedDate => _days[_page]; + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + /// Strip tap → animate the day card to that page (selection haptic fires in + /// the strip's own GestureDetector). Future / out-of-window days are ignored. + void _onSelectDay(String date) { + final idx = _days.indexOf(date); + if (idx < 0 || idx == _page) return; + _pageController.animateToPage( + idx, + duration: const Duration(milliseconds: 280), + curve: const Cubic(0.16, 1, 0.3, 1), + ); + } + + /// Swipe → update selection + fire the same selection haptic the strip uses. + void _onPageChanged(int page) { + if (page == _page) return; + HapticFeedback.selectionClick(); + setState(() => _page = page); + } + + /// A human date line for the card: "Today" / "Yesterday" / a localized + /// weekday-month-day (e.g. "Mon, Jun 9"). + String _dateLabel(String date, String locale) { + if (date == widget.todayDate) return tr('dashboard.today'); + if (date == addDays(widget.todayDate, -1)) { + return tr('dashboard.yesterday'); + } + return DateFormat('EEE, MMM d', locale).format(dateStringToDate(date)); + } @override Widget build(BuildContext context) { final bottomInset = MediaQuery.of(context).padding.bottom; - // Today card follows the selected day; everything else stays on today. - final selectedArgs = (userId: widget.args.userId, date: _selectedDate); + final locale = context.locale.toString(); return Stack( children: [ @@ -162,17 +234,32 @@ class _ContentState extends State<_Content> { bottom: bottomInset + 96, ), children: [ - // SECTION 1 — week strip + today summary (greeting now lives in the - // header row, beside the hamburger). + // SECTION 1 — week strip + the paged day-viewer (greeting now lives + // in the header row, beside the hamburger). WeekStrip( args: widget.args, todayDate: widget.todayDate, selectedDate: _selectedDate, - onSelectDay: (d) => setState(() => _selectedDate = d), + onSelectDay: _onSelectDay, ), Padding( padding: const EdgeInsets.only(bottom: NhamSpacing.sp4), - child: TodaySection(args: selectedArgs, targets: widget.targets), + child: widget.isFirstRun + ? TodaySection( + args: widget.args, + targets: widget.targets, + dateLabel: _dateLabel(widget.todayDate, locale), + isFirstRun: true, + ) + : _DayPager( + controller: _pageController, + days: _days, + todayPage: _todayPage, + userId: widget.args.userId, + targets: widget.targets, + onPageChanged: _onPageChanged, + dateLabel: (d) => _dateLabel(d, locale), + ), ), // SECTION 2 — Progress. _Section( @@ -203,6 +290,141 @@ class _ContentState extends State<_Content> { } } +/// The paged day-viewer: a PageView of [TodaySection]s, one per browsable day, +/// synced to the week strip. Swiping or tapping a strip day moves the page. +/// +/// A PageView needs a bounded height, but each day's card differs in height +/// (different meal counts). Each page reports its measured height; the pager +/// animates its own height to the active page so the surrounding ListView +/// reflows smoothly instead of the card being clipped or over-tall. +class _DayPager extends StatefulWidget { + const _DayPager({ + required this.controller, + required this.days, + required this.todayPage, + required this.userId, + required this.targets, + required this.onPageChanged, + required this.dateLabel, + }); + + final PageController controller; + final List days; + final int todayPage; + final String userId; + final DockTargets targets; + final ValueChanged onPageChanged; + final String Function(String date) dateLabel; + + @override + State<_DayPager> createState() => _DayPagerState(); +} + +class _DayPagerState extends State<_DayPager> { + late final List _heights = + List.filled(widget.days.length, null); + late int _active = widget.todayPage; + + void _report(int index, double height) { + if (_heights[index] == height) return; + // Defer the setState out of the layout/build phase. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _heights[index] = height); + }); + } + + @override + Widget build(BuildContext context) { + // While a page is unmeasured, fall back to the tallest known height (or a + // sensible minimum) so the first frame isn't zero-height. + final known = _heights.whereType(); + final fallback = known.isEmpty + ? 280.0 + : known.reduce((a, b) => a > b ? a : b); + final height = _heights[_active] ?? fallback; + + return AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: const Cubic(0.16, 1, 0.3, 1), + alignment: Alignment.topCenter, + child: SizedBox( + height: height, + child: PageView.builder( + controller: widget.controller, + itemCount: widget.days.length, + onPageChanged: (p) { + setState(() => _active = p); + widget.onPageChanged(p); + }, + itemBuilder: (context, index) { + final date = widget.days[index]; + return _MeasuredPage( + onHeight: (h) => _report(index, h), + child: TodaySection( + args: (userId: widget.userId, date: date), + targets: widget.targets, + dateLabel: widget.dateLabel(date), + isToday: index == widget.todayPage, + ), + ); + }, + ), + ), + ); + } +} + +/// Reports its child's laid-out height once per layout pass, top-aligned so the +/// card sits at the top of the (taller) page viewport. +class _MeasuredPage extends StatelessWidget { + const _MeasuredPage({required this.child, required this.onHeight}); + final Widget child; + final ValueChanged onHeight; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: _SizeReporter( + onHeight: onHeight, + child: child, + ), + ); + } +} + +class _SizeReporter extends SingleChildRenderObjectWidget { + const _SizeReporter({required this.onHeight, required super.child}); + final ValueChanged onHeight; + + @override + _SizeReporterRender createRenderObject(BuildContext context) => + _SizeReporterRender(onHeight); + + @override + void updateRenderObject( + BuildContext context, _SizeReporterRender renderObject) { + renderObject.onHeight = onHeight; + } +} + +class _SizeReporterRender extends RenderProxyBox { + _SizeReporterRender(this.onHeight); + ValueChanged onHeight; + double? _last; + + @override + void performLayout() { + super.performLayout(); + final h = size.height; + if (h != _last) { + _last = h; + onHeight(h); + } + } +} + /// A dashboard section: `mb-4 gap-1.5` (16px bottom margin, 6px inner gap). class _Section extends StatelessWidget { const _Section({required this.children}); diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart index 64609189..6205e309 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart @@ -13,6 +13,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; +import '../../logging/widgets/count_up.dart'; import '../data/dashboard_providers.dart'; import '../data/logging_day.dart'; import '../logic/dashboard_format.dart'; @@ -41,22 +42,80 @@ class DockTargets { const double _valueColumnWidth = 68; class TodaySection extends ConsumerWidget { - const TodaySection({super.key, required this.args, required this.targets}); + const TodaySection({ + super.key, + required this.args, + required this.targets, + required this.dateLabel, + this.isToday = true, + this.isFirstRun = false, + }); final DashboardArgs args; final DockTargets targets; + /// A human date line shown at the top of the card ("Today", "Yesterday", or a + /// localized "Mon, Jun 9"). The card always says which day it is showing. + final String dateLabel; + + /// Whether [args] is today. Today reads off the already-warm dashboard bundle + /// (no extra round-trip); other days fetch their own light per-day slice. + final bool isToday; + + /// The user has never logged anything, ever — collapse to the first-run card. + final bool isFirstRun; + @override Widget build(BuildContext context, WidgetRef ref) { - final async = ref.watch(loggingDayProvider(args)); + if (isFirstRun) return const _FirstRunCard(); + + // Today reads the bundle's day slice (warm cache, seam parity with the rest + // of the dashboard); any other day fetches its own slice so browsing never + // refetches the 90d heatmap + profile + weight bundle. + final provider = + isToday ? loggingDayProvider(args) : dashboardDayProvider(args); + final async = ref.watch(provider); return async.when( loading: () => SectionState(message: tr('dashboard.todayLoading')), error: (_, __) => SectionState( message: tr('dashboard.todayLoadError'), actionLabel: tr('dashboard.retry'), - onAction: () => ref.invalidate(dashboardBundleProvider(args)), + onAction: () => ref.invalidate(provider), + ), + data: (day) => _Dock(day: day, targets: targets, dateLabel: dateLabel), + ); + } +} + +/// First-run collapse: a single Lora question, no ring, no "% on track". Shown +/// only when the user has never logged a meal (zero today AND zero history). +class _FirstRunCard extends StatelessWidget { + const _FirstRunCard(); + + @override + Widget build(BuildContext context) { + return _FadeInDown( + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: NhamSpacing.sp6, horizontal: NhamSpacing.sp4), + decoration: BoxDecoration( + color: kCardSurface, + borderRadius: BorderRadius.circular(kCardRadius), + boxShadow: const [kCardShadow], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(tr('dashboard.firstRunQuestion'), style: dashHeadline()), + const SizedBox(height: NhamSpacing.sp2), + Text( + tr('dashboard.firstRunHint'), + style: dashBody(color: kInkSecondary), + ), + ], + ), ), - data: (day) => _Dock(day: day, targets: targets), ); } } @@ -70,10 +129,15 @@ class _MacroBarData { } class _Dock extends StatelessWidget { - const _Dock({required this.day, required this.targets}); + const _Dock({ + required this.day, + required this.targets, + required this.dateLabel, + }); final LoggingDayData day; final DockTargets targets; + final String dateLabel; @override Widget build(BuildContext context) { @@ -116,6 +180,11 @@ class _Dock extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // Date line — the card always names the day it is showing. + Padding( + padding: const EdgeInsets.only(bottom: NhamSpacing.sp3), + child: Text(dateLabel, style: dashEyebrow(color: kInkSecondary)), + ), // (a) Hero: big calories number on the left, ring on the right. Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -140,10 +209,13 @@ class _Dock extends StatelessWidget { textBaseline: TextBaseline.alphabetic, children: [ Flexible( - child: Text( - _fmt(remaining.abs(), locale), + // Counts the remaining figure up on day-swap (~300ms) + // so paging settles in place instead of popping. + child: CountUpText( + value: remaining.abs().toDouble(), + duration: const Duration(milliseconds: 300), style: dashHero(), - maxLines: 1, + format: (v) => _fmt(v.round(), locale), ), ), const SizedBox(width: 6), From df279a19d88c7f8ceaab1f8e451250a38c36ef1f Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 13:32:07 +0700 Subject: [PATCH 19/57] fix(mobile): suppress dashboard "% on track" until a day is logged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new user saw "0% on track" over an empty heatmap grid — a percentage computed from zero data. The header line is now suppressed entirely until at least one logged day exists, so the consistency section reads as a blank grid waiting to fill, not a 0% failure. Pairs with the first-run dashboard collapse. Co-Authored-By: Claude Opus 4.8 --- .../dashboard/widgets/adherence_heatmap.dart | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart index d2b90c5c..6461a6bc 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart @@ -127,9 +127,11 @@ class _HeatmapBodyState extends State<_HeatmapBody> return data.cells.isNotEmpty ? data.cells[0].length : 0; } - int get _adherenceRate { + /// (onTrackPercent, loggedDayCount). The percent is meaningless with zero + /// logged days — the count gates whether the "% on track" line renders. + ({int percent, int loggedDays}) get _adherence { final data = widget.data; - if (data == null) return 0; + if (data == null) return (percent: 0, loggedDays: 0); var onTarget = 0; var total = 0; for (final row in data.cells) { @@ -140,7 +142,8 @@ class _HeatmapBodyState extends State<_HeatmapBody> } } } - return total > 0 ? ((onTarget / total) * 100).round() : 0; + final percent = total > 0 ? ((onTarget / total) * 100).round() : 0; + return (percent: percent, loggedDays: total); } double _cellSize(double contentWidth, int numWeeks) { @@ -178,13 +181,15 @@ class _HeatmapBodyState extends State<_HeatmapBody> return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header: "{percent}% on track". + // Header: "{percent}% on track". Suppressed entirely until there's + // at least one logged day — a new user shouldn't read "0% on + // track" over an empty grid (the value is computed from no data). Padding( padding: const EdgeInsets.only(bottom: NhamSpacing.sp2), child: Text( - data != null + (data != null && _adherence.loggedDays > 0) ? tr('dashboard.adherenceHeatmap.onTrack', - namedArgs: {'percent': '$_adherenceRate'}) + namedArgs: {'percent': '${_adherence.percent}'}) : ' ', style: dashMeta(color: kInk, tabular: true), ), From f0bfebb67bfbeff6718c28e3191779baabe716a2 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 13:34:02 +0700 Subject: [PATCH 20/57] =?UTF-8?q?fix(mobile):=20nutrition=20iOS=20hands=20?= =?UTF-8?q?=E2=80=94=20bounce=20+=20pull-to-refresh,=20keep=20content=20on?= =?UTF-8?q?=20refetch=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nutrition screen was the only surface forced to ClampingScrollPhysics, had no pull-to-refresh, and a failed refetch nuked the editorial stack the user was reading (guard() replaced the value with a bare error). - swap Clamping for AlwaysScrollable + Bouncing, matching the rest of the app - add a RefreshIndicator (pull-to-refresh re-runs the current range) - refetch() now uses copyWithPrevious so a failure retains the prior overview underneath the error — content stays put - wire the previously-unused nutrition.errors.overviewToast key: a refetch failure that still holds content surfaces as a toast instead of a blank page Co-Authored-By: Claude Opus 4.8 --- .../nutrition_overview_provider.dart | 27 +++++++++--- .../nutrition/screens/nutrition_screen.dart | 41 +++++++++++++++++-- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/apps/mobile-flutter/lib/features/nutrition/providers/nutrition_overview_provider.dart b/apps/mobile-flutter/lib/features/nutrition/providers/nutrition_overview_provider.dart index e7baf4c3..4f845498 100644 --- a/apps/mobile-flutter/lib/features/nutrition/providers/nutrition_overview_provider.dart +++ b/apps/mobile-flutter/lib/features/nutrition/providers/nutrition_overview_provider.dart @@ -60,15 +60,30 @@ class NutritionOverviewNotifier } /// Refetch the current range — mirrors `query.refetch()`. Keeps the existing - /// data visible (sets `isFetching` semantics via `AsyncValue.isLoading` on a - /// guarded refresh that preserves the previous value). - Future refetch() async { + /// data visible while loading, and — critically — on FAILURE retains the + /// previous overview underneath the error (`copyWithPrevious`) so a flaky + /// refetch doesn't blank out the editorial stack the user is reading. The + /// screen reads `hasValue` to keep rendering content and surfaces the failure + /// as a toast instead. + /// + /// Returns true on success, false on failure (so the caller can toast). + Future refetch() async { final range = arg; - state = await AsyncValue.guard(() async { + final previous = state; + // Show the in-flight (isLoading) state over the current value. + state = const AsyncValue.loading() + .copyWithPrevious(previous); + try { final overview = await _fetch(range); _lastOverview = overview; - return overview; - }); + state = AsyncData(overview); + return true; + } catch (error, stack) { + // Retain the previous data under the error. + state = AsyncError(error, stack) + .copyWithPrevious(previous); + return false; + } } } diff --git a/apps/mobile-flutter/lib/features/nutrition/screens/nutrition_screen.dart b/apps/mobile-flutter/lib/features/nutrition/screens/nutrition_screen.dart index 3047738b..7e145231 100644 --- a/apps/mobile-flutter/lib/features/nutrition/screens/nutrition_screen.dart +++ b/apps/mobile-flutter/lib/features/nutrition/screens/nutrition_screen.dart @@ -7,6 +7,7 @@ import '../../../models/nutrition.dart'; import '../../../shared/widgets/nham_primitives.dart'; import '../../../shared/widgets/nham_text.dart'; import '../../../shell/app_header.dart'; +import '../../../theme/nham_colors.dart'; import '../providers/nutrition_overview_provider.dart'; import '../widgets/background_section.dart'; import '../widgets/daily_rhythm.dart'; @@ -53,6 +54,28 @@ class _NutritionScreenState extends ConsumerState { ); } + // A refetch that fails while we still hold a prior overview keeps the + // content on screen (copyWithPrevious) and surfaces the failure as a toast + // instead of nuking what the user is reading. + ref.listen>( + nutritionOverviewProvider(_range), + (prev, next) { + if (next.hasError && next.hasValue) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + content: NhamText( + tr('nutrition.errors.overviewToast'), + variant: NhamTextVariant.body, + style: const TextStyle(color: NhamColors.surface), + ), + ), + ); + } + }, + ); + final async = ref.watch(nutritionOverviewProvider(_range)); // `isFetching`: a refetch in flight while previous data is shown. final isFetching = async.isLoading && async.hasValue; @@ -66,10 +89,20 @@ class _NutritionScreenState extends ConsumerState { child: AppHeader(), ), Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 24, 20, 80), - physics: const ClampingScrollPhysics(), - child: _buildBody(async, isFetching), + // Native bounce physics (the only screen that was forced to + // Clamping) + pull-to-refresh, consistent with the rest of the app. + child: RefreshIndicator( + onRefresh: () => ref + .read(nutritionOverviewProvider(_range).notifier) + .refetch(), + color: NhamColors.accent, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 80), + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + child: _buildBody(async, isFetching), + ), ), ), ], From 5c703ec0efff978d4d76fb762109c27ebc3e28f4 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 13:43:17 +0700 Subject: [PATCH 21/57] =?UTF-8?q?feat(mobile):=20real=20nutrient=20detail?= =?UTF-8?q?=20route=20=E2=80=94=20sparkline=20+=20full=20food=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a steady/background nutrient row pushed a duplicate of the overview bar. Push a real Cupertino detail route (swipe-back, selection haptic) that delivers what the row hint promises: - A point figure + target progress, re-tokened on dashboard_tokens (solid white card, one radius, DM Sans scale, point value only — no ranges or confidence surfaced). - A 7/30/90d coverage sparkline that degrades to discrete dots when coverage is thin (the trend.pointMode copy was written for exactly this). We hold window averages, not a per-day series, so it renders the resolved point honestly rather than fabricating a line. - Food candidates as FULL rows — name, serving, rationale, and caution per Vietnamese food — the content the overview renders as name-only pills. The API already ships all four keys per candidate. Deletes the orphaned inline nutrient_detail.dart (the duplicate bar). Co-Authored-By: Claude Opus 4.8 --- .../screens/nutrient_detail_screen.dart | 240 ++++++++++++++++++ .../nutrition/widgets/food_candidate_row.dart | 81 ++++++ .../nutrition/widgets/nutrient_detail.dart | 116 --------- .../nutrition/widgets/nutrient_row.dart | 164 +++++------- .../nutrition/widgets/nutrient_sparkline.dart | 132 ++++++++++ 5 files changed, 514 insertions(+), 219 deletions(-) create mode 100644 apps/mobile-flutter/lib/features/nutrition/screens/nutrient_detail_screen.dart create mode 100644 apps/mobile-flutter/lib/features/nutrition/widgets/food_candidate_row.dart delete mode 100644 apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_detail.dart create mode 100644 apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_sparkline.dart diff --git a/apps/mobile-flutter/lib/features/nutrition/screens/nutrient_detail_screen.dart b/apps/mobile-flutter/lib/features/nutrition/screens/nutrient_detail_screen.dart new file mode 100644 index 00000000..65cee028 --- /dev/null +++ b/apps/mobile-flutter/lib/features/nutrition/screens/nutrient_detail_screen.dart @@ -0,0 +1,240 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../features/dashboard/widgets/dashboard_tokens.dart'; +import '../../../models/nutrition.dart'; +import '../../../shared/widgets/target_progress_bar.dart'; +import '../logic/helpers.dart'; +import '../providers/food_candidates_provider.dart'; +import '../widgets/food_candidate_row.dart'; +import '../widgets/nutrient_sparkline.dart'; + +/// The real nutrient detail route — pushed (Cupertino swipe-back) from a steady +/// / background nutrient row. Replaces the old inline "tap reveals a duplicate +/// bar" expand. +/// +/// Three bands, all re-tokened on `dashboard_tokens` (solid cards, one radius, +/// 5-size DM Sans, three inks): +/// 1. The point figure + target progress (point value only — no ranges or +/// confidence, per founder direction). +/// 2. A coverage sparkline degrading to dots when coverage is thin. +/// 3. Food candidates as FULL rows — name, serving, rationale, caution — the +/// content the overview screen renders as name-only pills. +class NutrientDetailScreen extends ConsumerWidget { + const NutrientDetailScreen({super.key, required this.card}); + + final NutrientCardData card; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final locale = context.locale.languageCode; + final label = tr(card.labelKey); + final hasTarget = card.percentOfTarget != null; + final percent = card.percentOfTarget ?? 0; + final showExceed = shouldShowExceed(card.nutrientType, card.percentOfTarget); + final limited = + card.displayState == ConfidenceDisplayState.limitedData || + card.displayState == ConfidenceDisplayState.insufficientData; + + // The point figure: today's resolved average, the single trustworthy number. + final figure = card.averagePerDay == null + ? '—' + : formatLocalizedNumber(card.averagePerDay!, locale); + + return CupertinoPageScaffold( + backgroundColor: kPage, + navigationBar: CupertinoNavigationBar( + backgroundColor: kPage, + border: const Border(bottom: BorderSide(color: kHairline, width: 0.5)), + middle: Text(label, style: dashBody(weight: FontWeight.w600)), + ), + child: SafeArea( + top: false, + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(20, 20, 20, 80), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── Band 1: the figure + target progress ────────────────── + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: kCardSurface, + borderRadius: BorderRadius.circular(kCardRadius), + boxShadow: const [kCardShadow], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(figure, style: dashHero()), + const SizedBox(width: 6), + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text(card.unit, style: dashMeta()), + ), + ], + ), + const SizedBox(height: 4), + Text( + tr('nutrition.rhythm.avgPerLoggedDay').toUpperCase(), + style: dashEyebrow(), + ), + if (hasTarget) ...[ + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: TargetProgressBar( + percentOfTarget: card.percentOfTarget, + showExceed: showExceed, + duration: const Duration(milliseconds: 550), + semanticLabel: tr( + 'nutrition.focus.spotlightBarAria', + namedArgs: { + 'label': label, + 'pct': percent.round().toString(), + }, + ), + ), + ), + if (card.target != null) ...[ + const SizedBox(width: 12), + Text( + '${formatLocalizedNumber(card.target!, locale)} ${card.unit}', + style: dashMeta(tabular: true), + ), + ], + ], + ), + ], + ], + ), + ), + + // ── Band 2: coverage sparkline ──────────────────────────── + const SizedBox(height: 24), + Text(tr('nutrition.card.targetProgress').toUpperCase(), + style: dashEyebrow()), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: kCardSurface, + borderRadius: BorderRadius.circular(kCardRadius), + boxShadow: const [kCardShadow], + ), + child: NutrientSparkline( + points: _coveragePoints(card, limited), + semanticLabel: tr('nutrition.trend.chartLabel'), + ), + ), + + // ── Band 3: food candidates as full rows ────────────────── + if (card.supportsCandidates) ...[ + const SizedBox(height: 24), + Text(tr('nutrition.candidates.title').toUpperCase(), + style: dashEyebrow()), + const SizedBox(height: 6), + Text( + tr('nutrition.candidates.description'), + style: dashMeta(color: kInkSecondary), + ), + const SizedBox(height: 12), + _FoodCandidates(nutrient: card.nutrient), + ], + ], + ), + ), + ), + ); + } + + /// Coverage points for the sparkline. We hold a single resolved average, not + /// a per-day series, so we surface that one point (normalized against target + /// when there is one). Limited/insufficient data → no point (the rail). + List _coveragePoints(NutrientCardData card, bool limited) { + if (limited || card.averagePerDay == null) return const []; + final target = card.target; + if (target != null && target > 0) { + return [(card.averagePerDay! / target).clamp(0.0, 1.0)]; + } + // No target → a mid-rail marker so the point still reads as "logged". + return const [0.5]; + } +} + +/// The candidate query, rendered as full rows (loading / error / empty / data). +class _FoodCandidates extends ConsumerWidget { + const _FoodCandidates({required this.nutrient}); + + final NutritionNutrientKey nutrient; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(foodCandidatesProvider(nutrient)); + + return async.when( + loading: () => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text(tr('nutrition.candidates.loading'), style: dashMeta()), + ), + error: (_, __) => _ErrorLine( + message: tr('nutrition.candidates.error'), + retryLabel: tr('nutrition.candidates.retry'), + onRetry: () => ref.invalidate(foodCandidatesProvider(nutrient)), + ), + data: (response) { + final candidates = response?.candidates ?? const []; + if (candidates.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text(tr('nutrition.candidates.empty'), style: dashMeta()), + ); + } + return Column( + children: [ + for (var i = 0; i < candidates.length; i++) ...[ + if (i > 0) const SizedBox(height: 12), + FoodCandidateRow(candidate: candidates[i]), + ], + ], + ); + }, + ); + } +} + +class _ErrorLine extends StatelessWidget { + const _ErrorLine({ + required this.message, + required this.retryLabel, + required this.onRetry, + }); + + final String message; + final String retryLabel; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text(message, style: dashMeta(color: kInkSecondary)), + ), + const SizedBox(width: 12), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onRetry, + child: Text(retryLabel, style: dashMeta(color: kInk)), + ), + ], + ); + } +} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/food_candidate_row.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/food_candidate_row.dart new file mode 100644 index 00000000..41aa7d4f --- /dev/null +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/food_candidate_row.dart @@ -0,0 +1,81 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../features/dashboard/widgets/dashboard_tokens.dart'; +import '../providers/candidates_response.dart'; + +/// A food-source candidate rendered as a FULL row — name, serving, rationale, +/// and (when present) a caution note. The API ships all four per Vietnamese +/// food (`candidates...{name,serving,rationale,caution}`); the +/// overview screen only renders name-only pills, so the detail route surfaces +/// the real content the audit calls "the App-Store-feature screen". +/// +/// Re-tokened on `dashboard_tokens`: solid white card, one radius, the 5-size +/// DM Sans scale, three inks. No alpha surfaces, no eyebrow. +class FoodCandidateRow extends StatelessWidget { + const FoodCandidateRow({super.key, required this.candidate}); + + final FoodSourceCandidate candidate; + + @override + Widget build(BuildContext context) { + final cautionKey = candidate.cautionKey; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: kCardSurface, + borderRadius: BorderRadius.circular(kCardRadius), + boxShadow: const [kCardShadow], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Name — the row's one emphasis. + Text(tr(candidate.nameKey), style: dashBody(weight: FontWeight.w600)), + const SizedBox(height: 6), + // Serving — how much, in everyday Vietnamese-meal terms. + Text(tr(candidate.servingKey), style: dashMeta()), + const SizedBox(height: 10), + // Rationale — why this food earns a place. + Text( + tr(candidate.rationaleKey), + style: dashBody(color: kInkSecondary), + ), + if (cautionKey != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: kTrack, + borderRadius: BorderRadius.circular(kCardRadius - 4), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 1), + child: Icon( + LucideIcons.info, + size: 14, + color: kInkSecondary, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + tr(cautionKey), + style: dashMeta(color: kInkSecondary), + ), + ), + ], + ), + ), + ], + ], + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_detail.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_detail.dart deleted file mode 100644 index 3cc10412..00000000 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_detail.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; - -import '../../../models/nutrition.dart'; -import '../../../shared/widgets/target_progress_bar.dart'; -import '../../../theme/nham_colors.dart'; -import '../../../theme/nham_typography.dart'; -import '../logic/helpers.dart'; -import '../logic/status.dart'; -import 'food_chip_row.dart'; - -/// RN port of `apps/mobile/src/components/nutrition/rows/nutrient-detail.tsx`. -class NutrientDetail extends StatelessWidget { - const NutrientDetail({super.key, required this.card}); - - final NutrientCardData card; - - @override - Widget build(BuildContext context) { - final locale = context.locale.languageCode; - final label = tr(card.labelKey); - - final hasTarget = card.percentOfTarget != null; - final percent = card.percentOfTarget ?? 0; - final showExceed = shouldShowExceed(card.nutrientType, card.percentOfTarget); - - final avgText = card.averagePerDay == null - ? null - : tr('nutrition.focus.averageLine', namedArgs: { - 'value': formatLocalizedNumber(card.averagePerDay!, locale), - 'unit': card.unit, - 'source': tr(card.targetSourceLabelKey), - }); - - final avgStyle = NhamTextStyles.sansMedium(fontSize: 14).copyWith( - color: NhamColors.text, - fontFeatures: const [FontFeature.tabularFigures()], - ); - - final children = []; - - if (hasTarget) { - children.add( - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: TargetProgressBar( - percentOfTarget: card.percentOfTarget, - showExceed: showExceed, - duration: const Duration(milliseconds: 550), - semanticLabel: tr('nutrition.focus.spotlightBarAria', - namedArgs: { - 'label': label, - 'pct': percent.round().toString(), - }), - ), - ), - if (card.target != null) ...[ - const SizedBox(width: 12), - Text( - '${formatLocalizedNumber(card.target!, locale)} ${card.unit}', - style: NhamTextStyles.sansRegular(fontSize: 11).copyWith( - color: NhamColors.textMuted, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ], - ], - ), - ); - if (avgText != null) { - children.add(Text(avgText, style: avgStyle)); - } - } else if (avgText != null) { - children.add(Text(avgText, style: avgStyle)); - } else { - children.add( - Text( - tr('nutrition.card.noData'), - style: NhamTextStyles.sansRegular(fontSize: 14) - .copyWith(color: NhamColors.textMuted), - ), - ); - } - - if (showChips(card)) { - children.add( - FoodChipRow( - nutrient: card.nutrient, - variant: FoodChipVariant.spotlight, - limit: 5, - ), - ); - } - - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: _withGap(children, 16), - ), - ); - } -} - -/// Inserts vertical [gap] between [children] (RN `gap-4` = 16px). -List _withGap(List children, double gap) { - if (children.isEmpty) return children; - final out = []; - for (var i = 0; i < children.length; i++) { - if (i > 0) out.add(SizedBox(height: gap)); - out.add(children[i]); - } - return out; -} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_row.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_row.dart index efa7b6d1..a346e20c 100644 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_row.dart +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_row.dart @@ -1,15 +1,20 @@ import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../models/nutrition.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_typography.dart'; import '../logic/helpers.dart'; import '../logic/status.dart'; -import 'nutrient_detail.dart'; +import '../screens/nutrient_detail_screen.dart'; -/// RN port of `apps/mobile/src/components/nutrition/rows/nutrient-row.tsx` -/// (expandable). lucide `ChevronRight` → `Icons.chevron_right`. +/// A steady / background nutrient row. Tapping now PUSHES the real detail route +/// (Cupertino swipe-back, selection haptic) instead of inline-expanding into a +/// duplicate bar — the detail screen carries the sparkline + full food rows the +/// row hint promises ("tap a row to see its trend, foods, and any caveats"). class NutrientRow extends StatefulWidget { const NutrientRow({super.key, required this.card}); @@ -20,9 +25,17 @@ class NutrientRow extends StatefulWidget { } class _NutrientRowState extends State { - bool _open = false; bool _pressed = false; + void _open() { + HapticFeedback.selectionClick(); + Navigator.of(context).push( + CupertinoPageRoute( + builder: (_) => NutrientDetailScreen(card: widget.card), + ), + ); + } + @override Widget build(BuildContext context) { final card = widget.card; @@ -61,110 +74,55 @@ class _NutrientRowState extends State { bottom: BorderSide(color: NhamColors.borderBiscotti40), ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - onTap: () => setState(() => _open = !_open), - child: ColoredBox( - color: _pressed ? NhamColors.hover40 : Colors.transparent, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: _open, + child: ColoredBox( + color: _pressed ? NhamColors.hover40 : Colors.transparent, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NhamTextStyles.sansRegular(fontSize: 14) + .copyWith(color: NhamColors.text), + ), ), - child: Row( - children: [ - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: dotColor, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NhamTextStyles.sansRegular(fontSize: 14) - .copyWith(color: NhamColors.text), - ), - ), - const SizedBox(width: 12), - Text( - figure, - style: NhamTextStyles.sansRegular(fontSize: 12).copyWith( - color: figureColor, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - const SizedBox(width: 12), - AnimatedRotation( - turns: _open ? 0.25 : 0, - duration: const Duration(milliseconds: 200), - child: const Icon( - Icons.chevron_right, - size: 16, - color: NhamColors.stone, - ), - ), - ], + const SizedBox(width: 12), + Text( + figure, + style: NhamTextStyles.sansRegular(fontSize: 12).copyWith( + color: figureColor, + fontFeatures: const [FontFeature.tabularFigures()], + ), ), - ), + const SizedBox(width: 12), + const Icon( + LucideIcons.chevronRight, + size: 16, + color: NhamColors.stone, + ), + ], ), ), - // Web height/opacity collapse, duration 0.28. AnimatedSize reflows the - // list as the detail mounts; the panel fades over the same window. - AnimatedSize( - duration: const Duration(milliseconds: 280), - // Web ease:[0.22, 1, 0.36, 1] — expo-out style. - curve: const Cubic(0.22, 1.0, 0.36, 1.0), - alignment: Alignment.topCenter, - child: _open - ? Padding( - padding: - const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: _FadeIn(child: NutrientDetail(card: card)), - ) - : const SizedBox(width: double.infinity), - ), - ], + ), ), ); } } - -/// One-shot fade-in mirroring Reanimated `FadeIn.duration(280)`. -class _FadeIn extends StatefulWidget { - const _FadeIn({required this.child}); - - final Widget child; - - @override - State<_FadeIn> createState() => _FadeInState(); -} - -class _FadeInState extends State<_FadeIn> - with SingleTickerProviderStateMixin { - late final AnimationController _c = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 280), - )..forward(); - - @override - void dispose() { - _c.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => - FadeTransition(opacity: _c, child: widget.child); -} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_sparkline.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_sparkline.dart new file mode 100644 index 00000000..e4d0b09d --- /dev/null +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_sparkline.dart @@ -0,0 +1,132 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../../../features/dashboard/widgets/dashboard_tokens.dart'; +import '../../../theme/nham_colors.dart'; + +/// A 7/30/90-day coverage sparkline that degrades to discrete dots when the +/// window holds too few points to imply a continuous trend. +/// +/// The nutrition overview ships window AVERAGES, not a per-day series — so this +/// renders the resolved data point(s) honestly in **point mode** (dots, the +/// `nutrition.trend.pointMode` copy was written for exactly this) rather than +/// fabricating a line across days we don't have. When a real per-day series is +/// wired (backend mapper work, out of scope here), pass >= [_lineThreshold] +/// points and it draws a continuous tan line instead. +class NutrientSparkline extends StatelessWidget { + const NutrientSparkline({ + super.key, + required this.points, + required this.semanticLabel, + }); + + /// Normalized 0..1 sample values, oldest → newest. Empty renders the + /// "no data point" rail. + final List points; + final String semanticLabel; + + /// Below this many points a line would over-claim continuity → dots. + static const int _lineThreshold = 4; + + @override + Widget build(BuildContext context) { + final lineMode = points.length >= _lineThreshold; + final modeLine = lineMode + ? tr('nutrition.trend.lineMode') + : tr('nutrition.trend.pointMode'); + + return Semantics( + label: semanticLabel, + image: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 56, + width: double.infinity, + child: points.isEmpty + ? _EmptyRail(label: tr('nutrition.trend.noDataPoint')) + : CustomPaint( + painter: _SparkPainter( + points: points, + lineMode: lineMode, + ), + ), + ), + const SizedBox(height: 8), + Text(modeLine, style: dashMeta(color: kInkDisabled)), + ], + ), + ); + } +} + +class _EmptyRail extends StatelessWidget { + const _EmptyRail({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Center( + child: Text(label, style: dashMeta(color: kInkDisabled)), + ); + } +} + +class _SparkPainter extends CustomPainter { + const _SparkPainter({required this.points, required this.lineMode}); + + final List points; + final bool lineMode; + + @override + void paint(Canvas canvas, Size size) { + const pad = 6.0; + final w = size.width - pad * 2; + final h = size.height - pad * 2; + + Offset at(int i) { + final x = points.length == 1 + ? size.width / 2 + : pad + w * (i / (points.length - 1)); + final y = pad + h * (1 - points[i].clamp(0.0, 1.0)); + return Offset(x, y); + } + + // Baseline rail. + final rail = Paint() + ..color = kTrack + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round; + canvas.drawLine( + Offset(pad, size.height - pad), + Offset(size.width - pad, size.height - pad), + rail, + ); + + if (lineMode) { + final line = Paint() + ..color = NhamColors.accent + ..strokeWidth = 2 + ..style = PaintingStyle.stroke + ..strokeJoin = StrokeJoin.round + ..strokeCap = StrokeCap.round; + final path = Path()..moveTo(at(0).dx, at(0).dy); + for (var i = 1; i < points.length; i++) { + path.lineTo(at(i).dx, at(i).dy); + } + canvas.drawPath(path, line); + } + + // Dots — always drawn (in line mode they mark the samples). + final dot = Paint()..color = NhamColors.accent; + for (var i = 0; i < points.length; i++) { + canvas.drawCircle(at(i), lineMode ? 2.5 : 4, dot); + } + } + + @override + bool shouldRepaint(covariant _SparkPainter old) => + old.points != points || old.lineMode != lineMode; +} From 0c1ea2208f033ec5611f378439228f2e0c7a1cac Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 13:45:09 +0700 Subject: [PATCH 22/57] refactor(mobile): re-clothe nutrition in dashboard_tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nutrition screen was a verbatim port of the web screen the drift watchlist condemns — translucent stacked cards, six eyebrows, a 24/16 radius mix — sitting one tap from the re-tokened dashboard. - Solid white cards (kCardSurface + kCardShadow), no alpha surfaces: daily-rhythm, steady list, more-nutrients list. - One radius (kCardRadius) — drop the 24/16 mix. - Collapse six eyebrows to two: keep the editorial header eyebrow and the steady-list eyebrow; drop rhythm / focus / more-nutrients / pull-quote eyebrows (the content groups now stand on structure). The verdict sentence is untouched; nutrition.confidence.* logic is untouched. Co-Authored-By: Claude Opus 4.8 --- .../nutrition/widgets/background_section.dart | 17 +++++++++-------- .../nutrition/widgets/daily_rhythm.dart | 13 ++++--------- .../nutrition/widgets/focus_section.dart | 7 ------- .../features/nutrition/widgets/pull_quote.dart | 8 -------- .../nutrition/widgets/steady_section.dart | 9 +++++---- 5 files changed, 18 insertions(+), 36 deletions(-) diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/background_section.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/background_section.dart index c2c97827..6ce9765b 100644 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/background_section.dart +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/background_section.dart @@ -1,8 +1,8 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import '../../../features/dashboard/widgets/dashboard_tokens.dart'; import '../../../models/nutrition.dart'; -import '../../../shared/widgets/section_eyebrow.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_typography.dart'; import 'nutrient_row.dart'; @@ -64,9 +64,10 @@ class _BackgroundSectionState extends State crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ - SectionEyebrow( - label: tr('nutrition.background.eyebrow'), - delay: const Duration(milliseconds: 200), + Text( + tr('nutrition.background.eyebrow'), + style: NhamTextStyles.sansMedium(fontSize: 14) + .copyWith(color: NhamColors.textMuted), ), const Spacer(), const SizedBox(width: 16), @@ -98,12 +99,12 @@ class _BackgroundSectionState extends State ), const SizedBox(height: 8), ClipRRect( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(kCardRadius), child: DecoratedBox( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: Border.all(color: NhamColors.borderHalf), - color: NhamColors.cardWhite30, + borderRadius: BorderRadius.circular(kCardRadius), + color: kCardSurface, + boxShadow: const [kCardShadow], ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/daily_rhythm.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/daily_rhythm.dart index ecf5a11e..38abf41b 100644 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/daily_rhythm.dart +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/daily_rhythm.dart @@ -1,8 +1,8 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import '../../../features/dashboard/widgets/dashboard_tokens.dart'; import '../../../models/nutrition.dart'; -import '../../../shared/widgets/section_eyebrow.dart'; import '../../../shared/widgets/target_progress_bar.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_typography.dart'; @@ -29,17 +29,12 @@ class DailyRhythm extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SectionEyebrow( - label: tr('nutrition.rhythm.eyebrow'), - delay: const Duration(milliseconds: 100), - ), - const SizedBox(height: 16), Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - border: Border.all(color: NhamColors.borderSoft), - color: NhamColors.cardWhite55, + borderRadius: BorderRadius.circular(kCardRadius), + color: kCardSurface, + boxShadow: const [kCardShadow], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/focus_section.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/focus_section.dart index 90fef661..0172e617 100644 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/focus_section.dart +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/focus_section.dart @@ -1,8 +1,6 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import '../../../models/nutrition.dart'; -import '../../../shared/widgets/section_eyebrow.dart'; import '../../../theme/nham_colors.dart'; import 'spotlight_row.dart'; @@ -19,11 +17,6 @@ class FocusSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SectionEyebrow( - label: tr('nutrition.focus.eyebrow'), - delay: const Duration(milliseconds: 150), - ), - const SizedBox(height: 20), for (var i = 0; i < cards.length; i++) ...[ if (i > 0) const SizedBox(height: 24), if (i < cards.length - 1) diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/pull_quote.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/pull_quote.dart index f10d07be..cda3e6a3 100644 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/pull_quote.dart +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/pull_quote.dart @@ -32,14 +32,6 @@ class PullQuote extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - tr('nutrition.pullQuote.eyebrow').toUpperCase(), - style: NhamTextStyles.sansBold(fontSize: 10).copyWith( - letterSpacing: 2.2, - color: NhamColors.stone, - ), - ), - const SizedBox(height: 12), Text( tr(card.titleKey), style: NhamTextStyles.serifItalic(fontSize: 18).copyWith( diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/steady_section.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/steady_section.dart index f58559ed..e7ba5c98 100644 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/steady_section.dart +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/steady_section.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import '../../../features/dashboard/widgets/dashboard_tokens.dart'; import '../../../models/nutrition.dart'; import '../../../shared/widgets/section_eyebrow.dart'; import '../../../theme/nham_colors.dart'; @@ -42,12 +43,12 @@ class SteadySection extends StatelessWidget { ), const SizedBox(height: 12), ClipRRect( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(kCardRadius), child: DecoratedBox( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: Border.all(color: NhamColors.borderHalf), - color: NhamColors.cardWhite40, + borderRadius: BorderRadius.circular(kCardRadius), + color: kCardSurface, + boxShadow: const [kCardShadow], ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, From 088494dfd33bf8234d0ad0f8862998e6e97a918b Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 13:49:45 +0700 Subject: [PATCH 23/57] feat(mobile): felt save + honest profile-load error in settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Felt save: on a successful profile save the floating bar now morphs into a 'Changes saved · N kcal/day' confirmation — wiring the previously unused settings.saved key — with the calorie target counting old→new (CountUpText, reduced-motion aware), holding ~1.6s before dissolving. The success haptic already fired; the morph gives it a visible payoff. Honest error state: a flaky profile fetch previously fell through to the re-onboarding 'Start setup' empty state, stranding a configured user in a false 'set up your profile' dead-end. Distinguish a load error (now a neutral message + retry) from a genuinely-absent profile (the only case that earns the empty state). Co-Authored-By: Claude Opus 4.8 --- .../settings/screens/settings_screen.dart | 64 ++++++- .../settings/widgets/profile_form.dart | 164 ++++++++++++++---- 2 files changed, 197 insertions(+), 31 deletions(-) diff --git a/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart b/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart index 09fb51f9..033a6c9f 100644 --- a/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart +++ b/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart @@ -190,7 +190,15 @@ class _ProfileScreen extends ConsumerWidget { ), ), ), - error: (_, __) => const _ProfileEmpty(), + // A flaky fetch is NOT an absent profile — only a + // genuinely-null profile (onboarding never ran) gets the + // re-onboarding empty state. An error offers a retry, not + // a misleading "Start setup". + error: (_, __) => _ProfileLoadError( + // userId is non-null in this branch (the null case is + // handled above), so the profile query is keyed `true`. + onRetry: () => ref.invalidate(profileProvider(true)), + ), data: (profile) => profile != null @@ -277,6 +285,60 @@ class _ProfileEmpty extends StatelessWidget { } } +/// Profile load failed (a flaky fetch, not an absent profile). Shows a neutral +/// error + a retry — never the re-onboarding "Start setup" CTA, which would +/// strand a configured user in a false "set up your profile" dead-end. +class _ProfileLoadError extends StatelessWidget { + const _ProfileLoadError({required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: NhamSpacing.sp5), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tr('common.error'), + style: NhamTextStyles.serifMedium( + fontSize: NhamFontSize.h3, + ).copyWith( + letterSpacing: NhamTracking.tight, + color: NhamColors.text, + ), + ), + const SizedBox(height: NhamSpacing.sp4), + Align( + alignment: Alignment.centerLeft, + child: GestureDetector( + onTap: onRetry, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp5, + vertical: 10, + ), + decoration: BoxDecoration( + color: NhamColors.text, + borderRadius: BorderRadius.circular(NhamRadii.pill), + ), + child: Text( + tr('common.retry'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: Colors.white), + ), + ), + ), + ), + ], + ), + ); + } +} + /// Sticky back header — mirrors the web shell's translucent cream/90 bar with /// a backdrop blur, a bottom border, and the ArrowLeft + "Settings" link whose /// text darkens (textWarm → text) on press. diff --git a/apps/mobile-flutter/lib/features/settings/widgets/profile_form.dart b/apps/mobile-flutter/lib/features/settings/widgets/profile_form.dart index c8fdb807..0747a923 100644 --- a/apps/mobile-flutter/lib/features/settings/widgets/profile_form.dart +++ b/apps/mobile-flutter/lib/features/settings/widgets/profile_form.dart @@ -2,9 +2,13 @@ import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'dart:async'; + import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../../features/logging/widgets/count_up.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; import '../../../theme/nham_typography.dart'; @@ -43,14 +47,28 @@ class _ProfileFormState extends ConsumerState { _SectionId _activeTab = _SectionId.bodyMetrics; String? _errorText; + // Felt-save state: after a successful save the bar morphs into a + // "Changes saved · N kcal/day" confirmation, the target counting old→new, + // holding ~1.6s before dissolving. Null = no confirmation showing. + int? _savedCalorieTarget; + int _previousCalorieTarget = 0; + Timer? _savedTimer; + @override void initState() { super.initState(); _controller.addListener(_onFormChanged); + // The saved calorie target lives in the raw profile row (no typed getter); + // it seeds the count-up's "from" so the morph animates old→new. + final rawTarget = widget.profile.raw['calorieTarget']; + _previousCalorieTarget = rawTarget is num + ? rawTarget.round() + : int.tryParse(rawTarget?.toString() ?? '') ?? 0; } @override void dispose() { + _savedTimer?.cancel(); _controller.removeListener(_onFormChanged); _controller.dispose(); super.dispose(); @@ -121,6 +139,18 @@ class _ProfileFormState extends ConsumerState { if (ok) { HapticFeedback.mediumImpact(); // success cue on save _controller.markSaved(); + // Felt save: morph the bar into "Changes saved · N kcal/day", count the + // target old→new, hold ~1.6s, then dissolve. + final newTarget = clampedCalories.round(); + _savedTimer?.cancel(); + setState(() => _savedCalorieTarget = newTarget); + _savedTimer = Timer(const Duration(milliseconds: 1600), () { + if (!mounted) return; + setState(() { + _previousCalorieTarget = newTarget; + _savedCalorieTarget = null; + }); + }); } else { setState(() => _errorText = tr('settings.profilePanel.saveError')); } @@ -210,9 +240,11 @@ class _ProfileFormState extends ConsumerState { right: 0, bottom: 0, child: _SaveBar( - visible: _controller.isDirty, + visible: _controller.isDirty || _savedCalorieTarget != null, errorText: _errorText, saving: saving, + savedTarget: _savedCalorieTarget, + previousTarget: _previousCalorieTarget, onCancel: _handleCancel, onSave: _handleSave, ), @@ -232,6 +264,8 @@ class _SaveBar extends StatefulWidget { required this.visible, required this.errorText, required this.saving, + required this.savedTarget, + required this.previousTarget, required this.onCancel, required this.onSave, }); @@ -239,6 +273,11 @@ class _SaveBar extends StatefulWidget { final bool visible; final String? errorText; final bool saving; + + /// When non-null, the bar shows the post-save confirmation morph instead of + /// the Cancel/Save buttons. + final int? savedTarget; + final int previousTarget; final VoidCallback onCancel; final VoidCallback onSave; @@ -319,6 +358,8 @@ class _SaveBarState extends State<_SaveBar> _BackdropCard( errorText: widget.errorText, saving: widget.saving, + savedTarget: widget.savedTarget, + previousTarget: widget.previousTarget, onCancel: widget.onCancel, onSave: widget.onSave, ), @@ -337,12 +378,16 @@ class _BackdropCard extends StatelessWidget { const _BackdropCard({ required this.errorText, required this.saving, + required this.savedTarget, + required this.previousTarget, required this.onCancel, required this.onSave, }); final String? errorText; final bool saving; + final int? savedTarget; + final int previousTarget; final VoidCallback onCancel; final VoidCallback onSave; @@ -375,36 +420,42 @@ class _BackdropCard extends StatelessWidget { ), ], ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (errorText != null) - Padding( - padding: const EdgeInsets.only(bottom: NhamSpacing.sp2), - child: Text( - errorText!, - textAlign: TextAlign.right, - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.xs) - .copyWith(color: NhamColors.danger), - ), + child: savedTarget != null + ? _SavedConfirmation( + target: savedTarget!, + previousTarget: previousTarget, + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (errorText != null) + Padding( + padding: const EdgeInsets.only(bottom: NhamSpacing.sp2), + child: Text( + errorText!, + textAlign: TextAlign.right, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.xs) + .copyWith(color: NhamColors.danger), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _GhostButton( + label: tr('common.cancel'), + onTap: saving ? null : onCancel, + ), + const SizedBox(width: NhamSpacing.sp3), + _SaveButton( + saving: saving, + onTap: saving ? null : onSave, + ), + ], + ), + ], ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - _GhostButton( - label: tr('common.cancel'), - onTap: saving ? null : onCancel, - ), - const SizedBox(width: NhamSpacing.sp3), - _SaveButton( - saving: saving, - onTap: saving ? null : onSave, - ), - ], - ), - ], - ), ), ), ); @@ -453,6 +504,59 @@ class _GhostButtonState extends State<_GhostButton> { } } +/// The post-save confirmation that the save bar morphs into: a sage check, the +/// "Changes saved" line, then the new calorie target counting up from the +/// previous value (CountUpText) with a "kcal/day" unit. Holds ~1.6s upstream. +class _SavedConfirmation extends StatelessWidget { + const _SavedConfirmation({ + required this.target, + required this.previousTarget, + }); + + final int target; + final int previousTarget; + + @override + Widget build(BuildContext context) { + final locale = context.locale.languageCode; + final fmt = NumberFormat.decimalPattern(locale); + final reduceMotion = + MediaQuery.maybeDisableAnimationsOf(context) ?? false; + + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Icon(LucideIcons.check, size: 16, color: NhamColors.success), + const SizedBox(width: 8), + Text( + tr('settings.saved'), + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) + .copyWith(color: NhamColors.text), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + '·', + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) + .copyWith(color: NhamColors.border), + ), + ), + CountUpText( + value: target.toDouble(), + from: previousTarget.toDouble(), + enabled: !reduceMotion, + format: (v) => '${fmt.format(v.round())} ' + '${tr('onboarding.bodyMetrics.perDay')}', + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm).copyWith( + color: NhamColors.text, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ); + } +} + class _SaveButton extends StatefulWidget { const _SaveButton({required this.saving, required this.onTap}); final bool saving; From 94a5d552955a5235c643c5af3e1dc42d18f13631 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 13:50:34 +0700 Subject: [PATCH 24/57] fix(mobile): stop fabricating 65/165/25 body-metric defaults in settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user who skipped onboarding had their settings profile pre-filled with weight 65 / height 165 / age 25 — fabricated values that could be saved as if they were real data. Start those numeric fields EMPTY when the profile carries none; validateBodyMetrics already requires them, so the user is forced to enter genuine values (the hint placeholders still suggest typical numbers without persisting them). Co-Authored-By: Claude Opus 4.8 --- .../features/settings/widgets/profile_form_values.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/mobile-flutter/lib/features/settings/widgets/profile_form_values.dart b/apps/mobile-flutter/lib/features/settings/widgets/profile_form_values.dart index 57c26caf..6c02fc3c 100644 --- a/apps/mobile-flutter/lib/features/settings/widgets/profile_form_values.dart +++ b/apps/mobile-flutter/lib/features/settings/widgets/profile_form_values.dart @@ -89,9 +89,12 @@ class ProfileFormValues { return ProfileFormValues( biologicalSex: _sexFrom(p.biologicalSex) ?? BiologicalSex.male, - weightKg: p.weightKg ?? 65, - heightCm: p.heightCm ?? 165, - age: p.age ?? 25, + // Numeric body metrics start EMPTY when the profile has none — never + // fabricated 65/165/25, which a user who skipped onboarding could save as + // if they were real. validateBodyMetrics forces a genuine entry instead. + weightKg: p.weightKg, + heightCm: p.heightCm, + age: p.age, activityLevel: _activityFrom(p.activityLevel) ?? ActivityLevel.light, goal: _goalFrom(p.goal) ?? Goal.maintaining, aggression: parseAggression(), From 548a5219a8ab9369689ace39ff88cb1d9f2400ff Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 13:51:28 +0700 Subject: [PATCH 25/57] feat(mobile): strike a meal-item row stepped to zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the edit-amounts surface, stepping a count-unit dish down to 0 now strikes the row (line-through name + dimmed macros) — a clear 'this one's out' cue before confirm drops it. Grams floor at minDishGrams, so only count units can reach 0. Co-Authored-By: Claude Opus 4.8 --- .../features/logging/widgets/meal_entry.dart | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart index 0dcf381e..c6d5f4aa 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart @@ -217,6 +217,10 @@ class _ItemRow extends StatelessWidget { final step = isGrams ? 10.0 : 1.0; final minusDisabled = isGrams ? item.quantity <= minDishGrams : item.quantity <= 0; + // Stepping a count-unit item to 0 strikes the row — a clear "this one's + // out" cue before confirm drops it. Grams floor at minDishGrams, so only + // count units can reach 0. + final struck = !isGrams && item.quantity <= 0; return AnimatedContainer( duration: const Duration(milliseconds: 150), @@ -272,26 +276,36 @@ class _ItemRow extends StatelessWidget { variant: NhamTextVariant.itemName, maxLines: 1, overflow: TextOverflow.ellipsis, + style: struck + ? const TextStyle( + decoration: TextDecoration.lineThrough, + decorationColor: NhamColors.textMuted, + color: NhamColors.textMuted, + ) + : null, ), ), ], ), ), const SizedBox(width: NhamSpacing.sp3), // gap-3 - Row( - children: [ - NhamText('P:${fmtG(item.macros.protein)}', - variant: NhamTextVariant.itemMacro, maxLines: 1), - const SizedBox(width: NhamSpacing.sp2), - NhamText('C:${fmtG(item.macros.carbs)}', - variant: NhamTextVariant.itemMacro, maxLines: 1), - const SizedBox(width: NhamSpacing.sp2), - NhamText('F:${fmtG(item.macros.fat)}', - variant: NhamTextVariant.itemMacro, maxLines: 1), - const SizedBox(width: NhamSpacing.sp3), // gap-3 - NhamText(fmtKcal(item.macros.calories), - variant: NhamTextVariant.itemCalories, maxLines: 1), - ], + Opacity( + opacity: struck ? 0.4 : 1, + child: Row( + children: [ + NhamText('P:${fmtG(item.macros.protein)}', + variant: NhamTextVariant.itemMacro, maxLines: 1), + const SizedBox(width: NhamSpacing.sp2), + NhamText('C:${fmtG(item.macros.carbs)}', + variant: NhamTextVariant.itemMacro, maxLines: 1), + const SizedBox(width: NhamSpacing.sp2), + NhamText('F:${fmtG(item.macros.fat)}', + variant: NhamTextVariant.itemMacro, maxLines: 1), + const SizedBox(width: NhamSpacing.sp3), // gap-3 + NhamText(fmtKcal(item.macros.calories), + variant: NhamTextVariant.itemCalories, maxLines: 1), + ], + ), ), ], ), From 2bbc61e9fdb17a4cb0ae5ceca6126cfd119b98c7 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 13:53:20 +0700 Subject: [PATCH 26/57] refactor(mobile): Lucide glyphs across the settings surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings mixed Material and Lucide icon DNAs. Swap the remaining Material glyphs one-for-one to Lucide: person_outline→user, chevron_right→ chevronRight, arrow_back→arrowLeft (settings list + back header), keyboard_arrow_down→chevronDown and check→check (custom select), public→globe and place→mapPin (regional panel). One icon DNA on the surface now. Co-Authored-By: Claude Opus 4.8 --- .../lib/features/settings/controls/custom_select.dart | 5 +++-- .../lib/features/settings/panels/regional.dart | 5 +++-- .../lib/features/settings/screens/settings_screen.dart | 7 ++++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/mobile-flutter/lib/features/settings/controls/custom_select.dart b/apps/mobile-flutter/lib/features/settings/controls/custom_select.dart index ab62f0d3..f8c8ef2e 100644 --- a/apps/mobile-flutter/lib/features/settings/controls/custom_select.dart +++ b/apps/mobile-flutter/lib/features/settings/controls/custom_select.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; @@ -161,7 +162,7 @@ class _CustomSelectState extends State RotationTransition( turns: Tween(begin: 0, end: 0.5).animate(_chevron), child: const Icon( - Icons.keyboard_arrow_down, + LucideIcons.chevronDown, size: 16, color: NhamColors.textWarm, ), @@ -317,7 +318,7 @@ class _DropdownRowState extends State<_DropdownRow> { ), ), if (widget.selected) - const Icon(Icons.check, size: 16, color: NhamColors.accent) + const Icon(LucideIcons.check, size: 16, color: NhamColors.accent) else const SizedBox(width: 16, height: 16), ], diff --git a/apps/mobile-flutter/lib/features/settings/panels/regional.dart b/apps/mobile-flutter/lib/features/settings/panels/regional.dart index a296b198..0db583de 100644 --- a/apps/mobile-flutter/lib/features/settings/panels/regional.dart +++ b/apps/mobile-flutter/lib/features/settings/panels/regional.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; @@ -28,14 +29,14 @@ class Regional extends StatelessWidget { ), const SizedBox(height: NhamSpacing.sp5), _CountryField( - icon: Icons.public, // lucide Globe + icon: LucideIcons.globe, label: tr('onboarding.origin.countryOfOrigin'), value: v.countryOfOrigin, onChange: (s) => form.update((f) => f.countryOfOrigin = s), ), const SizedBox(height: NhamSpacing.sp4), _CountryField( - icon: Icons.place, // lucide MapPin + icon: LucideIcons.mapPin, label: tr('onboarding.origin.countryOfResidence'), value: v.countryOfResidence, onChange: (s) => form.update((f) => f.countryOfResidence = s), diff --git a/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart b/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart index 033a6c9f..6f44a6a6 100644 --- a/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart +++ b/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart @@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../data/session_provider.dart'; import '../../../shared/widgets/nham_primitives.dart'; @@ -124,7 +125,7 @@ class _ProfileRowTileState extends State<_ProfileRowTile> { child: Row( children: [ Icon( - Icons.person_outline, + LucideIcons.user, size: 16, color: _pressed ? NhamColors.text : NhamColors.textMuted, ), @@ -141,7 +142,7 @@ class _ProfileRowTileState extends State<_ProfileRowTile> { ), // ChevronRight inactive = text-muted/50. const Icon( - Icons.chevron_right, + LucideIcons.chevronRight, size: 16, color: NhamColors.textMuted50, ), @@ -376,7 +377,7 @@ class _BackHeaderState extends State<_BackHeader> { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.arrow_back, size: 16, color: color), + Icon(LucideIcons.arrowLeft, size: 16, color: color), const SizedBox(width: 6), // gap-1.5 Text( tr('settings.title'), From 9ef4c5242bc0df71451a4b2cc3de6d99887fad0b Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 14:04:08 +0700 Subject: [PATCH 27/57] feat(mobile): pre-auth welcome screen + real confirm-email state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the "login wall titled Welcome back" with a real pre-auth welcome screen: Lora wordmark, a typing demo that resolves into a point result chip, then three stacked options (Apple, Google, Continue with email). Collapse the sign-in/sign-up tab split into one email path — a single form whose primary action signs in, with a quiet toggle to account-creation in place. Sign-up no longer flashes a vanishing SnackBar: it cross-fades to a "Check your email" state that names the address and offers resend on a 30s cooldown. Google OAuth now holds a "Finishing sign-in…" state across the Safari app-switch instead of clearing the spinner, dropping it only if the app resumes still signed-out. Replace the two e.toString() error leaks in the controller with warm localized copy. Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 21 +- apps/mobile-flutter/assets/l10n/vi.json | 21 +- .../auth/providers/auth_form_controller.dart | 90 +++- .../features/auth/screens/sign_in_screen.dart | 8 +- .../features/auth/screens/sign_up_screen.dart | 7 +- .../lib/features/auth/widgets/auth_page.dart | 407 ++++++++++-------- .../auth/widgets/confirm_email_view.dart | 192 +++++++++ .../auth/widgets/email_auth_form.dart | 224 ++++++++++ .../features/auth/widgets/sign_in_form.dart | 140 ------ .../features/auth/widgets/sign_up_form.dart | 122 ------ .../features/auth/widgets/welcome_demo.dart | 141 ++++++ 11 files changed, 903 insertions(+), 470 deletions(-) create mode 100644 apps/mobile-flutter/lib/features/auth/widgets/confirm_email_view.dart create mode 100644 apps/mobile-flutter/lib/features/auth/widgets/email_auth_form.dart delete mode 100644 apps/mobile-flutter/lib/features/auth/widgets/sign_in_form.dart delete mode 100644 apps/mobile-flutter/lib/features/auth/widgets/sign_up_form.dart create mode 100644 apps/mobile-flutter/lib/features/auth/widgets/welcome_demo.dart diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index a45635d6..754b5c23 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -667,7 +667,6 @@ "submit": "Create Account", "hasAccount": "Already have an account?", "signInLink": "Sign in", - "success": "Check your email to confirm your account", "successSignedIn": "Account created. Welcome!", "error": "Could not create account", "emailPlaceholder": "you@example.com", @@ -706,6 +705,26 @@ "submit": "Verify", "resend": "Resend code", "error": "Invalid code" + }, + "welcome": { + "tagline": "Track Vietnamese meals", + "taglineHighlight": "without the guesswork", + "demoMeal": "2 mực kho mặn + 1 chén cơm + canh chua", + "demoResult": "{kcal} kcal", + "continueWithEmail": "Continue with email", + "terms": "By continuing you agree to our terms and privacy policy." + }, + "confirm": { + "title": "Check your email", + "description": "We sent a confirmation link to", + "hint": "Tap the link to finish creating your account.", + "resend": "Resend email", + "resendIn": "Resend in {seconds}s", + "resent": "Email sent again", + "back": "Back" + }, + "pending": { + "finishing": "Finishing sign-in…" } }, "landing": { diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index 1f52faa2..3aad0505 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -666,7 +666,6 @@ "submit": "Tạo tài khoản", "hasAccount": "Đã có tài khoản?", "signInLink": "Đăng nhập", - "success": "Kiểm tra email để xác nhận tài khoản", "successSignedIn": "Đã tạo tài khoản. Chào mừng!", "error": "Không thể tạo tài khoản", "emailPlaceholder": "ban@example.com", @@ -705,6 +704,26 @@ "submit": "Xác nhận", "resend": "Gửi lại mã", "error": "Mã không hợp lệ" + }, + "welcome": { + "tagline": "Theo dõi bữa ăn Việt", + "taglineHighlight": "không cần phỏng đoán", + "demoMeal": "2 mực kho mặn + 1 chén cơm + canh chua", + "demoResult": "{kcal} kcal", + "continueWithEmail": "Tiếp tục với email", + "terms": "Khi tiếp tục, bạn đồng ý với điều khoản và chính sách bảo mật của chúng tôi." + }, + "confirm": { + "title": "Kiểm tra email của bạn", + "description": "Chúng tôi đã gửi liên kết xác nhận đến", + "hint": "Nhấn vào liên kết để hoàn tất tạo tài khoản.", + "resend": "Gửi lại email", + "resendIn": "Gửi lại sau {seconds}s", + "resent": "Đã gửi lại email", + "back": "Quay lại" + }, + "pending": { + "finishing": "Đang hoàn tất đăng nhập…" } }, "landing": { diff --git a/apps/mobile-flutter/lib/features/auth/providers/auth_form_controller.dart b/apps/mobile-flutter/lib/features/auth/providers/auth_form_controller.dart index 37b78731..d4449bce 100644 --- a/apps/mobile-flutter/lib/features/auth/providers/auth_form_controller.dart +++ b/apps/mobile-flutter/lib/features/auth/providers/auth_form_controller.dart @@ -25,7 +25,12 @@ enum AuthAction { email, google, apple } /// `AuthAction?` so the email and Google buttons own independent spinners. @immutable class AuthFormState { - const AuthFormState({this.action, this.error, this.notice}); + const AuthFormState({ + this.action, + this.error, + this.notice, + this.pendingEmail, + }); /// The in-flight action (email or Google), or `null` when idle. final AuthAction? action; @@ -36,6 +41,10 @@ class AuthFormState { /// `notice` — success notice (sage), used by sign-up's confirm-email path. final String? notice; + /// The address a confirmation email was sent to. Non-null drives the + /// "Check your email" cross-fade state (replacing the old vanishing toast). + final String? pendingEmail; + /// Any request in flight — disables inputs and the tab toggle. bool get busy => action != null; @@ -52,14 +61,17 @@ class AuthFormState { AuthAction? action, String? error, String? notice, + String? pendingEmail, bool clearAction = false, bool clearError = false, bool clearNotice = false, + bool clearPendingEmail = false, }) { return AuthFormState( action: clearAction ? null : (action ?? this.action), error: clearError ? null : (error ?? this.error), notice: clearNotice ? null : (notice ?? this.notice), + pendingEmail: clearPendingEmail ? null : (pendingEmail ?? this.pendingEmail), ); } } @@ -89,8 +101,11 @@ class AuthFormController extends StateNotifier { state = state.copyWith(clearAction: true); } on AuthException catch (e) { state = state.copyWith(clearAction: true, error: e.message); - } catch (e) { - state = state.copyWith(clearAction: true, error: e.toString()); + } catch (_) { + state = state.copyWith( + clearAction: true, + error: tr('auth.signIn.error'), + ); } } @@ -111,14 +126,20 @@ class AuthFormController extends StateNotifier { state = state.copyWith(clearAction: true); return; } + // No session: a confirmation email is on its way. Hold the address so the + // UI can cross-fade to a real "Check your email" state (not a toast that + // vanishes before the user reads it). state = state.copyWith( clearAction: true, - notice: tr('auth.signUp.success'), + pendingEmail: email.trim(), ); } on AuthException catch (e) { state = state.copyWith(clearAction: true, error: e.message); - } catch (e) { - state = state.copyWith(clearAction: true, error: e.toString()); + } catch (_) { + state = state.copyWith( + clearAction: true, + error: tr('auth.signUp.error'), + ); } } @@ -141,10 +162,10 @@ class AuthFormController extends StateNotifier { ); // The browser hands off; supabase_flutter's deep-link observer completes // the PKCE exchange on the nham://auth-callback return, onAuthStateChange - // fires, and the router redirect routes in. Clear the spinner now — the - // app is backgrounded in the browser and shouldn't sit spinning if the - // user cancels and returns. - state = state.copyWith(clearAction: true); + // fires, and the router redirect routes in. Keep the `google` action set + // so the UI can hold a "Finishing sign-in…" state across the Safari + // app-switch; the UI's lifecycle listener clears it if the user cancels + // and resumes still signed-out. } on AuthException catch (e) { state = state.copyWith(clearAction: true, error: e.message); } catch (_) { @@ -222,6 +243,44 @@ class AuthFormController extends StateNotifier { ).join(); } + /// Re-send the sign-up confirmation email to the pending address. Drives the + /// "Resend email" affordance on the confirm-email state; the UI owns the + /// cooldown so a tap can't spam Supabase's own rate limit. + Future resendConfirmation() async { + final email = state.pendingEmail; + if (email == null) return; + state = state.copyWith(action: AuthAction.email, clearError: true); + try { + await _auth.resend(type: OtpType.signup, email: email); + state = state.copyWith( + clearAction: true, + notice: tr('auth.confirm.resent'), + ); + } on AuthException catch (e) { + state = state.copyWith(clearAction: true, error: e.message); + } catch (e) { + state = state.copyWith( + clearAction: true, + error: tr('auth.signUp.error'), + ); + } + } + + /// Drop the in-flight action (e.g. the OAuth browser round-trip was + /// cancelled and the app resumed still signed-out). + void resetAction() { + if (state.action != null) state = state.copyWith(clearAction: true); + } + + /// Leave the confirm-email state (the "Back" affordance). + void clearPendingEmail() { + state = state.copyWith( + clearPendingEmail: true, + clearError: true, + clearNotice: true, + ); + } + /// Clear any surfaced error/notice (e.g. when the user edits a field). void clearMessages() { if (state.error != null || state.notice != null) { @@ -230,15 +289,10 @@ class AuthFormController extends StateNotifier { } } -/// Sign-in screen controller. +/// The auth controller. The welcome → email → confirm-email path is one flow, +/// so a single instance backs the whole surface (the old split sign-in/sign-up +/// providers collapsed with the split UI). final signInControllerProvider = StateNotifierProvider.autoDispose( AuthFormController.new, ); - -/// Sign-up screen controller (separate instance so its `notice` state is -/// independent of sign-in). -final signUpControllerProvider = - StateNotifierProvider.autoDispose( - AuthFormController.new, - ); diff --git a/apps/mobile-flutter/lib/features/auth/screens/sign_in_screen.dart b/apps/mobile-flutter/lib/features/auth/screens/sign_in_screen.dart index 63dc8945..e7f74b2a 100644 --- a/apps/mobile-flutter/lib/features/auth/screens/sign_in_screen.dart +++ b/apps/mobile-flutter/lib/features/auth/screens/sign_in_screen.dart @@ -5,9 +5,9 @@ import '../widgets/auth_page.dart'; /// The sign-in route. /// -/// A flat full-page auth surface (header, in-place Sign in / Sign up toggle, -/// Google button, divider, form, footer) on the cream surface. Opens on the -/// sign-in tab; the toggle/footer switches to sign-up in place (no route change). +/// Lands on the pre-auth welcome screen (wordmark, typing demo, three social/ +/// email options). "Continue with email" cross-fades to a single email path; +/// sign-up cross-fades to a "Check your email" state. No top tab split. class SignInScreen extends StatelessWidget { const SignInScreen({super.key}); @@ -15,7 +15,7 @@ class SignInScreen extends StatelessWidget { Widget build(BuildContext context) { return const Scaffold( backgroundColor: NhamColors.surface, - body: AuthPage(initialSignIn: true), + body: AuthPage(), ); } } diff --git a/apps/mobile-flutter/lib/features/auth/screens/sign_up_screen.dart b/apps/mobile-flutter/lib/features/auth/screens/sign_up_screen.dart index a64767c7..38a2cef4 100644 --- a/apps/mobile-flutter/lib/features/auth/screens/sign_up_screen.dart +++ b/apps/mobile-flutter/lib/features/auth/screens/sign_up_screen.dart @@ -5,9 +5,8 @@ import '../widgets/auth_page.dart'; /// The sign-up route. /// -/// A flat full-page auth surface (header, in-place Sign in / Sign up toggle, -/// Google button, divider, form, footer) on the cream surface. Opens on the -/// sign-up tab; the toggle/footer switches to sign-in in place (no route change). +/// Renders the same pre-auth welcome surface as `/sign-in` — the sign-in/sign-up +/// split is collapsed into one path, so both routes land on the welcome screen. class SignUpScreen extends StatelessWidget { const SignUpScreen({super.key}); @@ -15,7 +14,7 @@ class SignUpScreen extends StatelessWidget { Widget build(BuildContext context) { return const Scaffold( backgroundColor: NhamColors.surface, - body: AuthPage(initialSignIn: false), + body: AuthPage(), ); } } diff --git a/apps/mobile-flutter/lib/features/auth/widgets/auth_page.dart b/apps/mobile-flutter/lib/features/auth/widgets/auth_page.dart index 627535e2..f4498bb4 100644 --- a/apps/mobile-flutter/lib/features/auth/widgets/auth_page.dart +++ b/apps/mobile-flutter/lib/features/auth/widgets/auth_page.dart @@ -3,39 +3,69 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../services/supabase_service.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; import '../../../theme/nham_typography.dart'; import '../providers/auth_form_controller.dart'; import 'apple_button.dart'; import 'auth_divider.dart'; +import 'confirm_email_view.dart'; +import 'email_auth_form.dart'; import 'google_button.dart'; -import 'sign_in_form.dart'; -import 'sign_up_form.dart'; +import 'welcome_demo.dart'; -/// The auth surface as a full-bleed page. +/// Which face of the auth surface is showing. +enum _AuthMode { welcome, email } + +/// The auth surface as a full-bleed page on the cream surface. /// -/// Ported from web `components/auth/auth-dialog.tsx`, but rendered as a flat -/// page on the cream surface rather than a modal: no dimmed/blurred backdrop and -/// no floating card. It keeps the same content (header, Google button + -/// divider, form, footer) and cross-fades between the Sign in / Sign up forms; -/// the footer link is the only tab switch (the top toggle was removed). +/// Replaces the old "login wall titled Welcome back". It now lands on a real +/// pre-auth **welcome screen**: the Lora wordmark, a typing demo resolving into +/// a point result, then three stacked social/email options. "Continue with +/// email" cross-fades to a single email path (no sign-in/sign-up tab split). A +/// successful sign-up cross-fades again to a real "Check your email" state with +/// a resend-cooldown, instead of a SnackBar that vanishes before it's read. class AuthPage extends ConsumerStatefulWidget { - const AuthPage({super.key, this.initialSignIn = true}); - - /// Which tab opens first. `/sign-in` → true, `/sign-up` → false. - final bool initialSignIn; + const AuthPage({super.key}); @override ConsumerState createState() => _AuthPageState(); } class _AuthPageState extends ConsumerState { - late bool _signIn = widget.initialSignIn; + _AuthMode _mode = _AuthMode.welcome; + + // The welcome + email surfaces share one controller (single path). + static final _provider = signInControllerProvider; + + AuthFormController get _controller => ref.read(_provider.notifier); + + AppLifecycleListener? _lifecycle; + + @override + void initState() { + super.initState(); + // OAuth hands off to Safari; on return the deep-link observer completes the + // PKCE exchange. If the app resumes still signed-out, the user cancelled — + // drop the "Finishing sign-in…" pending state so it doesn't hang. + _lifecycle = AppLifecycleListener( + onResume: () { + final state = ref.read(_provider); + if (state.googleBusy && + SupabaseService.client.auth.currentSession == null) { + _controller.clearMessages(); + // Clear the in-flight action so the overlay drops. + ref.read(_provider.notifier).resetAction(); + } + }, + ); + } - void _setTab(bool signIn) { - if (_signIn == signIn) return; - setState(() => _signIn = signIn); + @override + void dispose() { + _lifecycle?.dispose(); + super.dispose(); } void _toast(String message) { @@ -64,202 +94,219 @@ class _AuthPageState extends ConsumerState { @override Widget build(BuildContext context) { - final provider = - _signIn ? signInControllerProvider : signUpControllerProvider; - final state = ref.watch(provider); + final state = ref.watch(_provider); + final showConfirm = state.pendingEmail != null; + + // Which face: confirm-email > email form > welcome. + final Widget face; + if (showConfirm) { + face = ConfirmEmailView( + key: const ValueKey('confirm'), + provider: _provider, + onNotice: _toast, + ); + } else if (_mode == _AuthMode.email) { + face = EmailAuthForm( + key: const ValueKey('email'), + provider: _provider, + onError: _toast, + onBack: () => setState(() => _mode = _AuthMode.welcome), + ); + } else { + face = _welcome(state); + } - return SafeArea( - child: Center( - child: SingleChildScrollView( - // Page gutter (32) + maxWidth 420 → a 356px content column, matching - // the old card's px-8 inner width so spacing/line lengths are 1:1. - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _header(), - _socialBlock(state, ref.read(provider.notifier)), - _formBlock(state), - ], + return Stack( + children: [ + SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 32, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: child, + ), + layoutBuilder: (currentChild, previousChildren) => Stack( + alignment: Alignment.center, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ), + child: face, + ), + ), ), ), ), - ), - ); - } - - // pb-2, centered. - Widget _header() { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Column( - children: [ - Text( - _signIn - ? tr('auth.dialog.signInTitle') - : tr('auth.dialog.signUpTitle'), - textAlign: TextAlign.center, - // Lora w400, 24px (text-2xl), #2C2416. - style: NhamTextStyles.serifRegular( - fontSize: NhamFontSize.h3, - ).copyWith(color: NhamColors.text), - ), - const SizedBox(height: 4), // mb-1 - Text( - _signIn - ? tr('auth.dialog.signInSubtitle') - : tr('auth.dialog.signUpSubtitle'), - textAlign: TextAlign.center, - // text-sm #8B7355 DM Sans. - style: NhamTextStyles.sansRegular( - fontSize: NhamFontSize.sm, - ).copyWith(color: NhamColors.textMuted), - ), - ], - ), + // OAuth app-switch: a calm "Finishing sign-in…" hold so the surface + // never sits blank or spins indefinitely after Safari returns. + if (state.googleBusy) const _FinishingOverlay(), + ], ); } - // space-y-3, pt-4. - Widget _socialBlock(AuthFormState state, AuthFormController controller) { - // Sign in with Apple is iOS/macOS-only (and an App Store requirement - // there); placed above Google as the most prominent social option. + Widget _welcome(AuthFormState state) { final showApple = defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.macOS; - return Padding( - padding: const EdgeInsets.only(top: 16), - child: Column( - children: [ - if (showApple) ...[ - AppleButton( - busy: state.busy, - onPressed: controller.signInWithApple, - ), - const SizedBox(height: 12), // space-y-3 - ], - GoogleButton( - busy: state.busy, - loading: state.googleBusy, - onPressed: controller.signInWithGoogle, + return Column( + key: const ValueKey('welcome'), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Wordmark. + Center( + child: Text( + 'Nhẩm', + style: NhamTextStyles.serifRegular( + fontSize: 28, + ).copyWith(color: NhamColors.text), ), - const SizedBox(height: 12), // space-y-3 - const AuthDivider(), - ], - ), - ); - } - - // pt-4. - Widget _formBlock(AuthFormState state) { - return Padding( - padding: const EdgeInsets.only(top: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Tab-switch cross-fade + 10px horizontal slide, 150ms, mode 'wait'. - AnimatedSwitcher( - duration: const Duration(milliseconds: 150), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - transitionBuilder: (child, animation) { - final incomingSignIn = (child.key == const ValueKey('sign-in')); - // sign-in enters from x:-10, sign-up from x:+10. - final beginDx = incomingSignIn ? -10.0 : 10.0; - return FadeTransition( - opacity: animation, - child: AnimatedBuilder( - animation: animation, - builder: - (context, c) => Transform.translate( - offset: Offset(beginDx * (1 - animation.value), 0), - child: c, - ), - child: child, - ), - ); - }, - child: - _signIn - ? SignInForm( - key: const ValueKey('sign-in'), - onError: _toast, - ) - : SignUpForm( - key: const ValueKey('sign-up'), - onError: _toast, - onNotice: _toast, - ), + ), + const SizedBox(height: 14), + // Tagline — sentence, with the second clause italic-tan. + Text.rich( + TextSpan( + children: [ + TextSpan(text: '${tr('auth.welcome.tagline')} '), + TextSpan( + text: tr('auth.welcome.taglineHighlight'), + style: NhamTextStyles.serifItalic( + fontSize: NhamFontSize.h3, + ).copyWith(color: NhamColors.accent), + ), + ], ), - const SizedBox(height: 20), // mt-5 - _footer(state), + textAlign: TextAlign.center, + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h3, + height: NhamLeading.snug, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: 28), + const WelcomeDemo(), + const SizedBox(height: 28), + if (showApple) ...[ + AppleButton(busy: state.busy, onPressed: _controller.signInWithApple), + const SizedBox(height: 12), ], - ), - ); - } - - Widget _footer(AuthFormState state) { - final prompt = - _signIn ? tr('auth.signIn.noAccount') : tr('auth.signUp.hasAccount'); - final action = - _signIn ? tr('auth.signIn.signUpLink') : tr('auth.signUp.signInLink'); - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ + GoogleButton( + busy: state.busy, + loading: state.googleBusy, + onPressed: _controller.signInWithGoogle, + ), + const SizedBox(height: 12), + const AuthDivider(), + const SizedBox(height: 12), + _EmailEntryButton( + busy: state.busy, + onPressed: () => setState(() => _mode = _AuthMode.email), + ), + const SizedBox(height: 18), Text( - '$prompt ', + tr('auth.welcome.terms'), + textAlign: TextAlign.center, style: NhamTextStyles.sansRegular( - fontSize: NhamFontSize.sm, + fontSize: NhamFontSize.xs, + height: NhamLeading.normal, ).copyWith(color: NhamColors.textMuted), ), - // Inert while a request is in flight — same guard as the tab toggle. - Opacity( - opacity: state.busy ? 0.6 : 1.0, - child: IgnorePointer( - ignoring: state.busy, - child: _FooterLink(label: action, onTap: () => _setTab(!_signIn)), - ), - ), ], ); } } -/// Inline tab-switch link: `font-semibold text-[#C9A87C] -/// hover:text-[#A88B63] transition-colors`. -class _FooterLink extends StatefulWidget { - const _FooterLink({required this.label, required this.onTap}); +/// "Continue with email" — a ghost button matching the Google button shape but +/// without a logo, so the three options read as one stack. +class _EmailEntryButton extends StatefulWidget { + const _EmailEntryButton({required this.onPressed, required this.busy}); - final String label; - final VoidCallback onTap; + final VoidCallback onPressed; + final bool busy; @override - State<_FooterLink> createState() => _FooterLinkState(); + State<_EmailEntryButton> createState() => _EmailEntryButtonState(); } -class _FooterLinkState extends State<_FooterLink> { +class _EmailEntryButtonState extends State<_EmailEntryButton> { bool _pressed = false; - // Web hover color is #A88B63 (distinct from the accentDark token #B89968). - static const Color _hover = Color(0xFFA88B63); + @override + Widget build(BuildContext context) { + final fill = _pressed ? NhamColors.cardCream : NhamColors.elev; + return Opacity( + opacity: widget.busy ? 0.6 : 1.0, + child: GestureDetector( + onTapDown: widget.busy ? null : (_) => setState(() => _pressed = true), + onTapUp: widget.busy ? null : (_) => setState(() => _pressed = false), + onTapCancel: + widget.busy ? null : () => setState(() => _pressed = false), + onTap: widget.busy ? null : widget.onPressed, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp4, + vertical: NhamSpacing.sp3, + ), + decoration: BoxDecoration( + color: fill, + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + border: Border.all(color: NhamColors.border), + ), + alignment: Alignment.center, + child: Text( + tr('auth.welcome.continueWithEmail'), + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) + .copyWith(color: NhamColors.text, letterSpacing: -0.2), + ), + ), + ), + ); + } +} + +/// A calm full-bleed "Finishing sign-in…" hold shown while the OAuth browser +/// round-trip completes after Safari returns. +class _FinishingOverlay extends StatelessWidget { + const _FinishingOverlay(); @override Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - onTap: widget.onTap, - child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 200), // transition-colors - style: NhamTextStyles.sansSemiBold( - fontSize: NhamFontSize.sm, - ).copyWith(color: _pressed ? _hover : NhamColors.accent), - child: Text(widget.label), + return Positioned.fill( + child: ColoredBox( + color: NhamColors.surface80, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: NhamColors.accent, + ), + ), + const SizedBox(height: 16), + Text( + tr('auth.pending.finishing'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted), + ), + ], + ), + ), ), ); } diff --git a/apps/mobile-flutter/lib/features/auth/widgets/confirm_email_view.dart b/apps/mobile-flutter/lib/features/auth/widgets/confirm_email_view.dart new file mode 100644 index 00000000..6d455d77 --- /dev/null +++ b/apps/mobile-flutter/lib/features/auth/widgets/confirm_email_view.dart @@ -0,0 +1,192 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_theme.dart'; +import '../../../theme/nham_typography.dart'; +import '../providers/auth_form_controller.dart'; + +/// The post-sign-up "Check your email" state. +/// +/// Replaces the SnackBar that vanished before the user could read it: a real +/// surface that names the address the confirmation went to, with a resend +/// affordance gated by a 30s cooldown so the tap can't spam Supabase's rate +/// limit. "Back" returns to the welcome screen. +class ConfirmEmailView extends ConsumerStatefulWidget { + const ConfirmEmailView({ + super.key, + required this.provider, + required this.onNotice, + }); + + final AutoDisposeStateNotifierProvider + provider; + final void Function(String message) onNotice; + + @override + ConsumerState createState() => _ConfirmEmailViewState(); +} + +class _ConfirmEmailViewState extends ConsumerState { + static const _cooldownSeconds = 30; + int _remaining = _cooldownSeconds; + Timer? _timer; + bool _wasBusy = false; + + @override + void initState() { + super.initState(); + _startCooldown(); + } + + void _startCooldown() { + _remaining = _cooldownSeconds; + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (t) { + if (!mounted) return; + if (_remaining <= 1) { + t.cancel(); + setState(() => _remaining = 0); + } else { + setState(() => _remaining--); + } + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + AuthFormController get _controller => ref.read(widget.provider.notifier); + + void _resend() { + if (_remaining > 0) return; + _controller.resendConfirmation(); + _startCooldown(); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(widget.provider); + final busy = state.busy; + final email = state.pendingEmail ?? ''; + + if (_wasBusy && !busy && state.notice != null) { + final msg = state.notice!; + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onNotice(msg); + _controller.clearMessages(); + }); + } + _wasBusy = busy; + + final canResend = _remaining == 0 && !busy; + + return Column( + key: const ValueKey('confirm-view'), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 56, + height: 56, + decoration: const BoxDecoration( + color: NhamColors.accentSelectedFill, + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.mail, + size: 24, + color: NhamColors.accent, + ), + ), + ), + const SizedBox(height: 20), + Text( + tr('auth.confirm.title'), + textAlign: TextAlign.center, + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h3, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: 8), + Text( + tr('auth.confirm.description'), + textAlign: TextAlign.center, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted), + ), + const SizedBox(height: 2), + Text( + email, + textAlign: TextAlign.center, + style: NhamTextStyles.sansSemiBold( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: 12), + Text( + tr('auth.confirm.hint'), + textAlign: TextAlign.center, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.xs, + height: NhamLeading.normal, + ).copyWith(color: NhamColors.textMuted), + ), + const SizedBox(height: 24), + // Resend (cooldown-gated). + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: canResend ? _resend : null, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp4, + vertical: NhamSpacing.sp3, + ), + decoration: BoxDecoration( + color: NhamColors.elev, + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + border: Border.all(color: NhamColors.border), + ), + alignment: Alignment.center, + child: Text( + canResend + ? tr('auth.confirm.resend') + : tr( + 'auth.confirm.resendIn', + namedArgs: {'seconds': '$_remaining'}, + ), + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) + .copyWith( + color: canResend + ? NhamColors.text + : NhamColors.textMuted, + ), + ), + ), + ), + const SizedBox(height: 12), + Center( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: busy ? null : _controller.clearPendingEmail, + child: Text( + tr('auth.confirm.back'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted), + ), + ), + ), + ], + ); + } +} diff --git a/apps/mobile-flutter/lib/features/auth/widgets/email_auth_form.dart b/apps/mobile-flutter/lib/features/auth/widgets/email_auth_form.dart new file mode 100644 index 00000000..4404ea8d --- /dev/null +++ b/apps/mobile-flutter/lib/features/auth/widgets/email_auth_form.dart @@ -0,0 +1,224 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_typography.dart'; +import '../providers/auth_form_controller.dart'; +import '../screens/forgot_password_screen.dart'; +import 'auth_submit_button.dart'; +import 'auth_text_field.dart'; + +/// The single email path, reached from the welcome screen's "Continue with +/// email". One form, no top tabs: a primary action signs the user in, and a +/// quiet toggle below flips it to account-creation in place — collapsing the +/// old sign-in/sign-up split into one screen. +class EmailAuthForm extends ConsumerStatefulWidget { + const EmailAuthForm({ + super.key, + required this.provider, + required this.onError, + required this.onBack, + }); + + final AutoDisposeStateNotifierProvider + provider; + final void Function(String message) onError; + final VoidCallback onBack; + + @override + ConsumerState createState() => _EmailAuthFormState(); +} + +class _EmailAuthFormState extends ConsumerState { + final _email = TextEditingController(); + final _password = TextEditingController(); + String? _emailError; + String? _passwordError; + bool _wasBusy = false; + bool _createMode = false; + + @override + void dispose() { + _email.dispose(); + _password.dispose(); + super.dispose(); + } + + AuthFormController get _controller => ref.read(widget.provider.notifier); + + bool _validate() { + final email = _email.text.trim(); + final emailOk = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email); + final passOk = _password.text.length >= 6; + setState(() { + _emailError = emailOk ? null : tr('auth.signIn.emailError'); + _passwordError = passOk ? null : tr('auth.signIn.passwordError'); + }); + return emailOk && passOk; + } + + void _submit() { + if (!_validate()) return; + if (_createMode) { + _controller.signUp(email: _email.text, password: _password.text); + } else { + _controller.signInWithEmail(email: _email.text, password: _password.text); + } + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(widget.provider); + final busy = state.busy; + + // Surface a Supabase error as a toast once the request settles. + if (_wasBusy && !busy && state.error != null) { + final msg = state.error!; + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onError(msg); + _controller.clearMessages(); + }); + } + _wasBusy = busy; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Back to the welcome screen. + Align( + alignment: Alignment.centerLeft, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: busy ? null : widget.onBack, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + LucideIcons.arrowLeft, + size: 16, + color: NhamColors.textMuted, + ), + const SizedBox(width: 6), + Text( + tr('auth.confirm.back'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted), + ), + ], + ), + ), + ), + ), + Text( + _createMode + ? tr('auth.dialog.signUpTitle') + : tr('auth.dialog.signInTitle'), + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h3, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: 16), + AuthTextField( + controller: _email, + label: tr('auth.signIn.email'), + placeholder: tr('auth.signIn.emailPlaceholder'), + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.email], + enabled: !busy, + errorText: _emailError, + onChanged: (_) { + if (_emailError != null) setState(() => _emailError = null); + }, + ), + const SizedBox(height: 16), + AuthTextField( + controller: _password, + label: tr('auth.signIn.password'), + placeholder: _createMode + ? tr('auth.signUp.passwordPlaceholder') + : tr('auth.signIn.passwordPlaceholder'), + obscureText: true, + textInputAction: TextInputAction.done, + autofillHints: _createMode + ? const [AutofillHints.newPassword] + : const [AutofillHints.password], + onSubmitted: (_) => _submit(), + enabled: !busy, + errorText: _passwordError, + onChanged: (_) { + if (_passwordError != null) setState(() => _passwordError = null); + }, + ), + if (!_createMode) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: busy + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ForgotPasswordScreen(), + ), + ), + child: Text( + tr('auth.signIn.forgotPassword'), + style: NhamTextStyles.sansRegular( + fontSize: 12, + ).copyWith(color: NhamColors.textMuted), + ), + ), + ), + ], + const SizedBox(height: 16), + AuthSubmitButton( + label: _createMode + ? tr('auth.signUp.submit') + : tr('auth.signIn.submit'), + busy: busy, + loading: state.emailBusy, + onPressed: _submit, + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _createMode + ? '${tr('auth.signUp.hasAccount')} ' + : '${tr('auth.signIn.noAccount')} ', + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.textMuted), + ), + Opacity( + opacity: busy ? 0.6 : 1.0, + child: IgnorePointer( + ignoring: busy, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _createMode = !_createMode), + child: Text( + _createMode + ? tr('auth.signUp.signInLink') + : tr('auth.signIn.signUpLink'), + style: NhamTextStyles.sansSemiBold( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.accent), + ), + ), + ), + ), + ], + ), + ], + ); + } +} diff --git a/apps/mobile-flutter/lib/features/auth/widgets/sign_in_form.dart b/apps/mobile-flutter/lib/features/auth/widgets/sign_in_form.dart deleted file mode 100644 index ac35a27c..00000000 --- a/apps/mobile-flutter/lib/features/auth/widgets/sign_in_form.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../../theme/nham_colors.dart'; -import '../../../theme/nham_typography.dart'; -import '../providers/auth_form_controller.dart'; -import '../screens/forgot_password_screen.dart'; -import 'auth_submit_button.dart'; -import 'auth_text_field.dart'; - -/// In-dialog sign-in form. -/// -/// Matches web `components/auth/sign-in-form.tsx`: a `space-y-4` (16px) stack of -/// email + password fields and the espresso submit button. Validation is -/// per-field (zod: email format, password min 6) surfacing inline red messages; -/// Supabase errors arrive via a transient toast (handled by the controller's -/// caller). -class SignInForm extends ConsumerStatefulWidget { - const SignInForm({super.key, required this.onError}); - - /// Surfaces a Supabase error message as a transient toast. - final void Function(String message) onError; - - @override - ConsumerState createState() => _SignInFormState(); -} - -class _SignInFormState extends ConsumerState { - final _email = TextEditingController(); - final _password = TextEditingController(); - String? _emailError; - String? _passwordError; - bool _wasBusy = false; - - @override - void dispose() { - _email.dispose(); - _password.dispose(); - super.dispose(); - } - - AuthFormController get _controller => - ref.read(signInControllerProvider.notifier); - - // zod: z.email() + z.string().min(6). - bool _validate() { - final email = _email.text.trim(); - final emailOk = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email); - final passOk = _password.text.length >= 6; - setState(() { - _emailError = emailOk ? null : tr('auth.signIn.emailError'); - _passwordError = passOk ? null : tr('auth.signIn.passwordError'); - }); - return emailOk && passOk; - } - - void _submit() { - if (!_validate()) return; - _controller.signInWithEmail(email: _email.text, password: _password.text); - } - - @override - Widget build(BuildContext context) { - final state = ref.watch(signInControllerProvider); - final busy = state.busy; - - // Surface a Supabase error as a toast once the request settles. - if (_wasBusy && !busy && state.error != null) { - final msg = state.error!; - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onError(msg); - _controller.clearMessages(); - }); - } - _wasBusy = busy; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AuthTextField( - controller: _email, - label: tr('auth.signIn.email'), - placeholder: tr('auth.signIn.emailPlaceholder'), - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.next, - autofillHints: const [AutofillHints.email], - enabled: !busy, - errorText: _emailError, - onChanged: (_) { - if (_emailError != null) setState(() => _emailError = null); - }, - ), - const SizedBox(height: 16), // space-y-4 - AuthTextField( - controller: _password, - label: tr('auth.signIn.password'), - placeholder: tr('auth.signIn.passwordPlaceholder'), - obscureText: true, - textInputAction: TextInputAction.done, - autofillHints: const [AutofillHints.password], - onSubmitted: (_) => _submit(), - enabled: !busy, - errorText: _passwordError, - onChanged: (_) { - if (_passwordError != null) setState(() => _passwordError = null); - }, - ), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerRight, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: - busy - ? null - : () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ForgotPasswordScreen(), - ), - ), - child: Text( - tr('auth.signIn.forgotPassword'), - style: NhamTextStyles.sansRegular( - fontSize: 12, - ).copyWith(color: NhamColors.textMuted), - ), - ), - ), - const SizedBox(height: 16), // space-y-4 - AuthSubmitButton( - label: tr('auth.signIn.submit'), - busy: busy, - loading: state.emailBusy, - onPressed: _submit, - ), - ], - ); - } -} diff --git a/apps/mobile-flutter/lib/features/auth/widgets/sign_up_form.dart b/apps/mobile-flutter/lib/features/auth/widgets/sign_up_form.dart deleted file mode 100644 index fbb67734..00000000 --- a/apps/mobile-flutter/lib/features/auth/widgets/sign_up_form.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../providers/auth_form_controller.dart'; -import 'auth_submit_button.dart'; -import 'auth_text_field.dart'; - -/// In-dialog sign-up form. -/// -/// Matches web `components/auth/sign-up-form.tsx`: a `space-y-4` (16px) stack of -/// email + password fields and the espresso submit button. Validation is -/// per-field (zod: email format, password min 6). Both the Supabase error and -/// the "check your email" success are delivered as transient toasts (web uses -/// sonner) rather than inline paragraphs. -class SignUpForm extends ConsumerStatefulWidget { - const SignUpForm({super.key, required this.onError, required this.onNotice}); - - final void Function(String message) onError; - final void Function(String message) onNotice; - - @override - ConsumerState createState() => _SignUpFormState(); -} - -class _SignUpFormState extends ConsumerState { - final _email = TextEditingController(); - final _password = TextEditingController(); - String? _emailError; - String? _passwordError; - bool _wasBusy = false; - - @override - void dispose() { - _email.dispose(); - _password.dispose(); - super.dispose(); - } - - AuthFormController get _controller => - ref.read(signUpControllerProvider.notifier); - - bool _validate() { - final email = _email.text.trim(); - final emailOk = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email); - final passOk = _password.text.length >= 6; - setState(() { - _emailError = emailOk ? null : tr('auth.signUp.emailError'); - _passwordError = passOk ? null : tr('auth.signUp.passwordError'); - }); - return emailOk && passOk; - } - - void _submit() { - if (!_validate()) return; - _controller.signUp(email: _email.text, password: _password.text); - } - - @override - Widget build(BuildContext context) { - final state = ref.watch(signUpControllerProvider); - final busy = state.busy; - - if (_wasBusy && !busy) { - if (state.error != null) { - final msg = state.error!; - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onError(msg); - _controller.clearMessages(); - }); - } else if (state.notice != null) { - final msg = state.notice!; - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onNotice(msg); - _controller.clearMessages(); - }); - } - } - _wasBusy = busy; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AuthTextField( - controller: _email, - label: tr('auth.signUp.email'), - placeholder: tr('auth.signUp.emailPlaceholder'), - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.next, - autofillHints: const [AutofillHints.email], - enabled: !busy, - errorText: _emailError, - onChanged: (_) { - if (_emailError != null) setState(() => _emailError = null); - }, - ), - const SizedBox(height: 16), // space-y-4 - AuthTextField( - controller: _password, - label: tr('auth.signUp.password'), - placeholder: tr('auth.signUp.passwordPlaceholder'), - obscureText: true, - textInputAction: TextInputAction.done, - autofillHints: const [AutofillHints.newPassword], - onSubmitted: (_) => _submit(), - enabled: !busy, - errorText: _passwordError, - onChanged: (_) { - if (_passwordError != null) setState(() => _passwordError = null); - }, - ), - const SizedBox(height: 16), // space-y-4 - AuthSubmitButton( - label: tr('auth.signUp.submit'), - busy: busy, - loading: state.emailBusy, - onPressed: _submit, - ), - ], - ); - } -} diff --git a/apps/mobile-flutter/lib/features/auth/widgets/welcome_demo.dart b/apps/mobile-flutter/lib/features/auth/widgets/welcome_demo.dart new file mode 100644 index 00000000..3ec44b81 --- /dev/null +++ b/apps/mobile-flutter/lib/features/auth/widgets/welcome_demo.dart @@ -0,0 +1,141 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_theme.dart'; +import '../../../theme/nham_typography.dart'; + +/// The pre-auth typing demo: a Vietnamese meal string types itself in, then a +/// small result chip springs in with a point calorie value. +/// +/// Mirrors the landing hero's real typing animation (50ms/char, then an 800ms +/// pause before the AI response springs in) — the one decorative flourish on the +/// welcome screen, and the product's pitch in a single glance. Per the founder +/// direction the result is a single point value (no range, no confidence label). +/// Respects reduced-motion by showing the resolved end-state immediately. +class WelcomeDemo extends StatefulWidget { + const WelcomeDemo({super.key}); + + @override + State createState() => _WelcomeDemoState(); +} + +class _WelcomeDemoState extends State + with SingleTickerProviderStateMixin { + late final String _full = tr('auth.welcome.demoMeal'); + int _typed = 0; + bool _resolved = false; + Timer? _timer; + + late final AnimationController _chip = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 420), + ); + + bool get _reducedMotion => + WidgetsBinding + .instance + .platformDispatcher + .accessibilityFeatures + .disableAnimations; + + @override + void initState() { + super.initState(); + if (_reducedMotion) { + _typed = _full.length; + _resolved = true; + _chip.value = 1; + return; + } + _startTyping(); + } + + void _startTyping() { + _timer = Timer.periodic(const Duration(milliseconds: 50), (t) { + if (!mounted) return; + if (_typed >= _full.length) { + t.cancel(); + // 800ms pause, then the result springs in. + _timer = Timer(const Duration(milliseconds: 800), () { + if (!mounted) return; + setState(() => _resolved = true); + _chip.forward(); + }); + return; + } + setState(() => _typed++); + }); + } + + @override + void dispose() { + _timer?.cancel(); + _chip.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16), + decoration: BoxDecoration( + color: NhamColors.elev, + borderRadius: BorderRadius.circular(NhamRadii.xxl), + border: Border.all(color: NhamColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // The user's words, in Lora — the loudest thing on the screen. + Text( + _full.substring(0, _typed), + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.lg, + height: NhamLeading.snug, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: 14), + // The point result chip springs in once typing resolves. + AnimatedSize( + duration: const Duration(milliseconds: 240), + curve: Curves.easeOut, + alignment: Alignment.centerLeft, + child: _resolved + ? FadeTransition( + opacity: _chip, + child: ScaleTransition( + scale: Tween(begin: 0.92, end: 1).animate( + CurvedAnimation(parent: _chip, curve: Curves.easeOut), + ), + alignment: Alignment.centerLeft, + child: _resultChip(), + ), + ) + : const SizedBox(height: 0, width: double.infinity), + ), + ], + ), + ); + } + + Widget _resultChip() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: NhamColors.accentSelectedFill, + borderRadius: BorderRadius.circular(NhamRadii.xl), + border: Border.all(color: NhamColors.accentSelectedBorder), + ), + child: Text( + tr('auth.welcome.demoResult', namedArgs: {'kcal': '620'}), + style: NhamTextStyles.sansSemiBold( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), + ), + ); + } +} From 8475f5acaa70239c6de0b1d6f32221157d1e24b1 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 14:06:36 +0700 Subject: [PATCH 28/57] =?UTF-8?q?fix(mobile):=20de-leak=20onboarding=20ste?= =?UTF-8?q?p=202=20=E2=80=94=20strings,=20flag=20emoji,=20sex=20strip,=20f?= =?UTF-8?q?lat=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Localize the hardcoded English that leaked the moment a user picked Tiếng Việt ("About You" + its hint, and the unlock placeholder's "this side turns into…" desktop-two-pane leftover) into onboarding.bodyMetrics keys (en + vi). Replace the banned flag emoji in the language toggle with a Lora language-code monogram in a tinted disc (EN / VI), and swap its Material check for Lucide. Sex is a binary choice, so render it as the 2-segment OptionStrip instead of a select popover. Flatten the daily-target hero from an accent gradient to a flat white card with a hairline border (dashboard token system: solid cards). Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 6 ++- apps/mobile-flutter/assets/l10n/vi.json | 6 ++- .../screens/screen_body_metrics.dart | 40 +++++++++---------- .../onboarding/widgets/language_toggle.dart | 39 ++++++++++++------ 4 files changed, 55 insertions(+), 36 deletions(-) diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index 754b5c23..f115417b 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -599,7 +599,11 @@ "fat": "Fat", "macroSummary": "Daily targets", "kcal": "kcal", - "grams": "g" + "grams": "g", + "aboutYou": "About you", + "aboutYouHint": "These stay optional, but once you fill them in, Nhẩm can compute more tailored targets locally.", + "unlockTitle": "Fill the basics to unlock targets.", + "unlockHint": "Once sex, weight, height, age, and activity are filled, this becomes your live calorie target and macro planner." }, "cooking": { "title": "Your cooking habits", diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index 3aad0505..715cb9ff 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -598,7 +598,11 @@ "fat": "Chất béo", "macroSummary": "Mục tiêu hàng ngày", "kcal": "kcal", - "grams": "g" + "grams": "g", + "aboutYou": "Về bạn", + "aboutYouHint": "Những mục này không bắt buộc, nhưng khi bạn điền vào, Nhẩm có thể tính mục tiêu phù hợp hơn ngay trên máy.", + "unlockTitle": "Điền thông tin cơ bản để mở khóa mục tiêu.", + "unlockHint": "Khi đã điền giới tính, cân nặng, chiều cao, tuổi và mức vận động, đây sẽ thành mục tiêu calo và bảng macro của bạn." }, "cooking": { "title": "Thói quen nấu ăn", diff --git a/apps/mobile-flutter/lib/features/onboarding/screens/screen_body_metrics.dart b/apps/mobile-flutter/lib/features/onboarding/screens/screen_body_metrics.dart index 7fa9b502..d0d778cb 100644 --- a/apps/mobile-flutter/lib/features/onboarding/screens/screen_body_metrics.dart +++ b/apps/mobile-flutter/lib/features/onboarding/screens/screen_body_metrics.dart @@ -9,6 +9,7 @@ import '../../../theme/nham_typography.dart'; import '../logic/tdee.dart'; import '../widgets/aggression_slider.dart'; import '../widgets/custom_select.dart'; +import '../widgets/option_strip.dart'; /// Step-2 form values + computed targets, reported up when the body-metrics /// schema passes (mirrors RN `ScreenOneData`). Keys match the RN payload so the @@ -241,16 +242,15 @@ class _ScreenBodyMetricsState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // "About You" header block (mb-4 before the grid). + // "About you" header block (mb-4 before the grid). Text( - 'About You', + tr('onboarding.bodyMetrics.aboutYou').toUpperCase(), style: NhamTextStyles.sansBold(fontSize: 11) .copyWith(letterSpacing: 1.5, color: NhamColors.stone), ), const SizedBox(height: NhamSpacing.sp1), // mt-1 Text( - 'These stay optional, but once you fill them in, Nhẩm can ' - 'compute more tailored targets locally.', + tr('onboarding.bodyMetrics.aboutYouHint'), style: NhamTextStyles.sansRegular(fontSize: 13, height: 1.625) .copyWith(color: NhamColors.textHelp), ), @@ -277,14 +277,13 @@ class _ScreenBodyMetricsState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Fill the basics to unlock targets.', + tr('onboarding.bodyMetrics.unlockTitle'), style: NhamTextStyles.sansMedium(fontSize: 14) .copyWith(color: NhamColors.text), ), const SizedBox(height: NhamSpacing.sp1), // mt-1 Text( - 'Once sex, weight, height, age, and activity are filled, this side ' - 'turns into your live calorie target and macro planner.', + tr('onboarding.bodyMetrics.unlockHint'), style: NhamTextStyles.sansRegular(fontSize: 13, height: 1.625) .copyWith(color: NhamColors.textHelp), ), @@ -297,15 +296,16 @@ class _ScreenBodyMetricsState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // biological sex (full) + // biological sex (full) — two options, so a 2-segment strip rather than + // a select popover (a tap-to-open menu for a binary choice). _FieldLabel(tr('onboarding.bodyMetrics.biologicalSex')), const SizedBox(height: 6), - CustomSelect( + OptionStrip( value: _sex ?? '', options: [ - CustomSelectOption( + OptionStripItem( label: tr('onboarding.bodyMetrics.male'), value: 'male'), - CustomSelectOption( + OptionStripItem( label: tr('onboarding.bodyMetrics.female'), value: 'female'), ], onChange: (v) { @@ -509,10 +509,10 @@ class _ScreenBodyMetricsState extends State { } } -/// Daily-target hero card — accent gradient surface showing the computed -/// calorie target, a "based on TDEE…" caption, and the selected split's macros. -/// Ported from the RN onboarding `hero` block (which used a flat accent tint); -/// here it's a soft accent gradient. +/// Daily-target card — a flat white surface showing the computed calorie +/// target, a "based on TDEE…" caption, and the selected split's macros. +/// Hierarchy comes from the hairline border, not an alpha gradient (matching +/// the dashboard token system: solid cards, one radius). class _DailyTargetCard extends StatelessWidget { const _DailyTargetCard({ required this.calorieTarget, @@ -559,13 +559,9 @@ class _DailyTargetCard extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.all(NhamSpacing.sp5), decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [NhamColors.accent15, NhamColors.accent05], - ), + color: NhamColors.elev, borderRadius: BorderRadius.circular(NhamRadii.containerLg), - border: Border.all(color: NhamColors.accent40), + border: Border.all(color: NhamColors.inputBorder), ), child: Column( children: [ @@ -573,7 +569,7 @@ class _DailyTargetCard extends StatelessWidget { tr('onboarding.bodyMetrics.calorieTarget').toUpperCase(), textAlign: TextAlign.center, style: NhamTextStyles.sansBold(fontSize: 11) - .copyWith(color: NhamColors.accentDark, letterSpacing: 1.5), + .copyWith(color: NhamColors.stone, letterSpacing: 1.5), ), const SizedBox(height: NhamSpacing.sp2), Text.rich( diff --git a/apps/mobile-flutter/lib/features/onboarding/widgets/language_toggle.dart b/apps/mobile-flutter/lib/features/onboarding/widgets/language_toggle.dart index ed9c4fd4..28e2d9d9 100644 --- a/apps/mobile-flutter/lib/features/onboarding/widgets/language_toggle.dart +++ b/apps/mobile-flutter/lib/features/onboarding/widgets/language_toggle.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; @@ -6,9 +7,10 @@ import '../../../theme/nham_typography.dart'; /// RN port of `components/onboarding/wizard/language-toggle.tsx`. /// -/// Web shows GB/VN SVG flags (`country-flag-icons`); on mobile we use the -/// regional-indicator flag emoji (🇬🇧 English, 🇻🇳 Tiếng Việt), which iOS/Android -/// render natively. lucide `Check` → [Icons.check]. Picking a language switches +/// Web shows GB/VN SVG flags (`country-flag-icons`). Emoji are banned in the +/// brand, so on mobile each option carries a small Lora language-code monogram +/// in a tinted disc (`EN` / `VI`) — the brand's monogram-in-tinted-disc pattern +/// — instead of a regional-indicator flag emoji. Picking a language switches /// the app locale live (see `ScreenOrigin`). class LanguageToggle extends StatelessWidget { const LanguageToggle({ @@ -20,9 +22,9 @@ class LanguageToggle extends StatelessWidget { final String value; // 'en' | 'vi' final ValueChanged onChange; - static const List<({String code, String label, String flag})> _languages = [ - (code: 'en', label: 'English', flag: '🇬🇧'), - (code: 'vi', label: 'Tiếng Việt', flag: '🇻🇳'), + static const List<({String code, String label, String mono})> _languages = [ + (code: 'en', label: 'English', mono: 'EN'), + (code: 'vi', label: 'Tiếng Việt', mono: 'VI'), ]; @override @@ -34,7 +36,7 @@ class LanguageToggle extends StatelessWidget { Expanded( child: _LangButton( label: _languages[i].label, - flag: _languages[i].flag, + mono: _languages[i].mono, selected: value == _languages[i].code, onTap: () => onChange(_languages[i].code), ), @@ -48,13 +50,13 @@ class LanguageToggle extends StatelessWidget { class _LangButton extends StatefulWidget { const _LangButton({ required this.label, - required this.flag, + required this.mono, required this.selected, required this.onTap, }); final String label; - final String flag; + final String mono; final bool selected; final VoidCallback onTap; @@ -91,8 +93,21 @@ class _LangButtonState extends State<_LangButton> { ), child: Row( children: [ - // h-5 w-7 rounded flag on web → regional-indicator emoji here. - Text(widget.flag, style: const TextStyle(fontSize: 20)), + // h-5 w-7 rounded flag on web → Lora language-code monogram disc. + Container( + width: 28, + height: 20, + alignment: Alignment.center, + decoration: BoxDecoration( + color: NhamColors.accent10, + borderRadius: BorderRadius.circular(NhamRadii.sm), + ), + child: Text( + widget.mono, + style: NhamTextStyles.serifRegular(fontSize: 11) + .copyWith(color: NhamColors.text, letterSpacing: 0.5), + ), + ), const SizedBox(width: NhamSpacing.sp3), Expanded( child: Text( @@ -105,7 +120,7 @@ class _LangButtonState extends State<_LangButton> { ), if (widget.selected) ...[ const SizedBox(width: NhamSpacing.sp3), - const Icon(Icons.check, size: 16, color: NhamColors.accent), + const Icon(LucideIcons.check, size: 16, color: NhamColors.accent), ], ], ), From c92d73c652d65499402986429854dba0ccae9f6b Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 14:08:20 +0700 Subject: [PATCH 29/57] =?UTF-8?q?feat(mobile):=20country=20picker=20as=20a?= =?UTF-8?q?=20bottom=20sheet=20with=20pinned=20Vi=E1=BB=87t=20Nam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the onboarding CountrySelect anchored popover — which opened alphabetically at Afghanistan, auto-focused a search whose keyboard covered the list, and pinned nothing — with a native modal bottom sheet: a grabber, a pinned search that lifts above the keyboard (the sheet tracks viewInsets), a "Common" section putting Việt Nam and the frequent residences one tap above the full alphabetical list, and Lucide glyphs (chevron, search, check) instead of the Material arrow. Selection fires a selection haptic. Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 3 +- apps/mobile-flutter/assets/l10n/vi.json | 3 +- .../onboarding/widgets/country_select.dart | 456 ++++++++---------- 3 files changed, 213 insertions(+), 249 deletions(-) diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index f115417b..ae72b68d 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -553,7 +553,8 @@ "noCountries": "No countries found", "fallbackNote": "Leave one or both empty if you want. The AI will fall back to your photo and meal text only.", "preferredLanguage": "Preferred language", - "preferredLanguageHint": "Choose your language for the app interface" + "preferredLanguageHint": "Choose your language for the app interface", + "commonCountries": "Common" }, "bodyMetrics": { "title": "Your body metrics", diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index 715cb9ff..a758fd1f 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -552,7 +552,8 @@ "noCountries": "Không tìm thấy quốc gia", "fallbackNote": "Bỏ trống nếu muốn. AI sẽ chỉ dựa vào ảnh và mô tả bữa ăn của bạn.", "preferredLanguage": "Ngôn ngữ ưa thích", - "preferredLanguageHint": "Chọn ngôn ngữ hiển thị cho ứng dụng" + "preferredLanguageHint": "Chọn ngôn ngữ hiển thị cho ứng dụng", + "commonCountries": "Phổ biến" }, "bodyMetrics": { "title": "Chỉ số cơ thể", diff --git a/apps/mobile-flutter/lib/features/onboarding/widgets/country_select.dart b/apps/mobile-flutter/lib/features/onboarding/widgets/country_select.dart index d79f6361..621347b1 100644 --- a/apps/mobile-flutter/lib/features/onboarding/widgets/country_select.dart +++ b/apps/mobile-flutter/lib/features/onboarding/widgets/country_select.dart @@ -1,17 +1,32 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; import '../../../theme/nham_typography.dart'; import '../data/countries.dart'; -/// Mirror of `components/onboarding/screen-origin.tsx` CountryPicker. +/// ISO values pinned above the alphabet — Việt Nam first, then the destinations +/// most Nhẩm users live in. Keeps Việt Nam one tap away instead of buried at "V". +const List _pinnedValues = [ + 'Vietnam', + 'United States', + 'Australia', + 'Japan', + 'South Korea', + 'Singapore', +]; + +/// Country picker for onboarding `screen-origin`. /// -/// An anchored popover dropdown glued to the trigger: portal-positioned at the -/// trigger bottom (+8px, flipped above when no room), same width as the trigger, -/// rounded-2xl, border #EAE7E0, shadow [0_20px_60px_rgba(44,36,22,0.18)], a -/// search input pinned at top, and a scroll list clamped to maxHeight 160..320. +/// Replaces the anchored popover (which opened alphabetically at Afghanistan, +/// auto-focused a search whose keyboard covered the list, and pinned nothing) +/// with a native modal bottom sheet: a grabber, a pinned search that stays above +/// the keyboard, a "Common" section (Việt Nam + frequent residences) above the +/// full alphabetical list, and a sheet that grows with the keyboard so the list +/// is never occluded. class CountrySelect extends StatefulWidget { const CountrySelect({super.key, required this.value, required this.onChange}); @@ -22,31 +37,7 @@ class CountrySelect extends StatefulWidget { State createState() => _CountrySelectState(); } -class _CountrySelectState extends State - with SingleTickerProviderStateMixin { - final OverlayPortalController _portal = OverlayPortalController(); - final LayerLink _link = LayerLink(); - final TextEditingController _search = TextEditingController(); - final GlobalKey _triggerKey = GlobalKey(); - - late final AnimationController _chevron = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 200), - ); - - bool _open = false; - String _query = ''; - bool _flipAbove = false; - double _maxHeight = 320; - double _triggerWidth = 0; - - @override - void dispose() { - _chevron.dispose(); - _search.dispose(); - super.dispose(); - } - +class _CountrySelectState extends State { Country? get _selected { if (widget.value == null) return null; return kCountries.cast().firstWhere( @@ -55,265 +46,234 @@ class _CountrySelectState extends State ); } - void _toggle() { - if (_open) { - _close(); - } else { - _computePlacement(); - setState(() => _open = true); - _portal.show(); - _chevron.forward(); - } - } - - void _close() { - _chevron.reverse(); - _portal.hide(); - _search.clear(); - setState(() { - _open = false; - _query = ''; - }); - } - - void _computePlacement() { - final box = _triggerKey.currentContext?.findRenderObject() as RenderBox?; - if (box == null) return; - final screenH = MediaQuery.sizeOf(context).height; - final topLeft = box.localToGlobal(Offset.zero); - final spaceBelow = screenH - (topLeft.dy + box.size.height); - final spaceAbove = topLeft.dy; - _triggerWidth = box.size.width; - // Flip above when no room below (mirrors web: spaceBelow<180 && - // spaceAbove>spaceBelow+40). - _flipAbove = spaceBelow < 180 && spaceAbove > spaceBelow + 40; - final avail = (_flipAbove ? spaceAbove : spaceBelow) - 16; - _maxHeight = avail.clamp(160.0, 320.0); + Future _open() async { + HapticFeedback.selectionClick(); + final picked = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + barrierColor: NhamColors.text40, + builder: (_) => _CountrySheet(selectedValue: widget.value), + ); + if (picked != null) widget.onChange(picked); } @override Widget build(BuildContext context) { final hasValue = widget.value != null && widget.value!.isNotEmpty; final display = hasValue - ? (_selected != null ? '${widget.value} (${_selected!.vi})' : widget.value!) + ? (_selected != null + ? '${widget.value} (${_selected!.vi})' + : widget.value!) : tr('onboarding.origin.selectCountry'); - return CompositedTransformTarget( - link: _link, - child: OverlayPortal( - controller: _portal, - overlayChildBuilder: _buildOverlay, - child: GestureDetector( - key: _triggerKey, - behavior: HitTestBehavior.opaque, - onTap: _toggle, - child: Container( - // px-4 py-3, rounded-2xl - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp4, - vertical: NhamSpacing.sp3, - ), - decoration: BoxDecoration( - // open: border #C9A87C + bg white + shadow-sm; closed: #EAE7E0 + cream - color: _open ? const Color(0xFFFFFFFF) : NhamColors.cream, - borderRadius: BorderRadius.circular(NhamRadii.containerLg), - border: Border.all( - color: _open ? NhamColors.accent : NhamColors.inputBorder, + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _open, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp4, + vertical: NhamSpacing.sp3, + ), + decoration: BoxDecoration( + color: NhamColors.cream, + borderRadius: BorderRadius.circular(NhamRadii.containerLg), + border: Border.all(color: NhamColors.inputBorder), + ), + child: Row( + children: [ + Expanded( + child: Text( + display, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NhamTextStyles.sansRegular(fontSize: 14).copyWith( + color: hasValue ? NhamColors.text : NhamColors.textHelp, + ), ), - boxShadow: _open ? const [NhamShadows.sm] : null, ), - child: Row( - children: [ - Expanded( - child: Text( - display, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NhamTextStyles.sansRegular(fontSize: 14).copyWith( - color: hasValue ? NhamColors.text : NhamColors.textHelp, - ), - ), - ), - const SizedBox(width: NhamSpacing.sp2), - RotationTransition( - turns: Tween(begin: 0, end: 0.5).animate(_chevron), - child: const Icon( - Icons.keyboard_arrow_down, - size: 16, - color: NhamColors.textHelp, - ), - ), - ], + const SizedBox(width: NhamSpacing.sp2), + const Icon( + LucideIcons.chevronDown, + size: 16, + color: NhamColors.textHelp, ), - ), + ], ), ), ); } - - Widget _buildOverlay(BuildContext context) { - // Tap-outside scrim to dismiss. - return Stack( - children: [ - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: _close, - ), - ), - CompositedTransformFollower( - link: _link, - showWhenUnlinked: false, - targetAnchor: - _flipAbove ? Alignment.topLeft : Alignment.bottomLeft, - followerAnchor: - _flipAbove ? Alignment.bottomLeft : Alignment.topLeft, - offset: Offset(0, _flipAbove ? -8 : 8), - child: SizedBox( - width: _triggerWidth, - child: _DropdownPanel( - maxHeight: _maxHeight, - query: _query, - search: _search, - selectedValue: widget.value, - onQueryChange: (v) => setState(() => _query = v), - onPick: (v) { - widget.onChange(v); - _close(); - }, - ), - ), - ), - ], - ); - } } -class _DropdownPanel extends StatelessWidget { - const _DropdownPanel({ - required this.maxHeight, - required this.query, - required this.search, - required this.selectedValue, - required this.onQueryChange, - required this.onPick, - }); +class _CountrySheet extends StatefulWidget { + const _CountrySheet({required this.selectedValue}); - final double maxHeight; - final String query; - final TextEditingController search; final String? selectedValue; - final ValueChanged onQueryChange; - final ValueChanged onPick; + + @override + State<_CountrySheet> createState() => _CountrySheetState(); +} + +class _CountrySheetState extends State<_CountrySheet> { + final TextEditingController _search = TextEditingController(); + String _query = ''; + + static final List _pinned = [ + for (final v in _pinnedValues) + kCountries.firstWhere((c) => c.value == v), + ]; + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - final q = query.toLowerCase(); - final filtered = q.isEmpty + final q = _query.trim().toLowerCase(); + final searching = q.isNotEmpty; + final filtered = searching ? kCountries - : kCountries .where((c) => c.value.toLowerCase().contains(q) || c.vi.toLowerCase().contains(q)) - .toList(); + .toList() + : kCountries; - return Material( - type: MaterialType.transparency, - child: Container( - decoration: BoxDecoration( - color: const Color(0xFFFFFFFF), - borderRadius: BorderRadius.circular(NhamRadii.containerLg), // rounded-2xl - border: Border.all(color: NhamColors.inputBorder), - boxShadow: const [ - // shadow-[0_20px_60px_rgba(44,36,22,0.18)] - BoxShadow( - color: Color(0x2E2C2416), - blurRadius: 60, - offset: Offset(0, 20), - ), - ], - ), - clipBehavior: Clip.antiAlias, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Search header (p-2 + bottom border). - Container( - padding: const EdgeInsets.all(NhamSpacing.sp2), - decoration: const BoxDecoration( - border: Border( - bottom: BorderSide(color: NhamColors.inputBorder), + // Keyboard inset → the sheet lifts so the pinned search + list clear it. + final bottomInset = MediaQuery.viewInsetsOf(context).bottom; + + return Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: FractionallySizedBox( + heightFactor: 0.85, + child: Container( + decoration: const BoxDecoration( + color: NhamColors.elev, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + const SizedBox(height: 8), + // Grabber. + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: NhamColors.border, + borderRadius: BorderRadius.circular(NhamRadii.pill), ), ), - child: TextField( - controller: search, - autofocus: true, - onChanged: onQueryChange, - cursorColor: NhamColors.accent, - style: NhamTextStyles.sansRegular(fontSize: 13) - .copyWith(color: NhamColors.text), - decoration: InputDecoration( - isDense: true, - filled: true, - fillColor: NhamColors.track, // bg-[#F5F4F0] - hintText: tr('onboarding.origin.searchCountry'), - hintStyle: NhamTextStyles.sansRegular(fontSize: 13) - .copyWith(color: NhamColors.textHelp), // #8B8682 - contentPadding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: NhamSpacing.sp2, + // Pinned search. + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: TextField( + controller: _search, + autofocus: false, + onChanged: (v) => setState(() => _query = v), + cursorColor: NhamColors.accent, + style: NhamTextStyles.sansRegular(fontSize: 14) + .copyWith(color: NhamColors.text), + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: NhamColors.track, + prefixIcon: const Icon( + LucideIcons.search, + size: 16, + color: NhamColors.textHelp, + ), + hintText: tr('onboarding.origin.searchCountry'), + hintStyle: NhamTextStyles.sansRegular(fontSize: 14) + .copyWith(color: NhamColors.textHelp), + contentPadding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: NhamSpacing.sp3, + ), + border: _border(), + enabledBorder: _border(), + focusedBorder: _border(), ), - border: _border(), - enabledBorder: _border(), - focusedBorder: _border(), ), ), - ), - Flexible( - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxHeight), + const _SheetDivider(), + Expanded( child: filtered.isEmpty - ? Padding( - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: NhamSpacing.sp2, - ), + ? Center( child: Text( tr('onboarding.origin.noCountries'), - textAlign: TextAlign.center, - style: NhamTextStyles.sansRegular(fontSize: 13) + style: NhamTextStyles.sansRegular(fontSize: 14) .copyWith(color: NhamColors.textHelp), ), ) - : ListView.builder( - padding: const EdgeInsets.all(NhamSpacing.sp1), // p-1 - shrinkWrap: true, + : ListView( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 24), keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - itemCount: filtered.length, - itemBuilder: (context, i) { - final item = filtered[i]; - return _OptionRow( - country: item, - selected: selectedValue == item.value, - onTap: () => onPick(item.value), - ); - }, + children: [ + if (!searching) ...[ + _SectionLabel( + tr('onboarding.origin.commonCountries'), + ), + for (final c in _pinned) + _OptionRow( + country: c, + selected: widget.selectedValue == c.value, + onTap: () => Navigator.of(context).pop(c.value), + ), + const SizedBox(height: 8), + const _SheetDivider(), + const SizedBox(height: 8), + ], + for (final c in filtered) + _OptionRow( + country: c, + selected: widget.selectedValue == c.value, + onTap: () => Navigator.of(context).pop(c.value), + ), + ], ), ), - ), - ], + ], + ), ), ), ); } OutlineInputBorder _border() => OutlineInputBorder( - borderRadius: BorderRadius.circular(NhamRadii.md), // rounded-lg + borderRadius: BorderRadius.circular(NhamRadii.md), borderSide: BorderSide.none, ); } +class _SectionLabel extends StatelessWidget { + const _SectionLabel(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 6), + child: Text( + text.toUpperCase(), + style: NhamTextStyles.sansBold(fontSize: 10) + .copyWith(letterSpacing: 1.5, color: NhamColors.stone), + ), + ); + } +} + +class _SheetDivider extends StatelessWidget { + const _SheetDivider(); + + @override + Widget build(BuildContext context) => + Container(height: 1, color: NhamColors.inputBorder); +} + class _OptionRow extends StatefulWidget { const _OptionRow({ required this.country, @@ -334,7 +294,6 @@ class _OptionRowState extends State<_OptionRow> { @override Widget build(BuildContext context) { - // selected = bg-[#C9A87C]/10 + medium #2C2416; hover/press = bg #F5F4F0. final Color fill = widget.selected ? NhamColors.accent10 : (_pressed ? NhamColors.track : Colors.transparent); @@ -347,32 +306,35 @@ class _OptionRowState extends State<_OptionRow> { child: Container( decoration: BoxDecoration( color: fill, - borderRadius: BorderRadius.circular(NhamRadii.buttonXl), // rounded-xl + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), ), padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, // px-3 - vertical: NhamSpacing.sp2_5, // py-2.5 + horizontal: NhamSpacing.sp3, + vertical: NhamSpacing.sp3, ), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Flexible( + Expanded( child: Text( widget.country.value, maxLines: 1, overflow: TextOverflow.ellipsis, style: (widget.selected - ? NhamTextStyles.sansMedium(fontSize: 13) - : NhamTextStyles.sansRegular(fontSize: 13)) + ? NhamTextStyles.sansMedium(fontSize: 14) + : NhamTextStyles.sansRegular(fontSize: 14)) .copyWith(color: NhamColors.text), ), ), const SizedBox(width: NhamSpacing.sp3), Text( widget.country.vi, - style: NhamTextStyles.sansRegular(fontSize: 11) - .copyWith(color: NhamColors.textHelp), // #8B8682 + style: NhamTextStyles.sansRegular(fontSize: 12) + .copyWith(color: NhamColors.textHelp), ), + if (widget.selected) ...[ + const SizedBox(width: NhamSpacing.sp2), + const Icon(LucideIcons.check, size: 16, color: NhamColors.accent), + ], ], ), ), From b79058905508328c73cc947e67ba640debece1eb Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 14:10:45 +0700 Subject: [PATCH 30/57] feat(mobile): /welcome counts the computed target up in Lora 40 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the check-badge radar pulse with the payoff the wizard earned: warm the dashboard bundle (the authority on the freshly-computed daily target), then count it up from zero in Lora 40 with a "kcal/day" caption. Falls back to the title-only setup beat when no target resolves — no fabricated number. The count-up is paused under reduced-motion, and the 1.6s min window and cache-warm gate are preserved. Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 4 +- apps/mobile-flutter/assets/l10n/vi.json | 4 +- .../screens/welcome_setup_screen.dart | 157 ++++++++++-------- 3 files changed, 95 insertions(+), 70 deletions(-) diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index ae72b68d..f32c1b5e 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -539,7 +539,9 @@ "saveError": "Couldn't save this step. Please try again.", "setup": { "title": "Setting up your account", - "subtitle": "Personalizing your targets — this'll just take a moment." + "subtitle": "Personalizing your targets — this'll just take a moment.", + "targetReadyLabel": "Your daily target", + "perDay": "kcal/day" }, "origin": { "title": "Where are you from?", diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index a758fd1f..39bdf931 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -538,7 +538,9 @@ "saveError": "Không thể lưu bước này. Vui lòng thử lại.", "setup": { "title": "Đang thiết lập tài khoản của bạn", - "subtitle": "Đang cá nhân hoá mục tiêu của bạn — chỉ mất một chút thôi." + "subtitle": "Đang cá nhân hoá mục tiêu của bạn — chỉ mất một chút thôi.", + "targetReadyLabel": "Mục tiêu mỗi ngày của bạn", + "perDay": "kcal/ngày" }, "origin": { "title": "Bạn đến từ đâu?", diff --git a/apps/mobile-flutter/lib/features/onboarding/screens/welcome_setup_screen.dart b/apps/mobile-flutter/lib/features/onboarding/screens/welcome_setup_screen.dart index 74a56fd5..bd1c6202 100644 --- a/apps/mobile-flutter/lib/features/onboarding/screens/welcome_setup_screen.dart +++ b/apps/mobile-flutter/lib/features/onboarding/screens/welcome_setup_screen.dart @@ -3,20 +3,24 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../data/session_provider.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_typography.dart'; import '../../dashboard/data/dashboard_providers.dart'; +import '../../dashboard/logic/dashboard_format.dart'; import '../../logging/data/logging_providers.dart'; import '../providers/onboarding_providers.dart'; -/// "Setting up your account" — the celebratory finish shown after the wizard -/// completes, before landing on `/logging`. +/// The celebratory finish shown after the wizard completes, before landing on +/// `/logging`. /// -/// It does double duty: a lively beat so the transition feels deliberate, and a -/// cache-warm gate that drops the stale/null targets the dashboard + logging -/// screens may be holding (the per-step saves only refreshed the profile). It -/// invalidates the profile, dashboard bundle, and logging profile, warms the -/// profile fetch, holds a minimum window so it never flashes, then routes on. +/// It does double duty: a deliberate beat that pays off the wizard by counting +/// the freshly-computed daily calorie target up in Lora 40, and a cache-warm +/// gate that drops the stale/null targets the dashboard + logging screens may +/// be holding (the per-step saves only refreshed the profile). It invalidates +/// the profile, dashboard bundle, and logging profile, warms the dashboard +/// fetch (the authority on the computed target), holds a minimum window so it +/// never flashes, then routes on. class WelcomeSetupScreen extends ConsumerStatefulWidget { const WelcomeSetupScreen({super.key}); @@ -26,11 +30,13 @@ class WelcomeSetupScreen extends ConsumerStatefulWidget { class _WelcomeSetupScreenState extends ConsumerState with SingleTickerProviderStateMixin { - // Gentle radar pulse around the badge. - late final AnimationController _pulse = AnimationController( + // Drives the count-up from 0 → target. + late final AnimationController _count = AnimationController( vsync: this, - duration: const Duration(milliseconds: 1600), - )..repeat(); + duration: const Duration(milliseconds: 1100), + ); + + int? _target; @override void initState() { @@ -40,7 +46,7 @@ class _WelcomeSetupScreenState extends ConsumerState @override void dispose() { - _pulse.dispose(); + _count.dispose(); super.dispose(); } @@ -50,12 +56,33 @@ class _WelcomeSetupScreenState extends ConsumerState ref.invalidate(loggingProfileProvider); ref.invalidate(profileProvider); - // Warm the profile (targets), but never block the finish on a slow/failed - // fetch — the min window carries the UX either way. - final warm = ref - .read(profileProvider.future) - .then((_) {}, onError: (_, __) {}) - .timeout(const Duration(seconds: 6), onTimeout: () {}); + final userId = ref.read(currentSessionProvider)?.user.id; + + // Warm the dashboard bundle (the computed target lives here), but never + // block the finish on a slow/failed fetch — the min window carries the UX. + Future warm = Future.value(); + if (userId != null) { + final args = (userId: userId, date: todayDateString()); + warm = ref + .read(dashboardBundleProvider(args).future) + .then((bundle) { + final t = bundle.profile?.calorieTarget; + if (t != null && mounted) { + setState(() => _target = t.round()); + final reduced = WidgetsBinding + .instance + .platformDispatcher + .accessibilityFeatures + .disableAnimations; + if (reduced) { + _count.value = 1; + } else { + _count.forward(); + } + } + }, onError: (_, __) {}) + .timeout(const Duration(seconds: 6), onTimeout: () {}); + } await Future.wait([ warm, @@ -70,6 +97,16 @@ class _WelcomeSetupScreenState extends ConsumerState context.go('/logging'); } + String _fmt(int n) { + final s = n.toString(); + final buf = StringBuffer(); + for (var i = 0; i < s.length; i++) { + if (i > 0 && (s.length - i) % 3 == 0) buf.write(','); + buf.write(s[i]); + } + return buf.toString(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -81,8 +118,40 @@ class _WelcomeSetupScreenState extends ConsumerState child: Column( mainAxisSize: MainAxisSize.min, children: [ - _Badge(pulse: _pulse), - const SizedBox(height: 28), + if (_target != null) ...[ + Text( + tr('onboarding.setup.targetReadyLabel').toUpperCase(), + textAlign: TextAlign.center, + style: NhamTextStyles.eyebrow() + .copyWith(color: NhamColors.stone), + ), + const SizedBox(height: 10), + // The target counts up in Lora 40. + AnimatedBuilder( + animation: _count, + builder: (context, _) { + final shown = (_target! * _count.value).round(); + return Text.rich( + TextSpan( + children: [ + TextSpan(text: _fmt(shown)), + TextSpan( + text: ' ${tr('onboarding.setup.perDay')}', + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.md, + ).copyWith(color: NhamColors.textMuted), + ), + ], + ), + textAlign: TextAlign.center, + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h1, + ).copyWith(color: NhamColors.text), + ); + }, + ), + const SizedBox(height: 24), + ], Text( tr('onboarding.setup.title'), textAlign: TextAlign.center, @@ -104,51 +173,3 @@ class _WelcomeSetupScreenState extends ConsumerState ); } } - -/// Accent badge with a check, wrapped by an expanding-and-fading radar ring. -class _Badge extends StatelessWidget { - const _Badge({required this.pulse}); - - final Animation pulse; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 120, - height: 120, - child: Stack( - alignment: Alignment.center, - children: [ - AnimatedBuilder( - animation: pulse, - builder: (context, _) { - final v = pulse.value; - final size = 76 + 44 * v; - return Container( - width: size, - height: size, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: NhamColors.accent.withValues(alpha: (1 - v) * 0.22), - ), - ); - }, - ), - Container( - width: 76, - height: 76, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: NhamColors.accent, - ), - child: const Icon( - Icons.check_rounded, - size: 38, - color: NhamColors.cream, - ), - ), - ], - ), - ); - } -} From 0e482ead67e8e74e16e6f1c6c1ffb2e841f555df Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 14:12:53 +0700 Subject: [PATCH 31/57] refactor(mobile): Lucide glyphs across the auth + onboarding surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the remaining Material icons on the onboarding wizard (X, arrow-back, skip-next, arrow-forward → x, arrowLeft, skipForward, arrowRight), screen-origin (translate/public/place → languages/globe/mapPin), custom-select (chevron + check), and the auth password reveal toggle (visibility → eye/eyeOff). One icon DNA across the pre-app surfaces instead of the Material/Lucide mix. Co-Authored-By: Claude Opus 4.8 --- .../lib/features/auth/widgets/auth_text_field.dart | 3 ++- .../features/onboarding/screens/screen_origin.dart | 9 +++++---- .../features/onboarding/widgets/custom_select.dart | 5 +++-- .../onboarding/widgets/onboarding_wizard.dart | 14 ++++++++------ 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/apps/mobile-flutter/lib/features/auth/widgets/auth_text_field.dart b/apps/mobile-flutter/lib/features/auth/widgets/auth_text_field.dart index 3999bfdf..30d44ab0 100644 --- a/apps/mobile-flutter/lib/features/auth/widgets/auth_text_field.dart +++ b/apps/mobile-flutter/lib/features/auth/widgets/auth_text_field.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; @@ -209,7 +210,7 @@ class _RevealToggleState extends State<_RevealToggle> { onTapCancel: () => setState(() => _pressed = false), onTap: widget.onTap, child: Icon( - widget.revealed ? Icons.visibility_off_outlined : Icons.visibility_outlined, + widget.revealed ? LucideIcons.eyeOff : LucideIcons.eye, size: 16, color: color, ), diff --git a/apps/mobile-flutter/lib/features/onboarding/screens/screen_origin.dart b/apps/mobile-flutter/lib/features/onboarding/screens/screen_origin.dart index a94318a9..bcd2d16f 100644 --- a/apps/mobile-flutter/lib/features/onboarding/screens/screen_origin.dart +++ b/apps/mobile-flutter/lib/features/onboarding/screens/screen_origin.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; @@ -14,7 +15,7 @@ import '../widgets/language_toggle.dart'; /// sets the persisted `preferredLocale` AND live-switches the app language via /// `context.setLocale` (matching web's `switchLocale`). lucide /// `Languages`/`Globe`/`MapPin` → -/// [Icons.translate]/[Icons.public]/[Icons.place_outlined]. +/// [LucideIcons.languages]/[LucideIcons.globe]/[LucideIcons.mapPin]. class ScreenOrigin extends StatefulWidget { const ScreenOrigin({ super.key, @@ -72,7 +73,7 @@ class _ScreenOriginState extends State { _Card( children: [ _LabelRow( - icon: Icons.translate, + icon: LucideIcons.languages, label: tr('onboarding.origin.preferredLanguage'), ), const SizedBox(height: NhamSpacing.sp4), @@ -93,7 +94,7 @@ class _ScreenOriginState extends State { _Card( children: [ _Field( - icon: Icons.public, + icon: LucideIcons.globe, label: tr('onboarding.origin.countryOfOrigin'), hint: tr('onboarding.origin.countryOfOriginHint'), child: CountrySelect( @@ -106,7 +107,7 @@ class _ScreenOriginState extends State { ), const SizedBox(height: NhamSpacing.sp4), _Field( - icon: Icons.place_outlined, + icon: LucideIcons.mapPin, label: tr('onboarding.origin.countryOfResidence'), hint: tr('onboarding.origin.countryOfResidenceHint'), child: CountrySelect( diff --git a/apps/mobile-flutter/lib/features/onboarding/widgets/custom_select.dart b/apps/mobile-flutter/lib/features/onboarding/widgets/custom_select.dart index 943b1675..9589920e 100644 --- a/apps/mobile-flutter/lib/features/onboarding/widgets/custom_select.dart +++ b/apps/mobile-flutter/lib/features/onboarding/widgets/custom_select.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; @@ -150,7 +151,7 @@ class _CustomSelectState extends State RotationTransition( turns: Tween(begin: 0, end: 0.5).animate(_chevron), child: const Icon( - Icons.keyboard_arrow_down, + LucideIcons.chevronDown, size: 16, color: NhamColors.textHelp, ), @@ -311,7 +312,7 @@ class _OptionRowState extends State<_OptionRow> { ), ), if (widget.selected) - const Icon(Icons.check, size: 16, color: NhamColors.accent) + const Icon(LucideIcons.check, size: 16, color: NhamColors.accent) else const SizedBox(width: 16, height: 16), ], diff --git a/apps/mobile-flutter/lib/features/onboarding/widgets/onboarding_wizard.dart b/apps/mobile-flutter/lib/features/onboarding/widgets/onboarding_wizard.dart index 0385de3e..9322be10 100644 --- a/apps/mobile-flutter/lib/features/onboarding/widgets/onboarding_wizard.dart +++ b/apps/mobile-flutter/lib/features/onboarding/widgets/onboarding_wizard.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../models/onboarding.dart'; import '../../../theme/nham_colors.dart'; @@ -35,8 +36,9 @@ double? _parseAggression(String? raw, double fallback) { /// The modal entrance animation (scale/opacity/y) is intentionally NOT here: it /// belongs to the dialog presentation ([showOnboardingDialog]), not the wizard. /// -/// lucide icons → Material: `X`→[Icons.close], `ArrowLeft`→[Icons.arrow_back], -/// `ArrowRight`→[Icons.arrow_forward], `SkipForward`→[Icons.skip_next]. +/// Icons are Lucide one-for-one: `X`→[LucideIcons.x], +/// `ArrowLeft`→[LucideIcons.arrowLeft], `ArrowRight`→[LucideIcons.arrowRight], +/// `SkipForward`→[LucideIcons.skipForward]. class OnboardingWizard extends ConsumerStatefulWidget { const OnboardingWizard({ super.key, @@ -307,7 +309,7 @@ class _CloseButtonState extends State<_CloseButton> { color: _pressed ? const Color(0x80EAE7E0) : Colors.transparent, ), child: Icon( - Icons.close, + LucideIcons.x, size: 20, color: _pressed ? NhamColors.text : NhamColors.textHelp, ), @@ -558,7 +560,7 @@ class _BackButtonState extends State<_BackButton> { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.arrow_back, size: 16, color: color), + Icon(LucideIcons.arrowLeft, size: 16, color: color), const SizedBox(width: NhamSpacing.sp2), Text( tr('common.back'), @@ -623,7 +625,7 @@ class _SkipButtonState extends State<_SkipButton> { ), ) else - Icon(Icons.skip_next, size: 14, color: textColor), + Icon(LucideIcons.skipForward, size: 14, color: textColor), ], ), ), @@ -695,7 +697,7 @@ class _NextButtonState extends State<_NextButton> { ), ) else - const Icon(Icons.arrow_forward, + const Icon(LucideIcons.arrowRight, size: 16, color: NhamColors.cream), ], ), From 8ab364d423f7abff575a3ed7dcc4e9b82e64087d Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 14:13:53 +0700 Subject: [PATCH 32/57] refactor(mobile): Lucide glyphs on the dashboard meal FAB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the floating meal trigger's last Material icons — restaurant/close → utensilsCrossed/x for the FAB face, arrow-upward → arrowUp for the inline submit. No Material Icons remain anywhere in the app; the whole surface is one Lucide DNA. Co-Authored-By: Claude Opus 4.8 --- .../features/dashboard/widgets/floating_meal_trigger.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/floating_meal_trigger.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/floating_meal_trigger.dart index 18096961..3f55e94b 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/floating_meal_trigger.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/floating_meal_trigger.dart @@ -9,6 +9,7 @@ library; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:go_router/go_router.dart'; import '../../../theme/nham_colors.dart'; @@ -123,7 +124,7 @@ class _FloatingMealTriggerState extends State { ], ), child: Icon( - _expanded ? Icons.close : Icons.restaurant_outlined, + _expanded ? LucideIcons.x : LucideIcons.utensilsCrossed, size: 20, // h-5 w-5 color: Colors.white, ), @@ -236,7 +237,7 @@ class _MealInputBarState extends State<_MealInputBar> { borderRadius: BorderRadius.circular(NhamRadii.buttonXl), // 12 ), child: Icon( - Icons.arrow_upward, + LucideIcons.arrowUp, size: 16, // h-4 w-4 color: hasText ? Colors.white : NhamColors.stone, ), From 476a000a329375d4f71294306bdcd4b79efd0403 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 14:20:58 +0700 Subject: [PATCH 33/57] feat(mobile): add once-daily partial-yesterday nudge to logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the web partial-yesterday-prompt.tsx pattern: when yesterday was under-logged (real meals but below half the calorie target), show a quiet once-per-session nudge above today's feed inviting the user to open yesterday and fold in what they missed. Reuses the existing per-day slice provider (loggingDayProvider) for the yesterday-totals fetch, suppresses on unknown calories (the calorie-based partial check can't be trusted then), and tracks dismissal in a session-scoped StateProvider keyed by date — mirroring the web's in-memory useState so the prompt is at most once per launch. Wires the already- translated partialYesterdayPrompt l10n keys. Co-Authored-By: Claude Opus 4.8 --- .../features/logging/data/logging_keys.dart | 11 + .../logging/data/logging_providers.dart | 7 + .../logging/screens/logging_screen.dart | 12 + .../widgets/partial_yesterday_prompt.dart | 217 ++++++++++++++++++ 4 files changed, 247 insertions(+) create mode 100644 apps/mobile-flutter/lib/features/logging/widgets/partial_yesterday_prompt.dart diff --git a/apps/mobile-flutter/lib/features/logging/data/logging_keys.dart b/apps/mobile-flutter/lib/features/logging/data/logging_keys.dart index b301b9a5..59237f30 100644 --- a/apps/mobile-flutter/lib/features/logging/data/logging_keys.dart +++ b/apps/mobile-flutter/lib/features/logging/data/logging_keys.dart @@ -35,6 +35,17 @@ String todayDateString([DateTime? date]) { return '${d.year}-$m-$day'; } +/// Local YYYY-MM-DD `delta` days from [date] (matches the web's `addDays`). +String addDays(String date, int delta) { + final parts = date.split('-'); + final base = DateTime( + int.parse(parts[0]), + int.parse(parts[1]), + int.parse(parts[2]), + ); + return todayDateString(base.add(Duration(days: delta))); +} + /// Local timezone offset in MINUTES, matching JS `Date.getTimezoneOffset()` /// (minutes BEHIND UTC, i.e. `-(localOffsetMinutes)`). int timezoneOffsetMinutes([DateTime? date]) { diff --git a/apps/mobile-flutter/lib/features/logging/data/logging_providers.dart b/apps/mobile-flutter/lib/features/logging/data/logging_providers.dart index a2e4ce4c..d4cc4a33 100644 --- a/apps/mobile-flutter/lib/features/logging/data/logging_providers.dart +++ b/apps/mobile-flutter/lib/features/logging/data/logging_providers.dart @@ -115,6 +115,13 @@ final mealDatesProvider = ); }); +/// Session-scoped dismiss state for the once-daily "yesterday looks +/// under-logged" nudge, keyed by the yesterday date so a fresh day re-prompts. +/// Mirrors the web's in-memory `yesterdayPromptDismissed` useState — it resets +/// on app relaunch (not persisted), so the prompt is at most once per session. +final yesterdayPromptDismissedProvider = + StateProvider.family((ref, date) => false); + /// The onboarding profile row the logging screen needs (calorie + macro /// targets). Mirrors RN `useQuery({ queryKey: onboardingKeys.profile, ... })`. final loggingProfileProvider = diff --git a/apps/mobile-flutter/lib/features/logging/screens/logging_screen.dart b/apps/mobile-flutter/lib/features/logging/screens/logging_screen.dart index b5710b8f..3c3abad9 100644 --- a/apps/mobile-flutter/lib/features/logging/screens/logging_screen.dart +++ b/apps/mobile-flutter/lib/features/logging/screens/logging_screen.dart @@ -11,6 +11,7 @@ import '../data/logging_keys.dart'; import '../data/logging_models.dart'; import '../data/logging_providers.dart'; import '../widgets/feed_area.dart'; +import '../widgets/partial_yesterday_prompt.dart'; import '../widgets/timeline_picker.dart'; /// The logging tab. Owns the selected date + picker-expanded state so the date @@ -89,6 +90,17 @@ class _LoggingScreenState extends ConsumerState { ), ), ), + // A once-daily nudge when *yesterday* was under-logged — only while + // viewing today, and only until dismissed this session. + if (_selectedDate == _today && + !ref.watch(yesterdayPromptDismissedProvider( + addDays(_today, -1)))) + PartialYesterdayPrompt( + userId: userId, + yesterday: addDays(_today, -1), + calorieTarget: profile.calorieTarget, + onOpenDay: (date) => setState(() => _selectedDate = date), + ), Expanded( child: Stack( children: [ diff --git a/apps/mobile-flutter/lib/features/logging/widgets/partial_yesterday_prompt.dart b/apps/mobile-flutter/lib/features/logging/widgets/partial_yesterday_prompt.dart new file mode 100644 index 00000000..ad43a41c --- /dev/null +++ b/apps/mobile-flutter/lib/features/logging/widgets/partial_yesterday_prompt.dart @@ -0,0 +1,217 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../shared/widgets/nham_text.dart'; +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_theme.dart'; +import '../../../theme/nham_typography.dart'; +import '../../dashboard/logic/dashboard_format.dart' show formatCount; +import '../data/logging_models.dart'; +import '../data/logging_providers.dart'; +import '../logic/format.dart'; +import '../logic/meal_utils.dart' show isLikelyPartialDay; + +/// A once-daily nudge shown above today's feed when *yesterday* looks +/// under-logged: it opens yesterday so the user can fold in whatever they +/// missed. Ported from the web `partial-yesterday-prompt.tsx`. +/// +/// Renders nothing when yesterday has no meals, has any meal with unknown +/// calories (the calorie-based check can't be trusted), or is above the +/// partial-day floor. Dismissal is session-scoped (see +/// [yesterdayPromptDismissedProvider]). +class PartialYesterdayPrompt extends ConsumerWidget { + const PartialYesterdayPrompt({ + super.key, + required this.userId, + required this.yesterday, + required this.calorieTarget, + required this.onOpenDay, + }); + + final String userId; + final String yesterday; + final int calorieTarget; + final ValueChanged onOpenDay; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final dayAsync = + ref.watch(loggingDayProvider(LoggingDayArgs(userId, yesterday))); + final meals = dayAsync.valueOrNull?.persistedMeals ?? const []; + + final hasMeals = meals.isNotEmpty; + // A meal with unknown calories makes the day's total untrustworthy, so the + // partial-day check would be misleading — suppress the prompt then. + final hasUnknownCalories = + meals.any((m) => m.nutrition.caloriesKcal == null); + final calories = round0( + meals.fold(0, (s, m) => s + (m.nutrition.caloriesKcal ?? 0)), + ); + + if (!hasMeals || + hasUnknownCalories || + !isLikelyPartialDay(calories.toDouble(), calorieTarget)) { + return const SizedBox.shrink(); + } + + final locale = context.locale.toString(); + final t = 'logging.feedArea.partialYesterdayPrompt'; + + return Padding( + padding: const EdgeInsets.fromLTRB( + NhamSpacing.sp3, + NhamSpacing.sp3, + NhamSpacing.sp3, + 0, + ), + child: Container( + padding: const EdgeInsets.all(NhamSpacing.sp3), + decoration: BoxDecoration( + color: NhamColors.surface, + borderRadius: BorderRadius.circular(NhamRadii.containerLg), + border: Border.all(color: NhamColors.borderSoft), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NhamText( + '$t.title'.tr(), + variant: NhamTextVariant.italicAccent, + style: const TextStyle(color: NhamColors.danger), + ), + const SizedBox(height: 4), // mt-1 + NhamText( + '$t.body'.tr(namedArgs: { + 'calories': formatCount(calories, locale), + 'target': formatCount(calorieTarget, locale), + }), + variant: NhamTextVariant.small, + style: const TextStyle(color: NhamColors.textMuted), + ), + const SizedBox(height: NhamSpacing.sp3), // mt-3 + _OpenButton( + label: '$t.open'.tr(), + onTap: () => onOpenDay(yesterday), + ), + ], + ), + ), + const SizedBox(width: NhamSpacing.sp3), + _DismissButton( + label: '$t.dismiss'.tr(), + onTap: () => ref + .read(yesterdayPromptDismissedProvider(yesterday).notifier) + .state = true, + ), + ], + ), + ), + ); + } +} + +/// "Open yesterday" — a ghost pill (ArrowLeft + label) whose border lightens to +/// accent/50 on press, mirroring the web's hover treatment. +class _OpenButton extends StatefulWidget { + const _OpenButton({required this.label, required this.onTap}); + final String label; + final VoidCallback onTap; + + @override + State<_OpenButton> createState() => _OpenButtonState(); +} + +class _OpenButtonState extends State<_OpenButton> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: widget.label, + child: GestureDetector( + onTap: widget.onTap, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + constraints: const BoxConstraints(minHeight: 32), // min-h-8 + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: 6, // py-1.5 + ), + decoration: BoxDecoration( + color: _pressed ? NhamColors.hover : Colors.transparent, + borderRadius: BorderRadius.circular(NhamRadii.pill), + border: Border.all( + color: _pressed ? NhamColors.accent50 : NhamColors.borderSoft, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(LucideIcons.arrowLeft, size: 16, color: NhamColors.text), + const SizedBox(width: 8), // gap-2 + NhamText( + widget.label, + variant: NhamTextVariant.body, + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) + .copyWith(color: NhamColors.text), + ), + ], + ), + ), + ), + ); + } +} + +/// The dismiss affordance — a 32×32 X target whose fill tints to hover on press. +class _DismissButton extends StatefulWidget { + const _DismissButton({required this.label, required this.onTap}); + final String label; + final VoidCallback onTap; + + @override + State<_DismissButton> createState() => _DismissButtonState(); +} + +class _DismissButtonState extends State<_DismissButton> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: widget.label, + child: GestureDetector( + onTap: widget.onTap, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 32, + height: 32, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _pressed ? NhamColors.hover : Colors.transparent, + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.x, + size: 16, + color: _pressed ? NhamColors.text : NhamColors.textMuted, + ), + ), + ), + ); + } +} From 986cc577f6e242a0a45669ec9d97ef17a902f736 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 14:31:48 +0700 Subject: [PATCH 34/57] refactor(mobile): flatten settings into grouped rows with focused editors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the corridor-and-tabs hybrid (a screen → a screen with three web-style tab panels) with a single scrollable root of grouped preference rows, each carrying a current-value subline and pushing ONE focused editor (Cupertino swipe-back): - Goal & pace ("Cutting · 0.50 kg/wk") → numeric body-metrics editor, which keeps the already-built felt save bar (the bar is now reserved for this, the only numeric surface). - Cooking habits → instant-commit editor (toggles persist on change). - Region & language ("Việt Nam · Tiếng Việt") → instant-commit editor with the country panel plus an app-language selector; switching language live-sets the locale and persists preferredLocale. Adds an About group (version / privacy / terms). Version is rendered from a static constant — package_info_plus is not a pubspec dependency, so no dep was added. Privacy/terms copy their URL to the clipboard (no url_launcher dependency available). A shared buildProfilePayload() unifies the target recomputation across the goal editor and the instant-commit editors. Instant-commit guards against the markSaved()-renotify loop and on incomplete body metrics never PUTs nulls. The fixed-defaults (never fabricated 65/165/25), error-state (retry, not re-onboarding), and felt-save behavior are preserved, not regressed. Deletes the now-orphaned TabStrip control (only the tabbed form used it). Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 15 +- apps/mobile-flutter/assets/l10n/vi.json | 15 +- .../features/settings/controls/tab_strip.dart | 96 ------ .../settings/logic/profile_payload.dart | 50 +++ .../settings/screens/settings_screen.dart | 308 ++++++++++++++---- .../widgets/instant_commit_editor.dart | 148 +++++++++ .../settings/widgets/profile_form.dart | 109 +------ .../settings/widgets/region_editor.dart | 86 +++++ 8 files changed, 572 insertions(+), 255 deletions(-) delete mode 100644 apps/mobile-flutter/lib/features/settings/controls/tab_strip.dart create mode 100644 apps/mobile-flutter/lib/features/settings/logic/profile_payload.dart create mode 100644 apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart create mode 100644 apps/mobile-flutter/lib/features/settings/widgets/region_editor.dart diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index f32c1b5e..23ae3d81 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -18,7 +18,8 @@ "notFound": "Page not found", "notSignedIn": "Not signed in.", "increase": "Increase", - "decrease": "Decrease" + "decrease": "Decrease", + "copied": "Link copied" }, "nav": { "logging": "Logging", @@ -1195,6 +1196,18 @@ "cookingSubtitle": "Oil usage, rice, sugar, protein, and broth defaults", "saveError": "Failed to save settings. Please try again.", "invalidError": "Some entries are invalid — please review your body metrics." + }, + "rows": { + "goalPace": "Goal & pace", + "cooking": "Cooking habits", + "region": "Region & language", + "notSet": "Not set" + }, + "about": { + "title": "About", + "version": "Version", + "privacy": "Privacy policy", + "terms": "Terms of service" } }, "errors": { diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index 39bdf931..6cf484b9 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -18,7 +18,8 @@ "notFound": "Không tìm thấy trang", "notSignedIn": "Chưa đăng nhập.", "increase": "Tăng", - "decrease": "Giảm" + "decrease": "Giảm", + "copied": "Đã sao chép liên kết" }, "nav": { "logging": "Ghi nhận", @@ -1194,6 +1195,18 @@ "cookingSubtitle": "Thiết lập dầu mỡ, cơm, đường, đạm và nước dùng mặc định", "saveError": "Không thể lưu cài đặt. Vui lòng thử lại.", "invalidError": "Một số giá trị không hợp lệ — vui lòng kiểm tra lại chỉ số cơ thể." + }, + "rows": { + "goalPace": "Mục tiêu & tốc độ", + "cooking": "Thói quen nấu nướng", + "region": "Khu vực & ngôn ngữ", + "notSet": "Chưa thiết lập" + }, + "about": { + "title": "Giới thiệu", + "version": "Phiên bản", + "privacy": "Chính sách bảo mật", + "terms": "Điều khoản dịch vụ" } }, "errors": { diff --git a/apps/mobile-flutter/lib/features/settings/controls/tab_strip.dart b/apps/mobile-flutter/lib/features/settings/controls/tab_strip.dart deleted file mode 100644 index 9f201c52..00000000 --- a/apps/mobile-flutter/lib/features/settings/controls/tab_strip.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../../../theme/nham_colors.dart'; -import '../../../theme/nham_theme.dart'; -import '../../../theme/nham_typography.dart'; - -/// A single tab descriptor for [TabStrip]. -class TabStripItem { - final String id; - final String label; - const TabStripItem({required this.id, required this.label}); -} - -/// RN port of the web settings `TabsList` — a pill segmented control. -/// -/// Container: `inputBorder40` bg, radius 12, 4px inner padding, 4px gap. Active -/// pill: white (`elev`) fill + `shadow.xs`, rounded-md (6). Press: 0.7 opacity. -class TabStrip extends StatelessWidget { - const TabStrip({ - super.key, - required this.tabs, - required this.active, - required this.onChange, - }); - - final List tabs; - final String active; - final ValueChanged onChange; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(NhamSpacing.sp1), - decoration: BoxDecoration( - color: NhamColors.inputBorder40, - borderRadius: BorderRadius.circular(NhamRadii.buttonXl), - ), - child: Row( - children: [ - for (var i = 0; i < tabs.length; i++) ...[ - if (i > 0) const SizedBox(width: NhamSpacing.sp1), - Expanded(child: _TabButton( - tab: tabs[i], - active: tabs[i].id == active, - onTap: () { - HapticFeedback.selectionClick(); - onChange(tabs[i].id); - }, - )), - ], - ], - ), - ); - } -} - -class _TabButton extends StatelessWidget { - const _TabButton({ - required this.tab, - required this.active, - required this.onTap, - }); - - final TabStripItem tab; - final bool active; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - alignment: Alignment.center, - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: NhamSpacing.sp1 + 2, // py-1.5 = 6 - ), - decoration: BoxDecoration( - color: active ? NhamColors.elev : Colors.transparent, - borderRadius: BorderRadius.circular(NhamRadii.md), // rounded-lg = 8 - boxShadow: active ? const [NhamShadows.sm] : null, - ), - child: Text( - tab.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm).copyWith( - color: active ? NhamColors.text : NhamColors.textWarm, - ), - ), - ), - ); - } -} diff --git a/apps/mobile-flutter/lib/features/settings/logic/profile_payload.dart b/apps/mobile-flutter/lib/features/settings/logic/profile_payload.dart new file mode 100644 index 00000000..267513f5 --- /dev/null +++ b/apps/mobile-flutter/lib/features/settings/logic/profile_payload.dart @@ -0,0 +1,50 @@ +import '../data/profile_providers.dart'; +import '../widgets/profile_form_values.dart'; +import 'tdee.dart'; + +/// Builds the flat `PUT /api/v1/profile` payload from the current form values, +/// recomputing TDEE + targets exactly as the web `handleSave` does. Shared by +/// every focused settings editor (the numeric goal editor and the +/// instant-commit cooking / region editors) so each persists the SAME contract. +/// +/// Requires complete body metrics — callers gate on a non-null profile and a +/// passing [validateBodyMetrics] before instant-committing. +ProfileSavePayload buildProfilePayload( + ProfileFormValues v, + String preferredLocale, +) { + final bmr = calcBMR( + biologicalSex: v.biologicalSex, + weightKg: v.weightKg!, + heightCm: v.heightCm!, + age: v.age!, + ); + final tdee = calcTDEE(bmr, v.activityLevel); + final targets = calcDailyTargets(tdee, v.goal, v.aggression, v.carbSplit); + final clampedCalories = targets.calories < 500 ? 500.0 : targets.calories; + final macros = calcMacroGrams(clampedCalories, v.carbSplit); + + return ProfileSavePayload( + weightKg: v.weightKg!, + heightCm: v.heightCm!, + age: v.age!, + biologicalSex: v.biologicalSex, + activityLevel: v.activityLevel, + tdeeKcal: tdee, + goal: v.goal, + aggression: v.aggression, + carbSplit: v.carbSplit, + calorieTarget: clampedCalories.round(), + proteinTargetG: macros.proteinG.round(), + carbsTargetG: macros.carbsG.round(), + fatTargetG: macros.fatG.round(), + countryOfOrigin: v.countryOfOrigin, + countryOfResidence: v.countryOfResidence, + preferredLocale: preferredLocale, + oilUsage: v.oilUsage, + defaultRicePortion: v.defaultRicePortion, + sugarBraised: v.sugarBraised, + defaultProteinPortion: v.defaultProteinPortion, + brothConsumption: v.brothConsumption, + ); +} diff --git a/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart b/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart index 6f44a6a6..a9919b64 100644 --- a/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart +++ b/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart @@ -2,6 +2,7 @@ import 'dart:ui'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -12,13 +13,24 @@ import '../../../shell/app_header.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; import '../../../theme/nham_typography.dart'; +import '../data/countries.dart'; import '../data/profile_providers.dart'; +import '../panels/cooking.dart'; +import '../widgets/instant_commit_editor.dart'; import '../widgets/profile_form.dart'; +import '../widgets/region_editor.dart'; import 'account_section.dart'; -/// Settings tab — two-level nav (list → profile drill-in), mirroring the RN -/// expo-router stack inside the settings tab. A nested [Navigator] owns the -/// drill-in so the single contract route `/settings` keeps one screen widget. +/// The marketing version string (no `package_info_plus` dependency in pubspec, +/// so this is rendered statically — keep in sync with `pubspec.yaml`). +const String _appVersion = '1.0.1'; +const String _privacyUrl = 'https://nham.app/privacy'; +const String _termsUrl = 'https://nham.app/terms'; + +/// Settings tab — a single scrollable root of grouped preference rows, each +/// pushing ONE focused editor (Cupertino swipe-back). The numeric goal editor +/// keeps the felt save bar; toggle/select editors instant-commit. A nested +/// [Navigator] owns the drill-in so the `/settings` route stays one widget. class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -34,13 +46,18 @@ class SettingsScreen extends StatelessWidget { } } -/// Settings list view — mirrors the web sidebar's drill-in nav. Tap "Profile" -/// to push the profile editor onto the (nested) stack. -class _SettingsList extends StatelessWidget { +/// Settings root: grouped preference rows with current-value sublines, the +/// account group, and an about/legal group. +class _SettingsList extends ConsumerWidget { const _SettingsList(); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(currentSessionProvider); + final userId = session?.user.id; + final profileAsync = ref.watch(profileProvider(userId != null)); + final profile = profileAsync.valueOrNull; + return Screen( bottom: false, child: Column( @@ -51,57 +68,164 @@ class _SettingsList extends StatelessWidget { child: AppHeader(onBack: () => GoRouter.of(context).pop()), ), Expanded( - child: Padding( + child: ListView( padding: const EdgeInsets.fromLTRB( NhamSpacing.sp4, NhamSpacing.sp2, NhamSpacing.sp4, - 0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - // Web sidebar h2: text-lg (18) font-medium tracking-tight, - // Lora. - tr('settings.title'), - style: NhamTextStyles.serifMedium( - fontSize: NhamFontSize.lg, - ).copyWith( - letterSpacing: NhamTracking.tight, - color: NhamColors.text, - ), - ), - const SizedBox(height: NhamSpacing.sp4), - _ProfileRowTile( - onTap: - () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const _ProfileScreen(), - ), - ), - ), - const SizedBox(height: NhamSpacing.sp5), - const AccountSection(), - ], + NhamSpacing.sp6, ), + children: [ + Text( + tr('settings.title'), + style: NhamTextStyles.serifMedium(fontSize: NhamFontSize.lg) + .copyWith( + letterSpacing: NhamTracking.tight, + color: NhamColors.text), + ), + const SizedBox(height: NhamSpacing.sp4), + + // ── Preferences ───────────────────────────────────────────── + _GroupLabel(tr('settings.preferences')), + _PreferenceRow( + icon: LucideIcons.target, + label: tr('settings.rows.goalPace'), + subline: _goalPaceSubline(context, profile), + onTap: () => _push(context, _EditorKind.goal), + ), + _PreferenceRow( + icon: LucideIcons.utensilsCrossed, + label: tr('settings.rows.cooking'), + subline: tr('settings.profilePanel.cookingSubtitle'), + onTap: () => _push(context, _EditorKind.cooking), + ), + _PreferenceRow( + icon: LucideIcons.globe, + label: tr('settings.rows.region'), + subline: _regionSubline(context, profile), + onTap: () => _push(context, _EditorKind.region), + ), + + const SizedBox(height: NhamSpacing.sp5), + const AccountSection(), + + const SizedBox(height: NhamSpacing.sp5), + // ── About ─────────────────────────────────────────────────── + _GroupLabel(tr('settings.about.title')), + _InfoRow( + icon: LucideIcons.info, + label: tr('settings.about.version'), + value: _appVersion, + ), + _PreferenceRow( + icon: LucideIcons.shieldCheck, + label: tr('settings.about.privacy'), + subline: _privacyUrl, + onTap: () => _copyLink(context, _privacyUrl), + ), + _PreferenceRow( + icon: LucideIcons.fileText, + label: tr('settings.about.terms'), + subline: _termsUrl, + onTap: () => _copyLink(context, _termsUrl), + ), + ], ), ), ], ), ); } + + void _push(BuildContext context, _EditorKind kind) { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => _ProfileScreen(kind: kind)), + ); + } + + void _copyLink(BuildContext context, String url) { + Clipboard.setData(ClipboardData(text: url)); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar(content: Text(tr('common.copied'))), + ); + } + + /// "Cutting · 0.50 kg/wk" — the saved goal + pace, or "Not set" when no + /// profile / no goal is configured. + String _goalPaceSubline(BuildContext context, ProfileRow? profile) { + if (profile == null) return tr('settings.rows.notSet'); + final goal = profile.goal; + if (goal == null) return tr('settings.rows.notSet'); + final goalLabel = switch (goal) { + 'cutting' => tr('onboarding.bodyMetrics.cutting'), + 'bulking' => tr('onboarding.bodyMetrics.bulking'), + _ => tr('onboarding.bodyMetrics.maintaining'), + }; + if (goal == 'maintaining') return goalLabel; + final aggression = double.tryParse(profile.aggression ?? ''); + if (aggression == null) return goalLabel; + final unit = tr('onboarding.bodyMetrics.weightUnit'); + return '$goalLabel · ${aggression.toStringAsFixed(2)} $unit/wk'; + } + + /// "Việt Nam · Tiếng Việt" — residence country + current app language, or + /// just the language when no country is set. + String _regionSubline(BuildContext context, ProfileRow? profile) { + final lang = context.locale.languageCode == 'vi' ? 'Tiếng Việt' : 'English'; + final residence = profile?.countryOfResidence; + if (residence == null || residence.isEmpty) return lang; + final label = _countryLabel(residence, context.locale.languageCode); + return '$label · $lang'; + } + + String _countryLabel(String value, String locale) { + for (final c in kCountries) { + if (c.value == value) return locale == 'vi' ? c.vi : c.value; + } + return value; + } +} + +/// The focused editor a preference row pushes onto the stack. +enum _EditorKind { goal, cooking, region } + +class _GroupLabel extends StatelessWidget { + const _GroupLabel(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: NhamSpacing.sp3, bottom: 4), + child: Text( + text.toUpperCase(), + style: NhamTextStyles.sansBold(fontSize: 10) + .copyWith(letterSpacing: 2.0, color: NhamColors.textMuted), + ), + ); + } } -class _ProfileRowTile extends StatefulWidget { - const _ProfileRowTile({required this.onTap}); +/// A grouped preference row: an icon, a label with a current-value subline, and +/// a trailing chevron. Presses fade the hover fill in and darken the text. +class _PreferenceRow extends StatefulWidget { + const _PreferenceRow({ + required this.icon, + required this.label, + required this.subline, + required this.onTap, + }); + + final IconData icon; + final String label; + final String subline; final VoidCallback onTap; @override - State<_ProfileRowTile> createState() => _ProfileRowTileState(); + State<_PreferenceRow> createState() => _PreferenceRowState(); } -class _ProfileRowTileState extends State<_ProfileRowTile> { +class _PreferenceRowState extends State<_PreferenceRow> { bool _pressed = false; @override @@ -112,35 +236,42 @@ class _ProfileRowTileState extends State<_ProfileRowTile> { onTapUp: (_) => setState(() => _pressed = false), onTapCancel: () => setState(() => _pressed = false), child: Container( - // Web nav item: no border. rounded-xl px-3 py-2.5 gap-2.5, inactive - // text-muted, on press hover/50 fill + text darkens to text. padding: const EdgeInsets.symmetric( horizontal: NhamSpacing.sp3, - vertical: 10, // py-2.5 + vertical: 10, ), decoration: BoxDecoration( color: _pressed ? NhamColors.hover50 : Colors.transparent, - borderRadius: BorderRadius.circular(NhamRadii.buttonXl), // rounded-xl + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), ), child: Row( children: [ Icon( - LucideIcons.user, + widget.icon, size: 16, color: _pressed ? NhamColors.text : NhamColors.textMuted, ), - const SizedBox(width: 10), // gap-2.5 + const SizedBox(width: 10), Expanded( - child: Text( - tr('settings.sidebar.profile'), - style: NhamTextStyles.sansMedium( - fontSize: NhamFontSize.sm, - ).copyWith( - color: _pressed ? NhamColors.text : NhamColors.textMuted, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.label, + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) + .copyWith(color: NhamColors.text), + ), + const SizedBox(height: 2), + Text( + widget.subline, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.xs) + .copyWith(color: NhamColors.textMuted), + ), + ], ), ), - // ChevronRight inactive = text-muted/50. const Icon( LucideIcons.chevronRight, size: 16, @@ -153,10 +284,57 @@ class _ProfileRowTileState extends State<_ProfileRowTile> { } } -/// Profile editor screen — pushed from the settings list. Back header mirrors -/// the web shell's ArrowLeft + "Settings" link. +/// A non-navigating info row (label on the left, a static value on the right) — +/// used for the app version. +class _InfoRow extends StatelessWidget { + const _InfoRow({ + required this.icon, + required this.label, + required this.value, + }); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: 10, + ), + child: Row( + children: [ + Icon(icon, size: 16, color: NhamColors.textMuted), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) + .copyWith(color: NhamColors.text), + ), + ), + Text( + value, + style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm) + .copyWith( + color: NhamColors.textMuted, + fontFeatures: const [FontFeature.tabularFigures()]), + ), + ], + ), + ); + } +} + +/// Focused profile editor screen — pushed from a settings row. Renders ONE of +/// the goal / cooking / region editors. Back header mirrors the web shell's +/// ArrowLeft + "Settings" link. class _ProfileScreen extends ConsumerWidget { - const _ProfileScreen(); + const _ProfileScreen({required this.kind}); + + final _EditorKind kind; @override Widget build(BuildContext context, WidgetRef ref) { @@ -169,7 +347,6 @@ class _ProfileScreen extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Back header — bg-[#FDFCF8]/90 backdrop-blur-sm, border-b. const _BackHeader(), Expanded( child: @@ -196,14 +373,12 @@ class _ProfileScreen extends ConsumerWidget { // re-onboarding empty state. An error offers a retry, not // a misleading "Start setup". error: (_, __) => _ProfileLoadError( - // userId is non-null in this branch (the null case is - // handled above), so the profile query is keyed `true`. onRetry: () => ref.invalidate(profileProvider(true)), ), data: (profile) => profile != null - ? ProfileForm(profile: profile) + ? _editor(profile) : const _ProfileEmpty(), ), ), @@ -211,6 +386,17 @@ class _ProfileScreen extends ConsumerWidget { ), ); } + + Widget _editor(ProfileRow profile) => switch (kind) { + _EditorKind.goal => ProfileForm(profile: profile), + _EditorKind.cooking => InstantCommitEditor( + profile: profile, + title: tr('settings.rows.cooking'), + subtitle: tr('settings.profilePanel.cookingSubtitle'), + child: const Cooking(), + ), + _EditorKind.region => RegionEditor(profile: profile), + }; } class _Centered extends StatelessWidget { diff --git a/apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart b/apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart new file mode 100644 index 00000000..0ea4f7d8 --- /dev/null +++ b/apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart @@ -0,0 +1,148 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/profile_providers.dart'; +import '../logic/profile_payload.dart'; +import 'profile_form_controller.dart'; +import 'profile_form_values.dart'; +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_theme.dart'; +import '../../../theme/nham_typography.dart'; + +/// A focused settings editor for non-numeric preferences (cooking habits, +/// region & language). Toggle/select changes instant-commit — the moment a +/// value diverges, the full profile payload is re-saved (targets recomputed +/// from the unchanged body metrics) and a success haptic fires. No save bar: +/// per the audit, the bar is reserved for the numeric goal editor. +/// +/// Seeds its own [ProfileFormController] from [profile] and exposes it to the +/// [child] panel via a [ProfileFormScope], so the existing panels (which read +/// `ProfileFormController.of(context)`) drop in unchanged. +class InstantCommitEditor extends ConsumerStatefulWidget { + const InstantCommitEditor({ + super.key, + required this.profile, + required this.title, + required this.subtitle, + required this.child, + }); + + final ProfileRow profile; + final String title; + final String subtitle; + final Widget child; + + @override + ConsumerState createState() => + _InstantCommitEditorState(); +} + +class _InstantCommitEditorState extends ConsumerState { + late final ProfileFormController _controller = + ProfileFormController(ProfileFormValues.fromRow(widget.profile)); + String? _errorText; + + /// The app locale at the last successful save. A locale change is committed + /// even when no form field is dirty (preferredLocale lives outside the form). + late String _savedLocale = widget.profile.raw['preferredLocale'] as String? ?? + WidgetsBinding.instance.platformDispatcher.locale.languageCode; + + @override + void initState() { + super.initState(); + _controller.addListener(_onChanged); + } + + @override + void dispose() { + _controller.removeListener(_onChanged); + _controller.dispose(); + super.dispose(); + } + + /// Guards against the markSaved()-notifies-listeners feedback loop: while a + /// commit is settling we ignore the controller notifications it triggers. + bool _committing = false; + + void _onChanged() { + setState(() {}); + // A controller notification only fires on a user edit (the panels and the + // language row both notify on change) — never on mount — so each genuine + // edit instant-commits. markSaved() inside _commit() re-notifies; _committing + // suppresses that so we don't loop. + if (_committing) return; + final localeChanged = context.locale.languageCode != _savedLocale; + if (_controller.isDirty || localeChanged) _commit(); + } + + Future _commit() async { + final v = _controller.values; + // Instant-commit requires complete body metrics (an onboarded profile). The + // editors are only reachable for such a profile, but guard anyway so a + // partial profile never PUTs nulls. + if (validateBodyMetrics(v).isNotEmpty) return; + + _committing = true; + final locale = context.locale.languageCode; + final payload = buildProfilePayload(v, locale); + final ok = await ref.read(saveProfileProvider.notifier).save(payload); + if (!mounted) { + _committing = false; + return; + } + if (ok) { + HapticFeedback.selectionClick(); + _controller.markSaved(); + _savedLocale = locale; + if (_errorText != null) setState(() => _errorText = null); + } else { + setState(() => _errorText = tr('settings.profilePanel.saveError')); + } + _committing = false; + } + + @override + Widget build(BuildContext context) { + return ProfileFormScope( + controller: _controller, + child: ListView( + padding: const EdgeInsets.fromLTRB( + NhamSpacing.sp4, + NhamSpacing.sp2, + NhamSpacing.sp4, + NhamSpacing.sp8, + ), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + children: [ + Text( + widget.title, + style: NhamTextStyles.serifRegular(fontSize: NhamFontSize.h3) + .copyWith( + letterSpacing: NhamTracking.tight, color: NhamColors.text), + ), + const SizedBox(height: NhamSpacing.sp1), + Padding( + padding: const EdgeInsets.only(bottom: NhamSpacing.sp4), + child: Text( + widget.subtitle, + style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.detail) + .copyWith(height: 20 / 13, color: NhamColors.textWarm), + ), + ), + if (_errorText != null) + Padding( + padding: const EdgeInsets.only(bottom: NhamSpacing.sp3), + child: Text( + _errorText!, + style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm) + .copyWith(color: NhamColors.danger), + ), + ), + widget.child, + ], + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/settings/widgets/profile_form.dart b/apps/mobile-flutter/lib/features/settings/widgets/profile_form.dart index 0747a923..4557cf85 100644 --- a/apps/mobile-flutter/lib/features/settings/widgets/profile_form.dart +++ b/apps/mobile-flutter/lib/features/settings/widgets/profile_form.dart @@ -12,26 +12,17 @@ import '../../../features/logging/widgets/count_up.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; import '../../../theme/nham_typography.dart'; -import '../controls/tab_strip.dart'; import '../data/profile_providers.dart'; -import '../logic/tdee.dart'; +import '../logic/profile_payload.dart'; import '../panels/body_metrics.dart'; -import '../panels/cooking.dart'; -import '../panels/regional.dart'; import 'profile_form_controller.dart'; import 'profile_form_values.dart'; -enum _SectionId { bodyMetrics, regional, cooking } - -const _fieldSection = { - ProfileField.weightKg: _SectionId.bodyMetrics, - ProfileField.heightCm: _SectionId.bodyMetrics, - ProfileField.age: _SectionId.bodyMetrics, -}; - -/// RN port of web `components/settings/profile/index.tsx`. The tabbed profile -/// editor: header + subtitle, [TabStrip], the active panel inside a cream card, -/// and a floating Save/Cancel bar that fades in when the form is dirty. +/// The "Goal & pace" focused editor — the one numeric settings surface, so it +/// keeps the felt floating Save/Cancel bar (toggle/select editors instant-commit +/// instead). Renders the body-metrics + goal/pace/split/target panel inside a +/// cream card; the bar fades in when the form is dirty and morphs into a +/// "Changes saved · N kcal/day" confirmation on save. class ProfileForm extends ConsumerStatefulWidget { const ProfileForm({super.key, required this.profile}); @@ -44,7 +35,6 @@ class ProfileForm extends ConsumerStatefulWidget { class _ProfileFormState extends ConsumerState { late final ProfileFormController _controller = ProfileFormController(ProfileFormValues.fromRow(widget.profile)); - _SectionId _activeTab = _SectionId.bodyMetrics; String? _errorText; // Felt-save state: after a successful save the bar morphs into a @@ -86,52 +76,16 @@ class _ProfileFormState extends ConsumerState { final errors = validateBodyMetrics(v); if (errors.isNotEmpty) { _controller.setErrors(errors); - final first = errors.keys.first; - final section = _fieldSection[first]; setState(() { - if (section != null) _activeTab = section; _errorText = tr('settings.profilePanel.invalidError'); }); return; } _controller.setErrors(const {}); - // Compute targets exactly as RN `handleSave`. - final bmr = calcBMR( - biologicalSex: v.biologicalSex, - weightKg: v.weightKg!, - heightCm: v.heightCm!, - age: v.age!, - ); - final tdee = calcTDEE(bmr, v.activityLevel); - final targets = - calcDailyTargets(tdee, v.goal, v.aggression, v.carbSplit); - final clampedCalories = targets.calories < 500 ? 500.0 : targets.calories; - final macros = calcMacroGrams(clampedCalories, v.carbSplit); - - final payload = ProfileSavePayload( - weightKg: v.weightKg!, - heightCm: v.heightCm!, - age: v.age!, - biologicalSex: v.biologicalSex, - activityLevel: v.activityLevel, - tdeeKcal: tdee, - goal: v.goal, - aggression: v.aggression, - carbSplit: v.carbSplit, - calorieTarget: clampedCalories.round(), - proteinTargetG: macros.proteinG.round(), - carbsTargetG: macros.carbsG.round(), - fatTargetG: macros.fatG.round(), - countryOfOrigin: v.countryOfOrigin, - countryOfResidence: v.countryOfResidence, - preferredLocale: context.locale.languageCode, - oilUsage: v.oilUsage, - defaultRicePortion: v.defaultRicePortion, - sugarBraised: v.sugarBraised, - defaultProteinPortion: v.defaultProteinPortion, - brothConsumption: v.brothConsumption, - ); + // Compute targets exactly as RN `handleSave` (shared with the instant-commit + // editors via buildProfilePayload). + final payload = buildProfilePayload(v, context.locale.languageCode); setState(() => _errorText = null); final ok = await ref.read(saveProfileProvider.notifier).save(payload); @@ -141,7 +95,7 @@ class _ProfileFormState extends ConsumerState { _controller.markSaved(); // Felt save: morph the bar into "Changes saved · N kcal/day", count the // target old→new, hold ~1.6s, then dissolve. - final newTarget = clampedCalories.round(); + final newTarget = payload.calorieTarget; _savedTimer?.cancel(); setState(() => _savedCalorieTarget = newTarget); _savedTimer = Timer(const Duration(milliseconds: 1600), () { @@ -159,16 +113,6 @@ class _ProfileFormState extends ConsumerState { @override Widget build(BuildContext context) { final saving = ref.watch(saveProfileProvider).isLoading; - final tabs = [ - (id: _SectionId.bodyMetrics, title: tr('settings.bodyMetrics'), - subtitle: tr('settings.profilePanel.bodyMetricsSubtitle')), - (id: _SectionId.regional, title: tr('settings.regional'), - subtitle: tr('settings.profilePanel.regionalSubtitle')), - (id: _SectionId.cooking, title: tr('settings.cooking'), - subtitle: tr('settings.profilePanel.cookingSubtitle')), - ]; - final activeSubtitle = - tabs.firstWhere((t) => t.id == _activeTab).subtitle; return ProfileFormScope( controller: _controller, @@ -184,7 +128,7 @@ class _ProfileFormState extends ConsumerState { keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, children: [ Text( - tr('settings.profilePage.title'), + tr('settings.rows.goalPace'), style: NhamTextStyles.serifRegular(fontSize: NhamFontSize.h3) .copyWith(letterSpacing: NhamTracking.tight, color: NhamColors.text), ), @@ -192,21 +136,11 @@ class _ProfileFormState extends ConsumerState { Padding( padding: const EdgeInsets.only(bottom: NhamSpacing.sp4), child: Text( - tr('settings.profilePage.description'), + tr('settings.profilePanel.bodyMetricsSubtitle'), style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.detail) .copyWith(height: 20 / 13, color: NhamColors.textWarm), ), ), - TabStrip( - tabs: [ - for (final t in tabs) - TabStripItem(id: t.id.name, label: t.title), - ], - active: _activeTab.name, - onChange: (id) => setState(() => - _activeTab = _SectionId.values.byName(id)), - ), - const SizedBox(height: NhamSpacing.sp2), Container( padding: const EdgeInsets.all(NhamSpacing.sp3), decoration: BoxDecoration( @@ -214,24 +148,7 @@ class _ProfileFormState extends ConsumerState { borderRadius: BorderRadius.circular(NhamRadii.containerLg), border: Border.all(color: NhamColors.inputBorder), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: NhamSpacing.sp5), - child: Text( - activeSubtitle, - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.detail) - .copyWith(color: NhamColors.textWarm), - ), - ), - switch (_activeTab) { - _SectionId.bodyMetrics => const BodyMetrics(), - _SectionId.regional => const Regional(), - _SectionId.cooking => const Cooking(), - }, - ], - ), + child: const BodyMetrics(), ), ], ), diff --git a/apps/mobile-flutter/lib/features/settings/widgets/region_editor.dart b/apps/mobile-flutter/lib/features/settings/widgets/region_editor.dart new file mode 100644 index 00000000..9781e7e4 --- /dev/null +++ b/apps/mobile-flutter/lib/features/settings/widgets/region_editor.dart @@ -0,0 +1,86 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../onboarding/widgets/language_toggle.dart'; +import '../data/profile_providers.dart'; +import '../panels/regional.dart'; +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_theme.dart'; +import '../../../theme/nham_typography.dart'; +import 'instant_commit_editor.dart'; +import 'profile_form_controller.dart'; + +/// The "Region & language" focused editor. Wraps the [Regional] country panel +/// (origin / residence) plus an app-language selector inside an +/// [InstantCommitEditor], so every change persists immediately. Switching the +/// language live-sets the app locale (the new locale is what +/// `buildProfilePayload` writes as `preferredLocale`). +class RegionEditor extends StatelessWidget { + const RegionEditor({super.key, required this.profile}); + + final ProfileRow profile; + + @override + Widget build(BuildContext context) { + return InstantCommitEditor( + profile: profile, + title: tr('settings.rows.region'), + subtitle: tr('settings.profilePanel.regionalSubtitle'), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _LanguageField(), + SizedBox(height: NhamSpacing.sp6), + Regional(), + ], + ), + ); + } +} + +/// Language row: a labelled [LanguageToggle]. Switching the language sets the +/// app locale live and nudges the form controller so the instant-commit save +/// fires (persisting the new `preferredLocale`). +class _LanguageField extends StatefulWidget { + const _LanguageField(); + + @override + State<_LanguageField> createState() => _LanguageFieldState(); +} + +class _LanguageFieldState extends State<_LanguageField> { + @override + Widget build(BuildContext context) { + final form = ProfileFormController.of(context); + final current = context.locale.languageCode; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Icon(LucideIcons.languages, size: 16, color: NhamColors.accent), + const SizedBox(width: NhamSpacing.sp2), + Text( + tr('settings.language'), + style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.detail) + .copyWith(color: NhamColors.text), + ), + ], + ), + const SizedBox(height: NhamSpacing.sp2), + LanguageToggle( + value: current, + onChange: (v) { + if (v == current) return; + context.setLocale(Locale(v)); + // Nudge the controller to fire a notification. preferredLocale lives + // outside the form, so InstantCommitEditor detects the locale change + // and commits even though no field value changed (identity assign). + form.update((f) => f.countryOfResidence = f.countryOfResidence); + }, + ), + ], + ); + } +} From c49d7687c8da478d1ea47b86a3b35c45670e8913 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 22:04:56 +0700 Subject: [PATCH 35/57] fix(mobile): localized neutral auth errors rendered inline Map Supabase AuthException codes to warm, localized copy in both locales instead of surfacing raw English e.message. Wrong-password and no-account share one neutral line (Supabase anti-enumeration makes them indistinguishable). An unconfirmed-email sign-in now lands on the real confirm-email state with its resend affordance instead of a dead-end error. Sign-in/sign-up errors render inline under the form in terracotta (fields keep their values); toasts remain only for out-of-view events such as OAuth failures on the welcome face. Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 9 +++- apps/mobile-flutter/assets/l10n/vi.json | 9 +++- .../auth/providers/auth_form_controller.dart | 43 +++++++++++++++---- .../lib/features/auth/widgets/auth_page.dart | 14 +++++- .../auth/widgets/email_auth_form.dart | 32 ++++++++------ 5 files changed, 80 insertions(+), 27 deletions(-) diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index 23ae3d81..577df7ab 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -660,7 +660,6 @@ "noAccount": "Don't have an account?", "signUpLink": "Sign up", "forgotPassword": "Forgot password?", - "error": "Invalid email or password", "success": "Signed in successfully", "emailPlaceholder": "you@example.com", "passwordPlaceholder": "Enter your password", @@ -676,7 +675,6 @@ "hasAccount": "Already have an account?", "signInLink": "Sign in", "successSignedIn": "Account created. Welcome!", - "error": "Could not create account", "emailPlaceholder": "you@example.com", "passwordPlaceholder": "Create a password", "emailError": "Enter a valid email address", @@ -733,6 +731,13 @@ }, "pending": { "finishing": "Finishing sign-in…" + }, + "errors": { + "invalidCredentials": "That email and password don't match — try again or reset your password.", + "emailNotConfirmed": "That email hasn't been confirmed yet — check your inbox for the link.", + "network": "Couldn't reach the server — check your connection and try again.", + "rateLimited": "Too many attempts — give it a moment, then try again.", + "generic": "Something went wrong — please try again." } }, "landing": { diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index 6cf484b9..53b88f17 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -659,7 +659,6 @@ "noAccount": "Chưa có tài khoản?", "signUpLink": "Đăng ký", "forgotPassword": "Quên mật khẩu?", - "error": "Email hoặc mật khẩu không đúng", "success": "Đăng nhập thành công", "emailPlaceholder": "ban@example.com", "passwordPlaceholder": "Nhập mật khẩu", @@ -675,7 +674,6 @@ "hasAccount": "Đã có tài khoản?", "signInLink": "Đăng nhập", "successSignedIn": "Đã tạo tài khoản. Chào mừng!", - "error": "Không thể tạo tài khoản", "emailPlaceholder": "ban@example.com", "passwordPlaceholder": "Tạo mật khẩu", "emailError": "Nhập địa chỉ email hợp lệ", @@ -732,6 +730,13 @@ }, "pending": { "finishing": "Đang hoàn tất đăng nhập…" + }, + "errors": { + "invalidCredentials": "Email và mật khẩu không khớp — thử lại hoặc đặt lại mật khẩu nhé.", + "emailNotConfirmed": "Email này chưa được xác nhận — kiểm tra hộp thư để tìm liên kết nhé.", + "network": "Không kết nối được máy chủ — kiểm tra mạng rồi thử lại nhé.", + "rateLimited": "Bạn thao tác hơi nhanh — đợi một chút rồi thử lại nhé.", + "generic": "Đã có lỗi xảy ra — bạn thử lại nhé." } }, "landing": { diff --git a/apps/mobile-flutter/lib/features/auth/providers/auth_form_controller.dart b/apps/mobile-flutter/lib/features/auth/providers/auth_form_controller.dart index d4449bce..80cd6a4c 100644 --- a/apps/mobile-flutter/lib/features/auth/providers/auth_form_controller.dart +++ b/apps/mobile-flutter/lib/features/auth/providers/auth_form_controller.dart @@ -20,6 +20,26 @@ const String kAuthRedirect = 'nham://auth-callback'; /// `busy` bool that made one tap spin every control. enum AuthAction { email, google, apple } +/// Maps a Supabase [AuthException] to warm, localized copy — the raw English +/// `e.message` never reaches the UI. Wrong-password and no-account are +/// deliberately indistinguishable (Supabase anti-enumeration returns the same +/// `invalid_credentials` for both), so they share one neutral line. +String authErrorMessage(AuthException e) { + if (e is AuthRetryableFetchException) return tr('auth.errors.network'); + switch (e.code) { + case 'invalid_credentials': + return tr('auth.errors.invalidCredentials'); + case 'email_not_confirmed': + return tr('auth.errors.emailNotConfirmed'); + case 'over_request_rate_limit': + case 'over_email_send_rate_limit': + return tr('auth.errors.rateLimited'); + default: + if (e.statusCode == '429') return tr('auth.errors.rateLimited'); + return tr('auth.errors.generic'); + } +} + /// Immutable view-state for an auth screen, mirroring the RN screens' /// `busy` / `error` / `notice` `useState` triplet — except `busy` is now an /// `AuthAction?` so the email and Google buttons own independent spinners. @@ -100,11 +120,18 @@ class AuthFormController extends StateNotifier { // routes into the app (RN did `router.replace('/logging')`). state = state.copyWith(clearAction: true); } on AuthException catch (e) { - state = state.copyWith(clearAction: true, error: e.message); + if (e.code == 'email_not_confirmed') { + // The account exists but was never confirmed — surface the real + // confirm-email state (with its resend affordance) instead of a + // dead-end error line. + state = state.copyWith(clearAction: true, pendingEmail: email.trim()); + return; + } + state = state.copyWith(clearAction: true, error: authErrorMessage(e)); } catch (_) { state = state.copyWith( clearAction: true, - error: tr('auth.signIn.error'), + error: tr('auth.errors.generic'), ); } } @@ -134,11 +161,11 @@ class AuthFormController extends StateNotifier { pendingEmail: email.trim(), ); } on AuthException catch (e) { - state = state.copyWith(clearAction: true, error: e.message); + state = state.copyWith(clearAction: true, error: authErrorMessage(e)); } catch (_) { state = state.copyWith( clearAction: true, - error: tr('auth.signUp.error'), + error: tr('auth.errors.generic'), ); } } @@ -167,7 +194,7 @@ class AuthFormController extends StateNotifier { // app-switch; the UI's lifecycle listener clears it if the user cancels // and resumes still signed-out. } on AuthException catch (e) { - state = state.copyWith(clearAction: true, error: e.message); + state = state.copyWith(clearAction: true, error: authErrorMessage(e)); } catch (_) { state = state.copyWith( clearAction: true, @@ -222,7 +249,7 @@ class AuthFormController extends StateNotifier { error: tr('auth.dialog.appleError'), ); } on AuthException catch (e) { - state = state.copyWith(clearAction: true, error: e.message); + state = state.copyWith(clearAction: true, error: authErrorMessage(e)); } catch (_) { state = state.copyWith( clearAction: true, @@ -257,11 +284,11 @@ class AuthFormController extends StateNotifier { notice: tr('auth.confirm.resent'), ); } on AuthException catch (e) { - state = state.copyWith(clearAction: true, error: e.message); + state = state.copyWith(clearAction: true, error: authErrorMessage(e)); } catch (e) { state = state.copyWith( clearAction: true, - error: tr('auth.signUp.error'), + error: tr('auth.errors.generic'), ); } } diff --git a/apps/mobile-flutter/lib/features/auth/widgets/auth_page.dart b/apps/mobile-flutter/lib/features/auth/widgets/auth_page.dart index f4498bb4..6e0cacd1 100644 --- a/apps/mobile-flutter/lib/features/auth/widgets/auth_page.dart +++ b/apps/mobile-flutter/lib/features/auth/widgets/auth_page.dart @@ -97,6 +97,19 @@ class _AuthPageState extends ConsumerState { final state = ref.watch(_provider); final showConfirm = state.pendingEmail != null; + // Errors raised while the email form is on screen render inline there. + // Anything else (OAuth on the welcome face, a failed resend on the + // confirm face) would otherwise be invisible — toast those. + ref.listen(_provider, (prev, next) { + final err = next.error; + final emailFormShowing = + _mode == _AuthMode.email && next.pendingEmail == null; + if (err != null && err != prev?.error && !emailFormShowing) { + _toast(err); + _controller.clearMessages(); + } + }); + // Which face: confirm-email > email form > welcome. final Widget face; if (showConfirm) { @@ -109,7 +122,6 @@ class _AuthPageState extends ConsumerState { face = EmailAuthForm( key: const ValueKey('email'), provider: _provider, - onError: _toast, onBack: () => setState(() => _mode = _AuthMode.welcome), ); } else { diff --git a/apps/mobile-flutter/lib/features/auth/widgets/email_auth_form.dart b/apps/mobile-flutter/lib/features/auth/widgets/email_auth_form.dart index 4404ea8d..f1c0114b 100644 --- a/apps/mobile-flutter/lib/features/auth/widgets/email_auth_form.dart +++ b/apps/mobile-flutter/lib/features/auth/widgets/email_auth_form.dart @@ -18,13 +18,11 @@ class EmailAuthForm extends ConsumerStatefulWidget { const EmailAuthForm({ super.key, required this.provider, - required this.onError, required this.onBack, }); final AutoDisposeStateNotifierProvider provider; - final void Function(String message) onError; final VoidCallback onBack; @override @@ -36,7 +34,6 @@ class _EmailAuthFormState extends ConsumerState { final _password = TextEditingController(); String? _emailError; String? _passwordError; - bool _wasBusy = false; bool _createMode = false; @override @@ -73,16 +70,6 @@ class _EmailAuthFormState extends ConsumerState { final state = ref.watch(widget.provider); final busy = state.busy; - // Surface a Supabase error as a toast once the request settles. - if (_wasBusy && !busy && state.error != null) { - final msg = state.error!; - WidgetsBinding.instance.addPostFrameCallback((_) { - widget.onError(msg); - _controller.clearMessages(); - }); - } - _wasBusy = busy; - return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -134,6 +121,7 @@ class _EmailAuthFormState extends ConsumerState { errorText: _emailError, onChanged: (_) { if (_emailError != null) setState(() => _emailError = null); + _controller.clearMessages(); }, ), const SizedBox(height: 16), @@ -153,6 +141,7 @@ class _EmailAuthFormState extends ConsumerState { errorText: _passwordError, onChanged: (_) { if (_passwordError != null) setState(() => _passwordError = null); + _controller.clearMessages(); }, ), if (!_createMode) ...[ @@ -177,6 +166,18 @@ class _EmailAuthFormState extends ConsumerState { ), ), ], + // Inline auth error — stays put (unlike a toast) and the fields keep + // their values so the user can correct and retry in place. + if (state.error != null) ...[ + const SizedBox(height: 12), + Text( + state.error!, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + height: NhamLeading.normal, + ).copyWith(color: NhamColors.danger), + ), + ], const SizedBox(height: 16), AuthSubmitButton( label: _createMode @@ -204,7 +205,10 @@ class _EmailAuthFormState extends ConsumerState { ignoring: busy, child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => setState(() => _createMode = !_createMode), + onTap: () { + _controller.clearMessages(); + setState(() => _createMode = !_createMode); + }, child: Text( _createMode ? tr('auth.signUp.signInLink') From a2deeac4460c4e1f36adba82f6d4d48724a11760 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 22:08:04 +0700 Subject: [PATCH 36/57] fix(mobile): drop the confidence clause from the Patterns trust line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Founder direction: bounded-estimate confidence is never surfaced. The verdict hero now reads only '{days} complete days'; the orphaned uncertainty strings (nutrition.summary.trustLine / averageConfidence, logging.confidence, the 'with confidence labels' phrase in nutrition.subtitle) are removed from both locales. The nutrition.confidence.* block stays — the audit marks it known-live in verdict_logic/status/nutrient_row. Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 8 ++------ apps/mobile-flutter/assets/l10n/vi.json | 8 ++------ .../features/nutrition/logic/verdict_logic.dart | 10 ---------- .../features/nutrition/widgets/verdict_hero.dart | 16 +++++----------- 4 files changed, 9 insertions(+), 33 deletions(-) diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index 577df7ab..facce2c1 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -101,7 +101,7 @@ "nutrition": { "eyebrow": "Pattern analysis", "title": "Nutrition", - "subtitle": "Review long-term macro and micronutrient patterns, with confidence labels when the food data is incomplete.", + "subtitle": "Review long-term macro and micronutrient patterns.", "loading": "Loading nutrition overview…", "range": { "label": "Nutrition range", @@ -117,10 +117,8 @@ "macroConsistency": "Macro consistency", "confidentOnly": "Only confident gaps are flagged", "coverageHint": "Nutrients with thin coverage", - "averageConfidence": "Avg confidence: {value}", "weakest": "Weakest", "none": "None yet", - "trustLine": "{days} logged days · {confidence} avg confidence · {range} view", "verdict": { "label": "Pattern verdict", "attention": "{nutrients} need attention", @@ -475,8 +473,7 @@ "quiet": "A quiet stretch. Keep logging to sharpen the pattern.", "gathering": "Gathering your pattern.", "gatheringSub": "{days} complete days so far — keep going and the picture sharpens.", - "trustLine": "{days} complete days · {confidence}% avg confidence", - "trustLineNoConfidence": "{days} complete days", + "trustLine": "{days} complete days", "partialNote": { "one": "· {} partial day set aside", "other": "· {} partial days set aside" @@ -997,7 +994,6 @@ "nutritionSummary": "Nutrition summary", "ingredients": "Ingredients", "calories": "Calories", - "confidence": "Confidence", "high": "High", "medium": "Medium", "low": "Low", diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index 53b88f17..74052223 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -101,7 +101,7 @@ "nutrition": { "eyebrow": "Phân tích xu hướng", "title": "Dinh dưỡng", - "subtitle": "Nhìn lại xu hướng macro và vi chất dài hạn, kèm nhãn độ tin cậy khi dữ liệu món ăn còn thiếu.", + "subtitle": "Nhìn lại xu hướng macro và vi chất dài hạn.", "loading": "Đang tải tổng quan dinh dưỡng…", "range": { "label": "Khoảng thời gian dinh dưỡng", @@ -117,10 +117,8 @@ "macroConsistency": "Độ ổn định macro", "confidentOnly": "Chỉ đánh dấu khi đủ tin cậy", "coverageHint": "Vi chất có độ phủ mỏng", - "averageConfidence": "Độ tin cậy TB: {value}", "weakest": "Yếu nhất", "none": "Chưa có", - "trustLine": "{days} ngày có bữa ăn · độ tin cậy TB {confidence} · chế độ xem {range}", "verdict": { "label": "Kết luận xu hướng", "attention": "{nutrients} cần chú ý", @@ -475,8 +473,7 @@ "quiet": "Một giai đoạn yên ả. Tiếp tục ghi nhận để hoàn thiện xu hướng.", "gathering": "Vẫn đang gom dần xu hướng của bạn.", "gatheringSub": "Đã có {days} ngày đủ bữa — cứ tiếp tục, bức tranh sẽ rõ dần.", - "trustLine": "{days} ngày đủ bữa · độ tin cậy TB {confidence}%", - "trustLineNoConfidence": "{days} ngày đủ bữa", + "trustLine": "{days} ngày đủ bữa", "partialNote": { "other": "· tạm bỏ qua {} ngày ghi thiếu" }, @@ -996,7 +993,6 @@ "nutritionSummary": "Tóm tắt dinh dưỡng", "ingredients": "Nguyên liệu", "calories": "Calo", - "confidence": "Độ tin cậy", "high": "Cao", "medium": "Trung bình", "low": "Thấp", diff --git a/apps/mobile-flutter/lib/features/nutrition/logic/verdict_logic.dart b/apps/mobile-flutter/lib/features/nutrition/logic/verdict_logic.dart index 00c781f3..058c7311 100644 --- a/apps/mobile-flutter/lib/features/nutrition/logic/verdict_logic.dart +++ b/apps/mobile-flutter/lib/features/nutrition/logic/verdict_logic.dart @@ -39,13 +39,3 @@ String joinNames(List names, String locale) { final head = names.sublist(0, names.length - 1).join(', '); return '$head, $conj ${names.last}'; } - -double? getAverageConfidence(NutritionOverview overview) { - final items = [ - ...overview.summary.mostConsistent, - ...overview.summary.needsAttention, - ]; - if (items.isEmpty) return null; - return items.fold(0, (total, item) => total + item.confidence) / - items.length; -} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/verdict_hero.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/verdict_hero.dart index 24c826ed..c0275ed2 100644 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/verdict_hero.dart +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/verdict_hero.dart @@ -58,17 +58,11 @@ class VerdictHero extends StatelessWidget { headline = tr('nutrition.verdict.quiet'); } - final avgConfidence = getAverageConfidence(overview); - muted = avgConfidence == null - ? tr('nutrition.verdict.trustLineNoConfidence', namedArgs: { - 'days': dayFormatter.format(overview.completeDays), - }) - : tr('nutrition.verdict.trustLine', namedArgs: { - 'days': dayFormatter.format(overview.completeDays), - 'confidence': (NumberFormat.decimalPattern(locale) - ..maximumFractionDigits = 0) - .format(avgConfidence), - }); + // Founder direction: confidence numbers are never surfaced — the trust + // line names only how many complete days back the verdict. + muted = tr('nutrition.verdict.trustLine', namedArgs: { + 'days': dayFormatter.format(overview.completeDays), + }); if (overview.partialDays > 0) { // ICU plural → easy_localization plural object. The leading '· ' and the // count live inside each form; '{}' is the count. From 0e58dcc266f4f16ab8f59dd0fcf317cc439f769c Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 22:11:03 +0700 Subject: [PATCH 37/57] fix(mobile): meal delete heals every cache and undo can't race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swipe-remove now tracks pendingRemovalIds instead of mutating the day cache: swiped meals are filtered out of the rendered feed (a mid-window refetch can't resurrect the card), Undo just releases the id, and a failed DELETE releases it too — eliminating the snapshot-restore races (resurrect-on-refetch, double-swipe stale snapshot) structurally. A successful delete now also invalidates dashboardBundleProvider and the per-date dashboardDayProvider, and meal confirm invalidates dashboardDayProvider as well. dashboardDayProvider stays non-autodispose (the pager caches days while swiping) with explicit invalidation documented as the contract. Co-Authored-By: Claude Opus 4.8 --- .../dashboard/data/dashboard_providers.dart | 6 ++ .../logging/data/logging_providers.dart | 19 ++---- .../features/logging/widgets/feed_area.dart | 64 +++++++++++++++---- 3 files changed, 62 insertions(+), 27 deletions(-) diff --git a/apps/mobile-flutter/lib/features/dashboard/data/dashboard_providers.dart b/apps/mobile-flutter/lib/features/dashboard/data/dashboard_providers.dart index 509ed3d9..bd6bda9a 100644 --- a/apps/mobile-flutter/lib/features/dashboard/data/dashboard_providers.dart +++ b/apps/mobile-flutter/lib/features/dashboard/data/dashboard_providers.dart @@ -96,6 +96,12 @@ final loggingDayProvider = /// the [LoggingDayData] (persistedMeals) slice the Today card renders. The /// *anchor* day (today) still reads off the already-warm bundle so the first /// page costs no extra round-trip; only swiping to another day fetches. +/// +/// Deliberately NOT autoDispose: the pager keeps day slices cached while the +/// user swipes back and forth (autoDispose would refetch on every page turn). +/// The trade-off is that mutations must invalidate the affected date +/// explicitly — meal confirm ([ConfirmMealNotifier]) and meal delete +/// (the feed's swipe-remove) both do. final dashboardDayProvider = FutureProvider.family((ref, args) async { final api = ref.watch(apiClientProvider); diff --git a/apps/mobile-flutter/lib/features/logging/data/logging_providers.dart b/apps/mobile-flutter/lib/features/logging/data/logging_providers.dart index d4cc4a33..f3f23ea0 100644 --- a/apps/mobile-flutter/lib/features/logging/data/logging_providers.dart +++ b/apps/mobile-flutter/lib/features/logging/data/logging_providers.dart @@ -68,18 +68,6 @@ class LoggingDayNotifier ); } - /// Optimistically remove a persisted meal (on delete). - void removeMeal(String mealId) { - final current = state.valueOrNull; - if (current == null) return; - state = AsyncData( - current.copyWith( - persistedMeals: - current.persistedMeals.where((m) => m.id != mealId).toList(), - ), - ); - } - /// Roll back to a snapshot (on mutation error). void restore(LoggingDayData snapshot) { state = AsyncData(snapshot); @@ -169,10 +157,15 @@ class ConfirmMealNotifier extends FamilyNotifier { ref.invalidate(mealDatesProvider(arg)); // The dashboard reads the day/macros/heatmap off its own bundle, keyed by // (userId, date) — invalidate it so the Today card + week-strip ring pick - // up the just-confirmed meal instead of showing the pre-log cache. + // up the just-confirmed meal instead of showing the pre-log cache. The + // pager's per-day slice is a separate non-autodispose family, so it must + // be invalidated explicitly too or a browsed day keeps its stale cache. ref.invalidate( dash.dashboardBundleProvider((userId: arg, date: originDate)), ); + ref.invalidate( + dash.dashboardDayProvider((userId: arg, date: originDate)), + ); } } } diff --git a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart index a6b40133..960982d4 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart @@ -17,6 +17,7 @@ import '../../../theme/nham_typography.dart'; import '../data/logging_keys.dart'; import '../data/logging_models.dart'; import '../data/logging_providers.dart'; +import '../../dashboard/data/dashboard_providers.dart' as dash; import '../../dashboard/logic/dashboard_format.dart' show formatCount; import '../data/stream_analysis_controller.dart'; import '../logic/format.dart'; @@ -69,6 +70,12 @@ class _FeedAreaState extends ConsumerState { /// which surface as the failed-attempt card. String? _errorText; + /// Meals swiped away but still inside the undo window. They are filtered out + /// of the rendered feed (so a mid-window refetch can't resurrect the card) + /// without ever mutating the day cache — Undo just removes the id, and a + /// failed DELETE removes it too (the data was never locally changed). + final Set _pendingRemovalIds = {}; + LoggingDayArgs get _dayArgs => LoggingDayArgs(widget.profile.userId, widget.date); @@ -117,14 +124,13 @@ class _FeedAreaState extends ConsumerState { /// Trailing-swipe removal of a saved meal: the day visibly heals (the meal /// drops out of the totals immediately) with a 5-second undo. The DELETE only - /// fires if the undo window closes — undo just restores the snapshot. No - /// confirm modal; nothing is destroyed within the grace window. + /// fires if the undo window closes. The day cache is never locally mutated — + /// the meal id sits in [_pendingRemovalIds] and is filtered out of the + /// rendered feed, so undo, refetch races, and failed deletes all resolve by + /// just adding/removing the id. No confirm modal; nothing is destroyed + /// within the grace window. void _removeMeal(PersistedMeal meal) { - final dayNotifier = ref.read(loggingDayProvider(_dayArgs).notifier); - final snapshot = ref.read(loggingDayProvider(_dayArgs)).valueOrNull; - if (snapshot == null) return; - - dayNotifier.removeMeal(meal.id); + setState(() => _pendingRemovalIds.add(meal.id)); var undone = false; final messenger = ScaffoldMessenger.of(context); @@ -143,24 +149,49 @@ class _FeedAreaState extends ConsumerState { textColor: NhamColors.accent, onPressed: () { undone = true; - dayNotifier.restore(snapshot); + if (mounted) { + setState(() => _pendingRemovalIds.remove(meal.id)); + } }, ), ), ) .closed .then((_) async { - if (undone) return; + if (undone || !mounted) return; try { await ref.read(apiClientProvider).delete( '/api/v1/meals/${Uri.encodeComponent(meal.id)}', ); - ref.invalidate(mealDatesProvider(widget.profile.userId)); } catch (_) { - // The server rejected the delete — restore so the feed stays truthful. - dayNotifier.restore(snapshot); - if (mounted) setState(() => _errorText = 'errors.internal'.tr()); + // The server rejected the delete — releasing the id makes the card + // reappear (the cache was never mutated), keeping the feed truthful. + if (mounted) { + setState(() { + _pendingRemovalIds.remove(meal.id); + _errorText = 'errors.internal'.tr(); + }); + } + return; + } + if (!mounted) return; + // The delete landed — heal every cache that carries this date before + // releasing the id, so the refetched day (sans meal) is what renders. + ref.invalidate(mealDatesProvider(widget.profile.userId)); + ref.invalidate(dash.dashboardBundleProvider( + (userId: widget.profile.userId, date: widget.date), + )); + ref.invalidate(dash.dashboardDayProvider( + (userId: widget.profile.userId, date: widget.date), + )); + try { + await ref.read(loggingDayProvider(_dayArgs).notifier).refresh(); + } catch (_) { + // The refetch failing doesn't un-delete the meal — keep the id + // filtered (a harmless no-op once a later fetch succeeds). + return; } + if (mounted) setState(() => _pendingRemovalIds.remove(meal.id)); }); } @@ -217,7 +248,12 @@ class _FeedAreaState extends ConsumerState { final day = dayAsync.valueOrNull; final isLoading = dayAsync.isLoading; - final persistedMeals = [...(day?.persistedMeals ?? const [])] + // Swiped-away meals inside the undo window are filtered out here (not + // removed from the cache), so totals heal immediately and a mid-window + // refetch cannot resurrect the card. + final persistedMeals = (day?.persistedMeals ?? const []) + .where((m) => !_pendingRemovalIds.contains(m.id)) + .toList() ..sort((a, b) => a.loggedAt.compareTo(b.loggedAt)); final pendingConfirmations = day?.pendingConfirmations ?? const []; From 20ce94e329175b77d08eb1c34fdd61a5177a534f Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 22:13:42 +0700 Subject: [PATCH 38/57] =?UTF-8?q?fix(mobile):=20reveal=20lifecycle=20?= =?UTF-8?q?=E2=80=94=20no=20vanished=20answers,=20softer=20seam,=20date-pi?= =?UTF-8?q?nned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second submit while an unconfirmed reveal is showing now refreshes the origin day first, so the server-side pending analysis resurfaces as a pending-confirmation card instead of visibly vanishing. The stream state carries its loggedDate: the streaming/reveal cards only render on the day they were submitted on, and _confirmReveal updates the origin date's caches. The streaming→reveal swap comment no longer claims there's no remount, and the two loudest discontinuities are gone — the revealing card matches the streaming card's surface background, and item rows crossfade in place instead of replaying FadeInLeft. Co-Authored-By: Claude Opus 4.8 --- .../data/stream_analysis_controller.dart | 11 ++++- .../features/logging/widgets/feed_area.dart | 40 +++++++++++++++--- .../features/logging/widgets/meal_entry.dart | 42 +++++++++++++------ 3 files changed, 75 insertions(+), 18 deletions(-) diff --git a/apps/mobile-flutter/lib/features/logging/data/stream_analysis_controller.dart b/apps/mobile-flutter/lib/features/logging/data/stream_analysis_controller.dart index 1889875a..c51e38a3 100644 --- a/apps/mobile-flutter/lib/features/logging/data/stream_analysis_controller.dart +++ b/apps/mobile-flutter/lib/features/logging/data/stream_analysis_controller.dart @@ -31,6 +31,11 @@ class StreamAnalysisState { final String? error; final bool isAnalyzing; + /// The day this run logs into (`StreamAnalyzeInput.loggedDate`). Lets the + /// feed pin the streaming/reveal cards to their origin date, so switching + /// the selected day doesn't render them on the wrong day's feed. + final String? loggedDate; + const StreamAnalysisState({ this.status = StreamStatus.idle, this.items = const [], @@ -39,6 +44,7 @@ class StreamAnalysisState { this.analysisId, this.error, this.isAnalyzing = false, + this.loggedDate, }); StreamAnalysisState copyWith({ @@ -49,6 +55,7 @@ class StreamAnalysisState { String? analysisId, String? error, bool? isAnalyzing, + String? loggedDate, }) => StreamAnalysisState( status: status ?? this.status, items: items ?? this.items, @@ -57,6 +64,7 @@ class StreamAnalysisState { analysisId: analysisId ?? this.analysisId, error: error ?? this.error, isAnalyzing: isAnalyzing ?? this.isAnalyzing, + loggedDate: loggedDate ?? this.loggedDate, ); static const StreamAnalysisState initial = StreamAnalysisState(); @@ -149,9 +157,10 @@ class StreamAnalysisController extends Notifier { Future analyze(StreamAnalyzeInput input) async { _closeStream(); final reqId = ++_requestId; - state = const StreamAnalysisState( + state = StreamAnalysisState( status: StreamStatus.connecting, isAnalyzing: true, + loggedDate: input.loggedDate, ); final api = ref.read(apiClientProvider); diff --git a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart index 960982d4..92b5e486 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart @@ -1,3 +1,4 @@ +import 'dart:async' show unawaited; import 'dart:math' as math; import 'package:easy_localization/easy_localization.dart'; @@ -98,6 +99,23 @@ class _FeedAreaState extends ConsumerState { } void _submit(String text) { + // A second submit while an unconfirmed reveal is showing must not + // vaporize the first answer: that analysis is already stored server-side + // as pending, so refresh its origin day — it resurfaces as a + // pending-confirmation card. + final prior = ref.read(streamAnalysisProvider); + if (prior.status == StreamStatus.done && prior.analysisId != null) { + final originDate = prior.loggedDate ?? widget.date; + unawaited( + ref + .read( + loggingDayProvider( + LoggingDayArgs(widget.profile.userId, originDate), + ).notifier, + ) + .refresh(), + ); + } setState(() { _failedText = null; _revealRawInput = null; @@ -266,7 +284,12 @@ class _FeedAreaState extends ConsumerState { m.nutrition.carbohydrateG == null || m.nutrition.fatG == null); + // Pin the live stream/reveal cards to the day they were submitted on, so + // switching the selected date doesn't render them on the wrong day's feed. + final streamIsForThisDay = stream.loggedDate == widget.date; + final isStreaming = + streamIsForThisDay && stream.status != StreamStatus.idle && stream.status != StreamStatus.done && stream.status != StreamStatus.error; @@ -274,6 +297,7 @@ class _FeedAreaState extends ConsumerState { // The completed-but-not-yet-confirmed answer, held in place as a morph of // the streaming card (built from the locally-held stream.result). final isRevealing = + streamIsForThisDay && stream.status == StreamStatus.done && stream.result != null && stream.analysisId != null; @@ -646,13 +670,17 @@ class _FeedAreaState extends ConsumerState { /// meal. On failure the stream stays so the user can retry the confirm. Future _confirmReveal( String analysisId, List edits) async { + // Confirm against the day the analysis was submitted on (the reveal is + // date-guarded to that day), so the ORIGIN date's caches are updated. + final originDate = + ref.read(streamAnalysisProvider).loggedDate ?? widget.date; try { await ref .read(confirmMealProvider(widget.profile.userId).notifier) .confirm( analysisId: analysisId, mealId: _uuid.v4(), - originDate: widget.date, + originDate: originDate, edits: edits.isEmpty ? null : [ @@ -726,10 +754,12 @@ class _Footer extends StatelessWidget { completedItems: stream.completedItems, isLast: !hasFailed, ), - // The completed answer, morphed in place from the streaming card: the - // per-row macros are already real, the totals count up, and the spinner - // row has swapped for Edit/Confirm. Keyed by analysisId so it's the same - // element across the streaming→done transition (no remount). + // The completed answer in the streaming card's slot: per-row macros + // already real, totals count up, the spinner row swapped for + // Edit/Confirm. This IS a remount (StreamingEntry and MealEntry are + // different widgets) — the `revealing` flag softens the seam: the card + // background matches the streaming card's and the item rows crossfade + // in place instead of re-entering. if (isRevealing) MealEntry( key: ValueKey('reveal-${stream.analysisId}'), diff --git a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart index c6d5f4aa..de1d1c11 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart @@ -98,6 +98,9 @@ class _MealEntryState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _Card( + // The reveal replaces the streaming card in place — matching its + // surface background removes the background flip at the swap. + color: widget.revealing ? NhamColors.surface : NhamColors.elev, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -133,17 +136,30 @@ class _MealEntryState extends State { children: [ for (final (index, item) in _items.indexed) // Web: each item enters opacity 0→1, x:-8→0, staggered - // delay index*0.05s (meal-entry-item.tsx:32-35). - FadeInLeft( - key: ValueKey(item.id), - offset: 8, - delay: Duration(milliseconds: index * 50), - child: _ItemRow( - item: item, - editing: _editing, - onChange: _change, + // delay index*0.05s (meal-entry-item.tsx:32-35). On + // the reveal the rows were already on screen in the + // streaming card — crossfade in place, don't re-enter. + if (widget.revealing) + FadeIn( + key: ValueKey(item.id), + duration: const Duration(milliseconds: 150), + child: _ItemRow( + item: item, + editing: _editing, + onChange: _change, + ), + ) + else + FadeInLeft( + key: ValueKey(item.id), + offset: 8, + delay: Duration(milliseconds: index * 50), + child: _ItemRow( + item: item, + editing: _editing, + onChange: _change, + ), ), - ), ], ), ), @@ -507,16 +523,18 @@ class _ConfirmButtonState extends State<_ConfirmButton> { } /// Card: rounded-2xl (16px), border/60 hairline, shadow.sm, padding 16. +/// [color] lets the reveal match the streaming card's surface background. class _Card extends StatelessWidget { - const _Card({required this.child}); + const _Card({required this.child, this.color = NhamColors.elev}); final Widget child; + final Color color; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(NhamSpacing.sp4), decoration: BoxDecoration( - color: NhamColors.elev, + color: color, borderRadius: BorderRadius.circular(NhamRadii.containerLg), border: Border.all(color: NhamColors.borderSoft), boxShadow: const [NhamShadows.sm], From a4064a26a2c65cacc83ee51d26d6582a25be4e64 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 22:17:13 +0700 Subject: [PATCH 39/57] fix(mobile): instant-commit never drops in-flight edits or desyncs locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A resolved save now baselines exactly the payload it sent (markSavedAs); if the values are dirty again — edited during the in-flight PUT — another commit is queued immediately instead of silently baselining the edit away. On failure the controls roll back to their last-saved values, a device-side locale change is reverted (device and server never diverge), and the error line gains a Try again affordance that re-applies the attempted values. The silently-skipped commit path (incomplete body metrics) also reverts the locale and surfaces a quiet localized hint. Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 4 +- apps/mobile-flutter/assets/l10n/vi.json | 4 +- .../widgets/instant_commit_editor.dart | 97 ++++++++++++++++--- .../widgets/profile_form_controller.dart | 15 +++ 4 files changed, 107 insertions(+), 13 deletions(-) diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index facce2c1..0e87b4ce 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -1196,7 +1196,9 @@ "regionalSubtitle": "Country of origin and current residence", "cookingSubtitle": "Oil usage, rice, sugar, protein, and broth defaults", "saveError": "Failed to save settings. Please try again.", - "invalidError": "Some entries are invalid — please review your body metrics." + "invalidError": "Some entries are invalid — please review your body metrics.", + "retry": "Try again", + "incompleteHint": "Nothing saved yet — fill in weight, height, and age first." }, "rows": { "goalPace": "Goal & pace", diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index 74052223..ff123667 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -1195,7 +1195,9 @@ "regionalSubtitle": "Quê quán và nơi ở hiện tại", "cookingSubtitle": "Thiết lập dầu mỡ, cơm, đường, đạm và nước dùng mặc định", "saveError": "Không thể lưu cài đặt. Vui lòng thử lại.", - "invalidError": "Một số giá trị không hợp lệ — vui lòng kiểm tra lại chỉ số cơ thể." + "invalidError": "Một số giá trị không hợp lệ — vui lòng kiểm tra lại chỉ số cơ thể.", + "retry": "Thử lại", + "incompleteHint": "Chưa lưu được gì — hãy điền cân nặng, chiều cao và tuổi trước nhé." }, "rows": { "goalPace": "Mục tiêu & tốc độ", diff --git a/apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart b/apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart index 0ea4f7d8..09216ba6 100644 --- a/apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart +++ b/apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart @@ -77,16 +77,33 @@ class _InstantCommitEditorState extends ConsumerState { if (_controller.isDirty || localeChanged) _commit(); } + /// The values/locale of a failed commit, held for the retry affordance + /// (the visible controls roll back to the last-saved state on failure). + ProfileFormValues? _failedValues; + String? _failedLocale; + Future _commit() async { - final v = _controller.values; + final locale = context.locale.languageCode; // Instant-commit requires complete body metrics (an onboarded profile). The // editors are only reachable for such a profile, but guard anyway so a // partial profile never PUTs nulls. - if (validateBodyMetrics(v).isNotEmpty) return; + if (validateBodyMetrics(_controller.values).isNotEmpty) { + // Nothing was sent — don't let a device-side locale change diverge from + // the server; revert it and say quietly why nothing saved. + if (locale != _savedLocale) { + await context.setLocale(Locale(_savedLocale)); + } + if (mounted) { + setState( + () => _errorText = tr('settings.profilePanel.incompleteHint'), + ); + } + return; + } _committing = true; - final locale = context.locale.languageCode; - final payload = buildProfilePayload(v, locale); + final committed = _controller.values.clone(); + final payload = buildProfilePayload(committed, locale); final ok = await ref.read(saveProfileProvider.notifier).save(payload); if (!mounted) { _committing = false; @@ -94,13 +111,50 @@ class _InstantCommitEditorState extends ConsumerState { } if (ok) { HapticFeedback.selectionClick(); - _controller.markSaved(); + // Baseline exactly what was sent — edits made while the save was in + // flight stay dirty and re-commit below, instead of being silently + // baselined away. + _controller.markSavedAs(committed); _savedLocale = locale; + _failedValues = null; + _failedLocale = null; if (_errorText != null) setState(() => _errorText = null); + _committing = false; + if (_controller.isDirty || + context.locale.languageCode != _savedLocale) { + await _commit(); + } } else { - setState(() => _errorText = tr('settings.profilePanel.saveError')); + // Visual rollback: the controls return to their last-saved values, and a + // device-side locale change is reverted so device and server never + // diverge. The attempted values are held for "Try again". + _failedValues = _controller.values.clone(); + _failedLocale = locale; + _controller.reset(); + if (context.locale.languageCode != _savedLocale) { + await context.setLocale(Locale(_savedLocale)); + } + if (mounted) { + setState(() => _errorText = tr('settings.profilePanel.saveError')); + } + _committing = false; + } + } + + /// Re-applies the failed commit's values (and locale) and commits again. + Future _retry() async { + final values = _failedValues; + if (values == null) return; + final locale = _failedLocale; + _failedValues = null; + _failedLocale = null; + setState(() => _errorText = null); + if (locale != null && locale != context.locale.languageCode) { + await context.setLocale(Locale(locale)); + if (!mounted) return; } - _committing = false; + // setValues notifies; _onChanged sees the dirty/locale state and commits. + _controller.setValues(values); } @override @@ -134,10 +188,31 @@ class _InstantCommitEditorState extends ConsumerState { if (_errorText != null) Padding( padding: const EdgeInsets.only(bottom: NhamSpacing.sp3), - child: Text( - _errorText!, - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm) - .copyWith(color: NhamColors.danger), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + _errorText!, + style: + NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm) + .copyWith(color: NhamColors.danger), + ), + ), + if (_failedValues != null) ...[ + const SizedBox(width: NhamSpacing.sp3), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _retry, + child: Text( + tr('settings.profilePanel.retry'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), + ), + ), + ], + ], ), ), widget.child, diff --git a/apps/mobile-flutter/lib/features/settings/widgets/profile_form_controller.dart b/apps/mobile-flutter/lib/features/settings/widgets/profile_form_controller.dart index dfc4ad3b..6d666386 100644 --- a/apps/mobile-flutter/lib/features/settings/widgets/profile_form_controller.dart +++ b/apps/mobile-flutter/lib/features/settings/widgets/profile_form_controller.dart @@ -65,6 +65,21 @@ class ProfileFormController extends ChangeNotifier { notifyListeners(); } + /// Marks [saved] (the values actually sent) as the clean baseline — used by + /// instant-commit so edits made while a save was in flight stay dirty and + /// re-commit, instead of being silently baselined away. + void markSavedAs(ProfileFormValues saved) { + _initial = saved.clone(); + notifyListeners(); + } + + /// Replaces the live values wholesale (the retry affordance re-applying a + /// failed commit's values) and notifies. + void setValues(ProfileFormValues v) { + _values = v.clone(); + notifyListeners(); + } + static ProfileFormController of(BuildContext context) { final scope = context .dependOnInheritedWidgetOfExactType(); From bf107b1083c304a37833aea529d02acdc975586e Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 22:21:02 +0700 Subject: [PATCH 40/57] fix(mobile): honest nutrient detail band + reduced-motion gates The nutrient detail's sparkline band can only ever hold one point (a single resolved average), so the fabricated 0.5 mid-rail dot for targetless nutrients is gone, the band is omitted entirely when there is nothing meaningful to plot, and its eyebrow now reads 'Average vs target' instead of the untrue 'Target progress' (orphaned key removed from both locales). The figure caption drops to meta type, bringing the screen to max two eyebrows. Remaining new animations now honor MediaQuery.disableAnimations: the reveal and pager count-ups land in place, both macro-bar sweeps render at rest, and the looping pulse skeletons and StreamingEntry spin/pulse freeze into calm static states. Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 4 +- apps/mobile-flutter/assets/l10n/vi.json | 4 +- .../dashboard/widgets/today_section.dart | 7 ++- .../features/logging/widgets/feed_area.dart | 19 +++++- .../features/logging/widgets/meal_entry.dart | 4 +- .../logging/widgets/streaming_entry.dart | 18 ++++++ .../screens/nutrient_detail_screen.dart | 59 +++++++++++-------- 7 files changed, 84 insertions(+), 31 deletions(-) diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index 0e87b4ce..1d240a76 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -152,7 +152,6 @@ "card": { "average": "Average", "target": "Target", - "targetProgress": "Target progress", "context": "Context", "noData": "No data", "noTargetClaim": "No target claim", @@ -160,6 +159,9 @@ "expand": "View trend", "collapse": "Hide trend" }, + "detail": { + "averageVsTarget": "Average vs target" + }, "confidence": { "normal": "Good confidence", "limitedData": "Limited data", diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index ff123667..8a08a6c3 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -152,7 +152,6 @@ "card": { "average": "Trung bình", "target": "Mục tiêu", - "targetProgress": "Tiến độ mục tiêu", "context": "Ngữ cảnh", "noData": "Chưa có dữ liệu", "noTargetClaim": "Chưa kết luận theo mục tiêu", @@ -160,6 +159,9 @@ "expand": "Xem xu hướng", "collapse": "Ẩn xu hướng" }, + "detail": { + "averageVsTarget": "Trung bình so với mục tiêu" + }, "confidence": { "normal": "Độ tin cậy tốt", "limitedData": "Dữ liệu hạn chế", diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart index 6205e309..d86efd24 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart @@ -213,6 +213,8 @@ class _Dock extends StatelessWidget { // so paging settles in place instead of popping. child: CountUpText( value: remaining.abs().toDouble(), + enabled: + !MediaQuery.disableAnimationsOf(context), duration: const Duration(milliseconds: 300), style: dashHero(), format: (v) => _fmt(v.round(), locale), @@ -376,6 +378,8 @@ class _MacroBarState extends State<_MacroBar> @override Widget build(BuildContext context) { + // Reduced motion: render the fill at its resting width, no sweep. + final reduceMotion = MediaQuery.disableAnimationsOf(context); return ClipRRect( borderRadius: BorderRadius.circular(NhamRadii.pill), child: Container( @@ -387,7 +391,8 @@ class _MacroBarState extends State<_MacroBar> builder: (context, _) => Align( alignment: Alignment.centerLeft, child: Container( - width: constraints.maxWidth * (_fill.value / 100), + width: constraints.maxWidth * + ((reduceMotion ? widget.pct : _fill.value) / 100), height: 8, decoration: BoxDecoration( color: widget.color, diff --git a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart index 92b5e486..b8fbd0ab 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart @@ -1081,6 +1081,8 @@ class _MacroBarState extends State<_MacroBar> @override Widget build(BuildContext context) { + // Reduced motion: render the fill at its resting width, no sweep. + final reduceMotion = MediaQuery.disableAnimationsOf(context); return ClipRRect( borderRadius: BorderRadius.circular(NhamRadii.pill), child: Container( @@ -1091,7 +1093,9 @@ class _MacroBarState extends State<_MacroBar> builder: (context, _) => FractionallySizedBox( alignment: Alignment.centerLeft, - widthFactor: (_anim.value / 100).clamp(0, 1), + widthFactor: + ((reduceMotion ? widget.pct : _anim.value) / 100) + .clamp(0, 1), child: Container( decoration: BoxDecoration( color: widget.color, @@ -1127,6 +1131,19 @@ class _PulseState extends State<_Pulse> with SingleTickerProviderStateMixin { end: 1, ).animate(CurvedAnimation(parent: _c, curve: const Cubic(0.4, 0, 0.6, 1))); + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Reduced motion: hold the skeleton fully visible instead of looping. + if (MediaQuery.disableAnimationsOf(context)) { + _c + ..stop() + ..value = 1; + } else if (!_c.isAnimating) { + _c.repeat(reverse: true); + } + } + @override void dispose() { _c.dispose(); diff --git a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart index de1d1c11..13897fbb 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart @@ -185,7 +185,9 @@ class _MealEntryState extends State { const SizedBox(width: NhamSpacing.sp4), // gap-4 CountUpText( value: totals.calories, - enabled: _countUp, + // Reduced motion: the reveal total lands in place. + enabled: _countUp && + !MediaQuery.disableAnimationsOf(context), format: (v) => fmtKcal(v), variant: NhamTextVariant.numStrong, ), diff --git a/apps/mobile-flutter/lib/features/logging/widgets/streaming_entry.dart b/apps/mobile-flutter/lib/features/logging/widgets/streaming_entry.dart index 687548e8..5ddb039c 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/streaming_entry.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/streaming_entry.dart @@ -90,6 +90,24 @@ class _StreamingEntryState extends State _reassuranceTimer; // start the timer } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Reduced motion: freeze the spin/pulse loops into calm static states — + // the arc rests, the skeleton bars and the timeline dot hold full opacity. + if (MediaQuery.disableAnimationsOf(context)) { + _spin + ..stop() + ..value = 0; + _pulse + ..stop() + ..value = 1; + } else { + if (!_spin.isAnimating) _spin.repeat(); + if (!_pulse.isAnimating) _pulse.repeat(reverse: true); + } + } + @override void dispose() { _reassuranceTimer.cancel(); diff --git a/apps/mobile-flutter/lib/features/nutrition/screens/nutrient_detail_screen.dart b/apps/mobile-flutter/lib/features/nutrition/screens/nutrient_detail_screen.dart index 65cee028..d45e6923 100644 --- a/apps/mobile-flutter/lib/features/nutrition/screens/nutrient_detail_screen.dart +++ b/apps/mobile-flutter/lib/features/nutrition/screens/nutrient_detail_screen.dart @@ -81,9 +81,11 @@ class NutrientDetailScreen extends ConsumerWidget { ], ), const SizedBox(height: 4), + // Meta, not an eyebrow — the screen keeps max two eyebrows + // (the sparkline + candidates section labels). Text( - tr('nutrition.rhythm.avgPerLoggedDay').toUpperCase(), - style: dashEyebrow(), + tr('nutrition.rhythm.avgPerLoggedDay'), + style: dashMeta(color: kInkSecondary), ), if (hasTarget) ...[ const SizedBox(height: 20), @@ -117,23 +119,29 @@ class NutrientDetailScreen extends ConsumerWidget { ), ), - // ── Band 2: coverage sparkline ──────────────────────────── - const SizedBox(height: 24), - Text(tr('nutrition.card.targetProgress').toUpperCase(), - style: dashEyebrow()), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: kCardSurface, - borderRadius: BorderRadius.circular(kCardRadius), - boxShadow: const [kCardShadow], - ), - child: NutrientSparkline( - points: _coveragePoints(card, limited), - semanticLabel: tr('nutrition.trend.chartLabel'), + // ── Band 2: the average against target ─────────────────── + // We hold one resolved average, not a per-day series — when + // there's nothing meaningful to plot (no target, or limited + // data) the band is omitted entirely; the candidate rows carry + // the screen. + if (_coveragePoints(card, limited).isNotEmpty) ...[ + const SizedBox(height: 24), + Text(tr('nutrition.detail.averageVsTarget').toUpperCase(), + style: dashEyebrow()), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: kCardSurface, + borderRadius: BorderRadius.circular(kCardRadius), + boxShadow: const [kCardShadow], + ), + child: NutrientSparkline( + points: _coveragePoints(card, limited), + semanticLabel: tr('nutrition.trend.chartLabel'), + ), ), - ), + ], // ── Band 3: food candidates as full rows ────────────────── if (card.supportsCandidates) ...[ @@ -155,17 +163,16 @@ class NutrientDetailScreen extends ConsumerWidget { ); } - /// Coverage points for the sparkline. We hold a single resolved average, not - /// a per-day series, so we surface that one point (normalized against target - /// when there is one). Limited/insufficient data → no point (the rail). + /// Points for the sparkline. We hold a single resolved average, not a + /// per-day series, so at most one point exists — the average normalized + /// against the target. No target (nothing to normalize against) or + /// limited/insufficient data → empty, and the band isn't rendered. Never a + /// fabricated midpoint. List _coveragePoints(NutrientCardData card, bool limited) { if (limited || card.averagePerDay == null) return const []; final target = card.target; - if (target != null && target > 0) { - return [(card.averagePerDay! / target).clamp(0.0, 1.0)]; - } - // No target → a mid-rail marker so the point still reads as "logged". - return const [0.5]; + if (target == null || target <= 0) return const []; + return [(card.averagePerDay! / target).clamp(0.0, 1.0)]; } } From ed9028e16ab0329664cd32aedcced0242b6a0242 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 22:32:27 +0700 Subject: [PATCH 41/57] fix(mobile): design-system + craft sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Day-error card re-clothed in terracotta nham-danger on cream (no literal Tailwind reds) - New-code Lora w500 (serifMedium) call sites in settings_screen drop to serifRegular — Lora never above 400 (pre-existing sites in nutrition/onboarding left as-is) - Macro shorthand unified to 'P: 38g' (with space) on meal-entry item rows and persisted-card group rows, matching their totals rows - Today-card meal rows name the unit: '412 kcal', not a bare number - '% on track' suppressed until 3 scored days (spec), not 1 - /welcome count-up uses locale-aware formatCount (vi '2.000') instead of hand-rolled comma grouping - Goal-pace subline localizes the per-week suffix (vi '/tuần') and the decimal separator - biologicalSex is a real required selection: null until chosen, placeholder in the select, localized validation message — no silent male default baking a wrong BMR (consistent with the 65/165/25 fix) - PlaceholderScreen header gains a back affordance (pop, else home) so a groups/admin deep link can't strand the user - Instant-commit save-success haptic unified to mediumImpact - _isFirstRun comment now admits the >90d-stale-user limit, and the first-run card gains three localized time-of-day suggestion chips that open the composer prefilled Co-Authored-By: Claude Opus 4.8 --- apps/mobile-flutter/assets/l10n/en.json | 16 ++++- apps/mobile-flutter/assets/l10n/vi.json | 16 ++++- .../dashboard/screens/dashboard_screen.dart | 11 +-- .../dashboard/widgets/adherence_heatmap.dart | 9 +-- .../dashboard/widgets/today_section.dart | 71 ++++++++++++++++++- .../features/logging/widgets/feed_area.dart | 28 ++++---- .../features/logging/widgets/meal_entry.dart | 6 +- .../logging/widgets/persisted_meal_card.dart | 7 +- .../screens/welcome_setup_screen.dart | 18 ++--- .../settings/controls/custom_select.dart | 13 +++- .../settings/logic/profile_payload.dart | 4 +- .../settings/panels/body_metrics.dart | 38 +++++++--- .../settings/screens/settings_screen.dart | 16 +++-- .../widgets/instant_commit_editor.dart | 4 +- .../settings/widgets/profile_form_values.dart | 16 ++++- .../lib/shell/placeholder_screen.dart | 17 ++++- 16 files changed, 219 insertions(+), 71 deletions(-) diff --git a/apps/mobile-flutter/assets/l10n/en.json b/apps/mobile-flutter/assets/l10n/en.json index 1d240a76..14f2d09d 100644 --- a/apps/mobile-flutter/assets/l10n/en.json +++ b/apps/mobile-flutter/assets/l10n/en.json @@ -562,6 +562,7 @@ "title": "Your body metrics", "subtitle": "We use these to calculate your daily calorie and macro targets.", "biologicalSex": "Biological sex", + "sexPlaceholder": "Select…", "male": "Male", "female": "Female", "weight": "Weight", @@ -973,6 +974,17 @@ "mealReceiptsHint": "Your meal receipts will show up here", "firstRunQuestion": "What did you eat today?", "firstRunHint": "Describe your first meal — Nhẩm estimates the rest.", + "firstRunChips": { + "morning1": "1 tô phở bò + quẩy", + "morning2": "2 trứng ốp la + bánh mì", + "morning3": "1 ly cà phê sữa đá", + "midday1": "1 dĩa cơm tấm sườn", + "midday2": "1 tô bún chả", + "midday3": "1 chén cơm + thịt kho", + "evening1": "2 mực kho + 1 chén cơm", + "evening2": "1 tô canh chua cá lóc", + "evening3": "1 tô bún bò Huế" + }, "yesterday": "Yesterday" }, "logging": { @@ -1206,7 +1218,8 @@ "goalPace": "Goal & pace", "cooking": "Cooking habits", "region": "Region & language", - "notSet": "Not set" + "notSet": "Not set", + "pacePerWeek": "{pace} {unit}/wk" }, "about": { "title": "About", @@ -1224,6 +1237,7 @@ }, "validation": { "bodyMetrics": { + "sexRequired": "Please select your biological sex.", "weightRequired": "Please enter your weight.", "weightMin": "Weight must be at least 30 kg.", "weightMax": "Weight must be at most 300 kg.", diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index 8a08a6c3..1fb29b79 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -561,6 +561,7 @@ "title": "Chỉ số cơ thể", "subtitle": "Chúng tôi dùng thông tin này để tính mục tiêu calo và dinh dưỡng hàng ngày.", "biologicalSex": "Giới tính sinh học", + "sexPlaceholder": "Chọn…", "male": "Nam", "female": "Nữ", "weight": "Cân nặng", @@ -972,6 +973,17 @@ "mealReceiptsHint": "Những bữa ăn sẽ hiển thị ở đây", "firstRunQuestion": "Hôm nay bạn đã ăn gì?", "firstRunHint": "Mô tả bữa đầu tiên — Nhẩm lo phần còn lại.", + "firstRunChips": { + "morning1": "1 tô phở bò + quẩy", + "morning2": "2 trứng ốp la + bánh mì", + "morning3": "1 ly cà phê sữa đá", + "midday1": "1 dĩa cơm tấm sườn", + "midday2": "1 tô bún chả", + "midday3": "1 chén cơm + thịt kho", + "evening1": "2 mực kho + 1 chén cơm", + "evening2": "1 tô canh chua cá lóc", + "evening3": "1 tô bún bò Huế" + }, "yesterday": "Hôm qua" }, "logging": { @@ -1205,7 +1217,8 @@ "goalPace": "Mục tiêu & tốc độ", "cooking": "Thói quen nấu nướng", "region": "Khu vực & ngôn ngữ", - "notSet": "Chưa thiết lập" + "notSet": "Chưa thiết lập", + "pacePerWeek": "{pace} {unit}/tuần" }, "about": { "title": "Giới thiệu", @@ -1223,6 +1236,7 @@ }, "validation": { "bodyMetrics": { + "sexRequired": "Vui lòng chọn giới tính sinh học.", "weightRequired": "Vui lòng nhập cân nặng.", "weightMin": "Cân nặng phải từ 30 kg trở lên.", "weightMax": "Cân nặng phải từ 300 kg trở xuống.", diff --git a/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart b/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart index 3198b909..ca31c7a0 100644 --- a/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart +++ b/apps/mobile-flutter/lib/features/dashboard/screens/dashboard_screen.dart @@ -107,13 +107,14 @@ class DashboardScreen extends ConsumerWidget { ); } - /// True when the user has never logged anything, ever — so the dashboard can - /// collapse to the one first-run card and suppress the "% on track" framing. + /// True when the user looks brand-new — so the dashboard can collapse to + /// the one first-run card and suppress the "% on track" framing. /// /// There is no explicit "has-logged-before" flag on the bundle, so this gates - /// on "zero meals today AND zero historical logged/partial heatmap cells" — a - /// real meal on any day in the 90d window produces a logged-or-partial cell, - /// so an existing user is never mistaken for first-run. + /// on "zero meals today AND zero logged/partial heatmap cells in the 90d + /// window". Known limit: a returning user whose last meal is older than 90 + /// days has no cells in the window and IS treated as first-run — acceptable, + /// since after that long a gentle restart reads better than stale framing. bool _isFirstRun(DashboardBundle data) { if (data.day.persistedMeals.isNotEmpty) return false; for (final row in data.heatmap.cells) { diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart index 6461a6bc..c594f3f2 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart @@ -181,13 +181,14 @@ class _HeatmapBodyState extends State<_HeatmapBody> return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header: "{percent}% on track". Suppressed entirely until there's - // at least one logged day — a new user shouldn't read "0% on - // track" over an empty grid (the value is computed from no data). + // Header: "{percent}% on track". Suppressed until at least 3 + // scored days (spec) — a percentage computed from one or two + // days reads as noise, and a new user shouldn't see "0% on + // track" over an empty grid. Padding( padding: const EdgeInsets.only(bottom: NhamSpacing.sp2), child: Text( - (data != null && _adherence.loggedDays > 0) + (data != null && _adherence.loggedDays >= 3) ? tr('dashboard.adherenceHeatmap.onTrack', namedArgs: {'percent': '${_adherence.percent}'}) : ' ', diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart index d86efd24..23902b03 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/today_section.dart @@ -9,6 +9,7 @@ library; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../theme/nham_colors.dart'; @@ -87,13 +88,27 @@ class TodaySection extends ConsumerWidget { } } -/// First-run collapse: a single Lora question, no ring, no "% on track". Shown -/// only when the user has never logged a meal (zero today AND zero history). +/// First-run collapse: a single Lora question, no ring, no "% on track", plus +/// three time-of-day-aware suggestion chips that open the meal composer +/// prefilled. Shown only when the user has never logged a meal (zero today +/// AND zero history). class _FirstRunCard extends StatelessWidget { const _FirstRunCard(); + /// Which suggestion set fits the device clock (morning / midday / evening). + static String _chipBucket() { + final hour = DateTime.now().hour; + if (hour < 11) return 'morning'; + if (hour < 16) return 'midday'; + return 'evening'; + } + @override Widget build(BuildContext context) { + final bucket = _chipBucket(); + final suggestions = [ + for (var i = 1; i <= 3; i++) tr('dashboard.firstRunChips.$bucket$i'), + ]; return _FadeInDown( child: Container( width: double.infinity, @@ -113,6 +128,20 @@ class _FirstRunCard extends StatelessWidget { tr('dashboard.firstRunHint'), style: dashBody(color: kInkSecondary), ), + const SizedBox(height: NhamSpacing.sp4), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final s in suggestions) + _FirstRunChip( + label: s, + // Same prefill handoff the meal FAB uses. + onTap: () => context + .go('/logging?meal=${Uri.encodeComponent(s)}'), + ), + ], + ), ], ), ), @@ -120,6 +149,42 @@ class _FirstRunCard extends StatelessWidget { } } +/// A suggestion pill matching the logging empty-state chips: border hairline, +/// pill radius, white fill; pressed → accent-tinged border. +class _FirstRunChip extends StatefulWidget { + const _FirstRunChip({required this.label, required this.onTap}); + final String label; + final VoidCallback onTap; + + @override + State<_FirstRunChip> createState() => _FirstRunChipState(); +} + +class _FirstRunChipState extends State<_FirstRunChip> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: widget.onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _pressed ? const Color(0x26E8D5B5) : NhamColors.elev, + borderRadius: BorderRadius.circular(NhamRadii.pill), + border: Border.all( + color: _pressed ? NhamColors.accent50 : NhamColors.borderSoft, + ), + ), + child: Text(widget.label, style: dashMeta(color: kInk)), + ), + ); + } +} + class _MacroBarData { const _MacroBarData(this.label, this.current, this.target, this.color); final String label; @@ -506,7 +571,7 @@ class _MealRow extends StatelessWidget { SizedBox( width: _valueColumnWidth, child: Text( - '${round0(meal.nutrition.caloriesKcal)}', + '${round0(meal.nutrition.caloriesKcal)} kcal', textAlign: TextAlign.right, style: dashMeta(color: kInkSecondary, tabular: true), ), diff --git a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart index b8fbd0ab..36e8461d 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart @@ -1302,18 +1302,14 @@ class _LoggingDaySkeleton extends StatelessWidget { } } -/// Day fetch error: a red alert card with an AlertCircle, title/desc, and a -/// retry pill whose icon spins while refetching (LoggingDayErrorState). +/// Day fetch error: a warm alert card — terracotta `nham-danger` accents on +/// the cream surface (never literal reds, which break the palette on sight) — +/// with a CircleAlert, title/desc, and a retry pill (LoggingDayErrorState). class _LoggingDayErrorState extends StatelessWidget { const _LoggingDayErrorState({required this.onRetry}); final VoidCallback onRetry; - static const _red50 = Color(0xCCFEF2F2); // bg-red-50/80 - static const _red200 = Color(0xB3FECACA); // border-red-200/70 - static const _red600 = Color(0xFFDC2626); - static const _red950 = Color(0xFF450A0A); - static const _red900 = Color(0xCC7F1D1D); // red-900/80 - static const _red100 = Color(0xFFFEE2E2); + static const _dangerFill = Color(0x1AD37B69); // nham-danger @ 10% @override Widget build(BuildContext context) { @@ -1324,9 +1320,9 @@ class _LoggingDayErrorState extends StatelessWidget { constraints: const BoxConstraints(maxWidth: 448), // max-w-md padding: const EdgeInsets.all(NhamSpacing.sp4), // p-4 decoration: BoxDecoration( - color: _red50, + color: NhamColors.surface, borderRadius: BorderRadius.circular(NhamRadii.containerLg), // 2xl - border: Border.all(color: _red200), + border: Border.all(color: NhamColors.borderSoft), boxShadow: const [NhamShadows.sm], ), child: Row( @@ -1337,7 +1333,7 @@ class _LoggingDayErrorState extends StatelessWidget { child: Icon( LucideIcons.circleAlert, // lucide AlertCircle size: 20, - color: _red600, + color: NhamColors.danger, ), ), const SizedBox(width: NhamSpacing.sp3), // gap-3 @@ -1350,13 +1346,13 @@ class _LoggingDayErrorState extends StatelessWidget { variant: NhamTextVariant.small, style: NhamTextStyles.sansSemiBold( fontSize: NhamFontSize.sm, - ).copyWith(color: _red950), + ).copyWith(color: NhamColors.text), ), const SizedBox(height: 4), // mt-1 NhamText( 'logging.feedArea.loadErrorDescription'.tr(), variant: NhamTextVariant.small, - style: const TextStyle(color: _red900), + style: const TextStyle(color: NhamColors.textMuted), ), const SizedBox(height: NhamSpacing.sp3), // mt-3 _RetryPill(onRetry: onRetry), @@ -1386,7 +1382,7 @@ class _RetryPill extends StatelessWidget { vertical: 8, ), // px-3.5 py-2 decoration: BoxDecoration( - color: _LoggingDayErrorState._red100, + color: _LoggingDayErrorState._dangerFill, borderRadius: BorderRadius.circular(NhamRadii.pill), ), child: Row( @@ -1395,7 +1391,7 @@ class _RetryPill extends StatelessWidget { const Icon( LucideIcons.refreshCw, // lucide RefreshCw size: 16, - color: _LoggingDayErrorState._red950, + color: NhamColors.danger, ), const SizedBox(width: NhamSpacing.sp2), // gap-2 NhamText( @@ -1403,7 +1399,7 @@ class _RetryPill extends StatelessWidget { variant: NhamTextVariant.small, style: NhamTextStyles.sansMedium( fontSize: NhamFontSize.sm, - ).copyWith(color: _LoggingDayErrorState._red950), + ).copyWith(color: NhamColors.danger), ), ], ), diff --git a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart index 13897fbb..2d2965c7 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart @@ -311,13 +311,13 @@ class _ItemRow extends StatelessWidget { opacity: struck ? 0.4 : 1, child: Row( children: [ - NhamText('P:${fmtG(item.macros.protein)}', + NhamText('P: ${fmtG(item.macros.protein)}', variant: NhamTextVariant.itemMacro, maxLines: 1), const SizedBox(width: NhamSpacing.sp2), - NhamText('C:${fmtG(item.macros.carbs)}', + NhamText('C: ${fmtG(item.macros.carbs)}', variant: NhamTextVariant.itemMacro, maxLines: 1), const SizedBox(width: NhamSpacing.sp2), - NhamText('F:${fmtG(item.macros.fat)}', + NhamText('F: ${fmtG(item.macros.fat)}', variant: NhamTextVariant.itemMacro, maxLines: 1), const SizedBox(width: NhamSpacing.sp3), // gap-3 NhamText(fmtKcal(item.macros.calories), diff --git a/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart b/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart index 656dc54f..1cee773e 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/persisted_meal_card.dart @@ -292,13 +292,14 @@ class _ExpandedDetails extends StatelessWidget { const SizedBox(width: NhamSpacing.sp3), // gap-3 Row( children: [ - NhamText('P:${fmtG(group.nutrition.proteinG)}', + NhamText('P: ${fmtG(group.nutrition.proteinG)}', variant: NhamTextVariant.macroTiny), const SizedBox(width: NhamSpacing.sp2), - NhamText('C:${fmtG(group.nutrition.carbohydrateG)}', + NhamText( + 'C: ${fmtG(group.nutrition.carbohydrateG)}', variant: NhamTextVariant.macroTiny), const SizedBox(width: NhamSpacing.sp2), - NhamText('F:${fmtG(group.nutrition.fatG)}', + NhamText('F: ${fmtG(group.nutrition.fatG)}', variant: NhamTextVariant.macroTiny), const SizedBox(width: NhamSpacing.sp3), // gap-3 NhamText( diff --git a/apps/mobile-flutter/lib/features/onboarding/screens/welcome_setup_screen.dart b/apps/mobile-flutter/lib/features/onboarding/screens/welcome_setup_screen.dart index bd1c6202..dc0a0f27 100644 --- a/apps/mobile-flutter/lib/features/onboarding/screens/welcome_setup_screen.dart +++ b/apps/mobile-flutter/lib/features/onboarding/screens/welcome_setup_screen.dart @@ -97,16 +97,6 @@ class _WelcomeSetupScreenState extends ConsumerState context.go('/logging'); } - String _fmt(int n) { - final s = n.toString(); - final buf = StringBuffer(); - for (var i = 0; i < s.length; i++) { - if (i > 0 && (s.length - i) % 3 == 0) buf.write(','); - buf.write(s[i]); - } - return buf.toString(); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -134,7 +124,13 @@ class _WelcomeSetupScreenState extends ConsumerState return Text.rich( TextSpan( children: [ - TextSpan(text: _fmt(shown)), + // Locale-aware grouping (en "2,000" / vi "2.000"). + TextSpan( + text: formatCount( + shown, + context.locale.toString(), + ), + ), TextSpan( text: ' ${tr('onboarding.setup.perDay')}', style: NhamTextStyles.sansRegular( diff --git a/apps/mobile-flutter/lib/features/settings/controls/custom_select.dart b/apps/mobile-flutter/lib/features/settings/controls/custom_select.dart index f8c8ef2e..259c5de1 100644 --- a/apps/mobile-flutter/lib/features/settings/controls/custom_select.dart +++ b/apps/mobile-flutter/lib/features/settings/controls/custom_select.dart @@ -27,12 +27,17 @@ class CustomSelect extends StatefulWidget { required this.options, required this.value, required this.onChange, + this.placeholder, }); final List options; final String value; final ValueChanged onChange; + /// Muted trigger text shown while [value] matches no option (e.g. a field + /// that must be genuinely chosen and starts empty). + final String? placeholder; + @override State createState() => _CustomSelectState(); } @@ -150,12 +155,16 @@ class _CustomSelectState extends State child: Padding( padding: const EdgeInsets.only(right: NhamSpacing.sp2), child: Text( - selected ?? '', + selected ?? widget.placeholder ?? '', maxLines: 1, overflow: TextOverflow.ellipsis, style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm) - .copyWith(color: NhamColors.text), + .copyWith( + color: selected != null + ? NhamColors.text + : NhamColors.textHelp, + ), ), ), ), diff --git a/apps/mobile-flutter/lib/features/settings/logic/profile_payload.dart b/apps/mobile-flutter/lib/features/settings/logic/profile_payload.dart index 267513f5..89172256 100644 --- a/apps/mobile-flutter/lib/features/settings/logic/profile_payload.dart +++ b/apps/mobile-flutter/lib/features/settings/logic/profile_payload.dart @@ -14,7 +14,7 @@ ProfileSavePayload buildProfilePayload( String preferredLocale, ) { final bmr = calcBMR( - biologicalSex: v.biologicalSex, + biologicalSex: v.biologicalSex!, weightKg: v.weightKg!, heightCm: v.heightCm!, age: v.age!, @@ -28,7 +28,7 @@ ProfileSavePayload buildProfilePayload( weightKg: v.weightKg!, heightCm: v.heightCm!, age: v.age!, - biologicalSex: v.biologicalSex, + biologicalSex: v.biologicalSex!, activityLevel: v.activityLevel, tdeeKcal: tdee, goal: v.goal, diff --git a/apps/mobile-flutter/lib/features/settings/panels/body_metrics.dart b/apps/mobile-flutter/lib/features/settings/panels/body_metrics.dart index 1b1c0d56..5a9a68b4 100644 --- a/apps/mobile-flutter/lib/features/settings/panels/body_metrics.dart +++ b/apps/mobile-flutter/lib/features/settings/panels/body_metrics.dart @@ -31,7 +31,8 @@ class BodyMetrics extends StatelessWidget { // Live TDEE / targets — recomputed on every form mutation (the controller // notifies, the screen rebuilds this panel). Matches RN useWatch + useMemo. - final allMetricsFilled = v.weightKg != null && + final allMetricsFilled = v.biologicalSex != null && + v.weightKg != null && !(v.weightKg!.isNaN) && v.heightCm != null && v.age != null; @@ -39,7 +40,7 @@ class BodyMetrics extends StatelessWidget { int? tdee; if (allMetricsFilled) { final bmr = calcBMR( - biologicalSex: v.biologicalSex, + biologicalSex: v.biologicalSex!, weightKg: v.weightKg!, heightCm: v.heightCm!, age: v.age!, @@ -59,13 +60,32 @@ class BodyMetrics extends StatelessWidget { children: [ _Field( label: t('biologicalSex'), - child: CustomSelect( - value: v.biologicalSex.name, - onChange: (s) => form.update( - (f) => f.biologicalSex = BiologicalSex.values.byName(s)), - options: [ - CustomSelectOption(value: 'male', label: t('male')), - CustomSelectOption(value: 'female', label: t('female')), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CustomSelect( + // Empty until genuinely chosen — no silent male default. + value: v.biologicalSex?.name ?? '', + placeholder: t('sexPlaceholder'), + onChange: (s) { + form.update((f) => + f.biologicalSex = BiologicalSex.values.byName(s)); + form.clearError(ProfileField.biologicalSex); + }, + options: [ + CustomSelectOption(value: 'male', label: t('male')), + CustomSelectOption(value: 'female', label: t('female')), + ], + ), + if (form.errorFor(ProfileField.biologicalSex) != null) ...[ + const SizedBox(height: 6), // gap-1.5 + Text( + form.errorFor(ProfileField.biologicalSex)!, + style: + NhamTextStyles.sansRegular(fontSize: NhamFontSize.xs) + .copyWith(color: NhamColors.danger), + ), + ], ], ), ), diff --git a/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart b/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart index a9919b64..1f4b3e99 100644 --- a/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart +++ b/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart @@ -78,7 +78,7 @@ class _SettingsList extends ConsumerWidget { children: [ Text( tr('settings.title'), - style: NhamTextStyles.serifMedium(fontSize: NhamFontSize.lg) + style: NhamTextStyles.serifRegular(fontSize: NhamFontSize.lg) .copyWith( letterSpacing: NhamTracking.tight, color: NhamColors.text), @@ -165,7 +165,15 @@ class _SettingsList extends ConsumerWidget { final aggression = double.tryParse(profile.aggression ?? ''); if (aggression == null) return goalLabel; final unit = tr('onboarding.bodyMetrics.weightUnit'); - return '$goalLabel · ${aggression.toStringAsFixed(2)} $unit/wk'; + // Locale decimal separator (vi "0,50") + localized per-week suffix. + final paceFmt = NumberFormat.decimalPattern(context.locale.languageCode) + ..minimumFractionDigits = 2 + ..maximumFractionDigits = 2; + final pace = tr('settings.rows.pacePerWeek', namedArgs: { + 'pace': paceFmt.format(aggression), + 'unit': unit, + }); + return '$goalLabel · $pace'; } /// "Việt Nam · Tiếng Việt" — residence country + current app language, or @@ -426,7 +434,7 @@ class _ProfileEmpty extends StatelessWidget { children: [ Text( tr('settings.profilePage.emptyTitle'), - style: NhamTextStyles.serifMedium( + style: NhamTextStyles.serifRegular( fontSize: NhamFontSize.h3, ).copyWith( letterSpacing: NhamTracking.tight, @@ -490,7 +498,7 @@ class _ProfileLoadError extends StatelessWidget { children: [ Text( tr('common.error'), - style: NhamTextStyles.serifMedium( + style: NhamTextStyles.serifRegular( fontSize: NhamFontSize.h3, ).copyWith( letterSpacing: NhamTracking.tight, diff --git a/apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart b/apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart index 09216ba6..c5244abd 100644 --- a/apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart +++ b/apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart @@ -110,7 +110,9 @@ class _InstantCommitEditorState extends ConsumerState { return; } if (ok) { - HapticFeedback.selectionClick(); + // One save-success cue app-wide: mediumImpact (matches logging + the + // goal editor), not the lighter selection tick. + HapticFeedback.mediumImpact(); // Baseline exactly what was sent — edits made while the save was in // flight stay dirty and re-commit below, instead of being silently // baselined away. diff --git a/apps/mobile-flutter/lib/features/settings/widgets/profile_form_values.dart b/apps/mobile-flutter/lib/features/settings/widgets/profile_form_values.dart index 6c02fc3c..c364a5d6 100644 --- a/apps/mobile-flutter/lib/features/settings/widgets/profile_form_values.dart +++ b/apps/mobile-flutter/lib/features/settings/widgets/profile_form_values.dart @@ -9,7 +9,9 @@ import '../data/profile_providers.dart'; /// nullable because the user can clear an input mid-edit; validation guards the /// save. `weightKg` is nullable (the DecimalInput reports `null` while empty). class ProfileFormValues { - BiologicalSex biologicalSex; + /// Null until the user has actually chosen — never silently defaulted to + /// male, which would bake a wrong BMR into the saved targets. + BiologicalSex? biologicalSex; double? weightKg; int? heightCm; int? age; @@ -88,7 +90,10 @@ class ProfileFormValues { } return ProfileFormValues( - biologicalSex: _sexFrom(p.biologicalSex) ?? BiologicalSex.male, + // Like the numeric metrics below, sex starts EMPTY when the profile has + // none — a silent male default would compute a wrong BMR the user never + // chose. validateBodyMetrics forces a genuine selection instead. + biologicalSex: _sexFrom(p.biologicalSex), // Numeric body metrics start EMPTY when the profile has none — never // fabricated 65/165/25, which a user who skipped onboarding could save as // if they were real. validateBodyMetrics forces a genuine entry instead. @@ -177,7 +182,7 @@ BrothConsumption? _brothFrom(String? s) => switch (s) { }; /// Field identifiers, used to map a validation error to its owning tab. -enum ProfileField { weightKg, heightCm, age } +enum ProfileField { biologicalSex, weightKg, heightCm, age } /// Validates the body-metrics numeric fields exactly as the RN zod schema does /// (`createBodyMetricsSchema`). Returns the per-field localized error messages @@ -185,6 +190,11 @@ enum ProfileField { weightKg, heightCm, age } Map validateBodyMetrics(ProfileFormValues v) { final errors = {}; + if (v.biologicalSex == null) { + errors[ProfileField.biologicalSex] = + tr('validation.bodyMetrics.sexRequired'); + } + final w = v.weightKg; if (w == null || w.isNaN) { errors[ProfileField.weightKg] = tr('validation.bodyMetrics.weightRequired'); diff --git a/apps/mobile-flutter/lib/shell/placeholder_screen.dart b/apps/mobile-flutter/lib/shell/placeholder_screen.dart index 37e6c25a..cb9d3bc6 100644 --- a/apps/mobile-flutter/lib/shell/placeholder_screen.dart +++ b/apps/mobile-flutter/lib/shell/placeholder_screen.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../shared/widgets/widgets.dart'; import '../theme/nham_theme.dart'; @@ -20,9 +21,19 @@ class PlaceholderScreen extends StatelessWidget { return Screen( child: Column( children: [ - const Padding( - padding: EdgeInsets.symmetric(horizontal: NhamSpacing.sp3), - child: AppHeader(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: NhamSpacing.sp3), + // Back affordance: a deep link can land here directly, so the + // header must always offer a way out (pop if possible, else home). + child: AppHeader( + onBack: () { + if (context.canPop()) { + context.pop(); + } else { + context.go('/logging'); + } + }, + ), ), Expanded( child: Center( From 60c496e8797a3b587d2362c3e2aeaf18156fc07e Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Fri, 12 Jun 2026 22:38:00 +0700 Subject: [PATCH 42/57] perf(mobile): bundle Lora + DM Sans so cold starts render brand type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles the static OFL TTFs under assets/google_fonts/ — Lora Regular/Italic/Medium and DM Sans Regular/Medium/SemiBold/Bold, the exact weight/style set the app's type system uses — named per the Google Fonts API convention so google_fonts resolves them from assets, and disables runtime fetching in main.dart. A cold offline start now renders in the brand fonts instead of falling back while a network fetch races first paint. Lora's TTFs carry full Vietnamese coverage (verified ẩ ở ự đ ọ in the cmap); DM Sans has no Vietnamese subset — identical coverage to the previous runtime fetch, so no regression. The audit listed only Lora Regular+Italic and DM Sans Regular/Medium/Bold, but with fetching disabled the other live variants (Lora 500, DM Sans 600) would throw and drop to the system fallback — so the full in-use set is bundled. Co-Authored-By: Claude Opus 4.8 --- .../assets/google_fonts/DMSans-Bold.ttf | Bin 0 -> 48200 bytes .../assets/google_fonts/DMSans-Medium.ttf | Bin 0 -> 48308 bytes .../assets/google_fonts/DMSans-Regular.ttf | Bin 0 -> 48280 bytes .../assets/google_fonts/DMSans-SemiBold.ttf | Bin 0 -> 48280 bytes .../assets/google_fonts/Lora-Italic.ttf | Bin 0 -> 137328 bytes .../assets/google_fonts/Lora-Medium.ttf | Bin 0 -> 132320 bytes .../assets/google_fonts/Lora-Regular.ttf | Bin 0 -> 132188 bytes apps/mobile-flutter/lib/main.dart | 6 ++++++ apps/mobile-flutter/pubspec.yaml | 5 +++++ 9 files changed, 11 insertions(+) create mode 100644 apps/mobile-flutter/assets/google_fonts/DMSans-Bold.ttf create mode 100644 apps/mobile-flutter/assets/google_fonts/DMSans-Medium.ttf create mode 100644 apps/mobile-flutter/assets/google_fonts/DMSans-Regular.ttf create mode 100644 apps/mobile-flutter/assets/google_fonts/DMSans-SemiBold.ttf create mode 100644 apps/mobile-flutter/assets/google_fonts/Lora-Italic.ttf create mode 100644 apps/mobile-flutter/assets/google_fonts/Lora-Medium.ttf create mode 100644 apps/mobile-flutter/assets/google_fonts/Lora-Regular.ttf diff --git a/apps/mobile-flutter/assets/google_fonts/DMSans-Bold.ttf b/apps/mobile-flutter/assets/google_fonts/DMSans-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..811136c6dafecfa0880718f2709ef86e71dc2ff8 GIT binary patch literal 48200 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 WrW6E7$NK-00_{mMCv@JF6aEk4!)=5B literal 0 HcmV?d00001 diff --git a/apps/mobile-flutter/assets/google_fonts/DMSans-Medium.ttf b/apps/mobile-flutter/assets/google_fonts/DMSans-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..dc447543b7d04d75f68cf303ea911f539c244922 GIT binary patch literal 48308 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 literal 0 HcmV?d00001 diff --git a/apps/mobile-flutter/assets/google_fonts/DMSans-Regular.ttf b/apps/mobile-flutter/assets/google_fonts/DMSans-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6c789eadb84764a2d7f75fc989ccb78873ec1540 GIT binary patch literal 48280 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 P;`?OOSMq<&12z8xwzf~F literal 0 HcmV?d00001 diff --git a/apps/mobile-flutter/assets/google_fonts/Lora-Italic.ttf b/apps/mobile-flutter/assets/google_fonts/Lora-Italic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..b2314c7bf579913204d94d373293659a2ed30bee GIT binary patch literal 137328 zcmdqK2YeRA+W$Q>n*s?bq>zwMLJL($htN~$MS2qmAtZr7AS56~MFd1ZMX+$xqgamx zd&7Dx6crFuM8t9sm2L!#*pU1Go!Pzj4#;yJ&-;I#&*y#L`~Li9ubrKly=Ja!uARAd zcUDLtL^y$;5Pfp<^6zZF?t~DkJ4om=bja{&nRk9CMBjIXc=$k{;Ulw~tnWNfh>*2H z1RfnSyi>~cy(cslLViIhlZTJU8gXRSPJi|{u%BBvvtV}KtjkJ;h`v&YI?IX+=FZk< z<9HO?I>luRrcL|ejzvQJ@Su=wc9s+sOijG|i^UwD%<+s8BI+!xa~1pWBU+Zsti1Bd zelxZT(fAxZyjxaYSTJRCTxTHy9s!4E7F;>oS|)=?--z@HvkGPwC9NL0n#xU}J_Bc$ z&#fFedW%Ph_FE`J&8{e#T@tWnIqB<>K5(=2EPvYXMs|htctjXmVO5BwB2cUl0eY|2 z&rhe~c=h>e%Kz1~^pEa-A6rL7TCFrXx6Wm$dYLx{H=7jJ{U>3OhTqpWM!s&g_b&a^ zv-JG8ejocq3&mNKj^(FIuVeP2OxRMW`Nbuby13HHf-D$(^?Ok!CJ1qd zJ}zra*$JFY!dXY6LWwgty7t3GbA565cKUPPkESB)nffLimJy zlJIHyG~sjdIl>p@3xqGp&4jPWHwfR9?+|X2+X+9Ay9qy(pAdd3za~6N8%cRu))4+I ze<3`t>IkWVRRm#_YCzafH6)Bz)J`>3EeR72VFmAaB}p}LxIiCRLqPwgZ8MtwtgM8TW-QT<4GUY!@x z>S(2)t7+B*BWJByT4NdjEn#VON1m2RB}mmx_**&FcoD)eKdXn;)9OV|A%ev!o!_r6 z^hvSLp|xgz;Lu9c6K^}T#W`+wXg|)i#G(Dsm}w3jK>SdL4iy<3G3C=Q>Y;}=Eq!H5 zf32Njm?#p(qDqvB0#PAGvscP~iKsNF6tl%#^sy8FxuTF1rNmT1cLb&6>L|*IE#~+T zkuL`F*ZT$HGIY9^-g7m)Q-9(NU3zvDKo16B+ zvTL8md-fc-m@O6u#4At`6l28HIFMW)`B(6cGAlY5@asEf^0nbR(9k!ymO@068bq%qgD z)jYzf9LuNVS%#;TDlq`@RtFEdNU04>a3-#AZxVsi1obnrnSTR)cVHy(en5O`^EUR z@ayE)&99H&aKA}@GyLZJUE_DX-#Wjq{nP!k{0I4u^)K?DPTb!OC=UuS8Zo9gVW+r93M zb^liPp}No1eLW~OXjag|pcO&ug6;`=GU%0{Z9yLeeHC;(=xneG4h?P++%dRk@ZjKy z!PA511-~1-H~3(^A@v@t_iM=Dkbj2k2-z2MC^R*+S7`sxQK1E)Geh4F{VA+dSl6)p zuwh~MhCLqkQrJ6TyTd*YI}-L|m?u0qJU+aAc(?EY;p4-L!{>%y6TUk9j_`lgD8 z{`mUE_2<_AD58Bt=ZKt$Ara#viX-MmTodtl#7hxxM|=?3B(hy(Mr3y6u*iv#(J}|y_{DAmT@pr{P9RE!GKjYtz|0Moke0BV< zjp{XuX*90Uqm7$4?$|i9@xsQ-8{g9S?#7QaKGI}ilW&`xYI3e=ou*Mun>JnB^u4B^ zH2u2i$)>*~3{DuAFfE}X;i`li5^hV_n6N3~wS=7sUnHDJ_^p|Lv+!nDG%IaZ+3aZZ zq0L`w5!PZ{i)k(9v{>BY;}&1FIM$-3g{Ngu%ez}vw`$p{Q>$*R@>^ZrYICbCt@gC~ zyw%}WXIh<43`%T}*gP>MF*|Ws;*`WWiHj3gCf=I3G4b)%bz84!y{`2=tsiUsVw
  • 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

    Y?qu9855 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 literal 0 HcmV?d00001 diff --git a/apps/mobile-flutter/assets/google_fonts/Lora-Medium.ttf b/apps/mobile-flutter/assets/google_fonts/Lora-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..913c1dd9f6623be1724e2071ab399b85dc50a114 GIT binary patch literal 132320 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 AMgRZ+ literal 0 HcmV?d00001 diff --git a/apps/mobile-flutter/lib/main.dart b/apps/mobile-flutter/lib/main.dart index 6e3ce5db..11ee2d36 100644 --- a/apps/mobile-flutter/lib/main.dart +++ b/apps/mobile-flutter/lib/main.dart @@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'app.dart'; import 'services/supabase_service.dart'; @@ -19,6 +20,11 @@ Future main() async { WidgetsFlutterBinding.ensureInitialized(); await EasyLocalization.ensureInitialized(); + // The brand fonts (Lora + DM Sans, every weight/style the app uses) are + // bundled under assets/google_fonts/ — never fetch over HTTP, so a cold + // offline start still renders in the brand type instead of the fallback. + GoogleFonts.config.allowRuntimeFetching = false; + if (_supabaseUrl.isEmpty || _supabaseAnonKey.isEmpty) { throw StateError( 'Missing SUPABASE_URL / SUPABASE_ANON_KEY — pass them via --dart-define ' diff --git a/apps/mobile-flutter/pubspec.yaml b/apps/mobile-flutter/pubspec.yaml index ddeb9669..c79be58a 100644 --- a/apps/mobile-flutter/pubspec.yaml +++ b/apps/mobile-flutter/pubspec.yaml @@ -40,3 +40,8 @@ flutter: uses-material-design: true assets: - assets/l10n/ + # Bundled brand fonts (OFL). google_fonts resolves these local assets by + # their API filenames — do NOT rename — so the UI renders in Lora/DM Sans + # on a cold offline start instead of falling back while fetching. Runtime + # fetching is disabled in main.dart. No `fonts:` section needed. + - assets/google_fonts/ From 13f6d6b963c2525d8d63ed8e881cf78d147b444c Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Thu, 18 Jun 2026 22:49:17 +0700 Subject: [PATCH 43/57] fix: polish mobile ux review blockers --- apps/mobile-flutter/assets/l10n/vi.json | 2 +- apps/mobile-flutter/lib/data/api_client.dart | 21 +- .../features/auth/widgets/welcome_demo.dart | 47 ++- .../dashboard/widgets/adherence_heatmap.dart | 199 +++++---- .../features/logging/data/logging_keys.dart | 19 +- .../logging/screens/logging_screen.dart | 26 +- .../features/logging/widgets/count_up.dart | 44 +- .../features/logging/widgets/feed_area.dart | 266 ++++++------ .../features/logging/widgets/meal_entry.dart | 211 ++++++---- .../features/logging/widgets/meal_input.dart | 6 +- .../widgets/partial_yesterday_prompt.dart | 50 ++- .../nutrition_overview_provider.dart | 39 +- .../screens/nutrient_detail_screen.dart | 68 +-- .../nutrition/screens/nutrition_screen.dart | 43 +- .../nutrition/widgets/background_section.dart | 57 ++- .../nutrition/widgets/daily_rhythm.dart | 148 ++++--- .../nutrition/widgets/editorial_header.dart | 160 +------ .../nutrition/widgets/inline_error.dart | 72 ++-- .../nutrition/widgets/nutrient_row.dart | 128 +++--- .../nutrition/widgets/range_selector.dart | 129 ++++++ .../screens/screen_body_metrics.dart | 393 ++++++++++-------- .../onboarding/widgets/language_toggle.dart | 112 ++--- .../settings/controls/country_select.dart | 356 +++++++++------- .../settings/controls/custom_select.dart | 218 +++++----- .../settings/controls/option_strip.dart | 87 ++-- .../settings/screens/account_section.dart | 107 +++-- .../settings/screens/settings_screen.dart | 318 ++++++++------ .../widgets/instant_commit_editor.dart | 55 ++- .../settings/widgets/region_editor.dart | 23 +- apps/mobile-flutter/lib/router.dart | 7 +- .../lib/shell/tab_scaffold.dart | 14 +- .../lib/theme/nham_typography.dart | 70 ++-- apps/mobile-flutter/test/widget_test.dart | 38 +- 33 files changed, 2007 insertions(+), 1526 deletions(-) create mode 100644 apps/mobile-flutter/lib/features/nutrition/widgets/range_selector.dart diff --git a/apps/mobile-flutter/assets/l10n/vi.json b/apps/mobile-flutter/assets/l10n/vi.json index 1fb29b79..7d497700 100644 --- a/apps/mobile-flutter/assets/l10n/vi.json +++ b/apps/mobile-flutter/assets/l10n/vi.json @@ -1173,7 +1173,7 @@ "deleteScreenTitle": "Xóa tài khoản", "deleteConsequence": "Thao tác này xóa hồ sơ, mọi bữa ăn bạn đã ghi, và lịch sử cân nặng của bạn. Không thể hoàn tác.", "deleteConfirmLabel": "Nhập {word} để xác nhận", - "deleteConfirmWord": "XÓA", + "deleteConfirmWord": "XOA", "deleteConfirmAction": "Xóa tài khoản của tôi", "deleteError": "Không thể xóa tài khoản. Vui lòng thử lại." }, diff --git a/apps/mobile-flutter/lib/data/api_client.dart b/apps/mobile-flutter/lib/data/api_client.dart index b2bb9d26..05fedae6 100644 --- a/apps/mobile-flutter/lib/data/api_client.dart +++ b/apps/mobile-flutter/lib/data/api_client.dart @@ -15,11 +15,14 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; +import 'package:supabase_flutter/supabase_flutter.dart' show Session; import '../models/streaming.dart'; import '../services/supabase_service.dart'; import 'env.dart'; +const _authRefreshSkew = Duration(minutes: 5); + /// Client-side mirror of the server `ApiError` envelope. /// /// Re-implemented (not imported) exactly as the RN client does, because the @@ -112,10 +115,26 @@ class ApiClient { final http.Client _http; final String _baseUrl; + bool _expiresSoon(Session session) { + final expiresAt = session.expiresAt; + if (expiresAt == null) return false; + final expiry = DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000); + return DateTime.now().add(_authRefreshSkew).isAfter(expiry); + } + /// Bearer header from the current Supabase session token, or empty when /// signed out. Mirrors `authHeaders()` in the RN client. Future> _authHeaders() async { - final token = SupabaseService.client.auth.currentSession?.accessToken; + final auth = SupabaseService.client.auth; + var session = auth.currentSession; + if (session != null && _expiresSoon(session)) { + try { + session = (await auth.refreshSession()).session ?? auth.currentSession; + } catch (_) { + session = auth.currentSession; + } + } + final token = session?.accessToken; return token != null ? {'Authorization': 'Bearer $token'} : {}; } diff --git a/apps/mobile-flutter/lib/features/auth/widgets/welcome_demo.dart b/apps/mobile-flutter/lib/features/auth/widgets/welcome_demo.dart index 3ec44b81..77a3d943 100644 --- a/apps/mobile-flutter/lib/features/auth/widgets/welcome_demo.dart +++ b/apps/mobile-flutter/lib/features/auth/widgets/welcome_demo.dart @@ -24,15 +24,12 @@ class WelcomeDemo extends StatefulWidget { class _WelcomeDemoState extends State with SingleTickerProviderStateMixin { - late final String _full = tr('auth.welcome.demoMeal'); + String _full = ''; int _typed = 0; bool _resolved = false; Timer? _timer; - late final AnimationController _chip = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 420), - ); + late final AnimationController _chip; bool get _reducedMotion => WidgetsBinding @@ -44,12 +41,29 @@ class _WelcomeDemoState extends State @override void initState() { super.initState(); + _chip = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 420), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final full = tr('auth.welcome.demoMeal'); + if (full == _full) return; + + _timer?.cancel(); + _full = full; if (_reducedMotion) { _typed = _full.length; _resolved = true; _chip.value = 1; return; } + _typed = 0; + _resolved = false; + _chip.reset(); _startTyping(); } @@ -104,18 +118,19 @@ class _WelcomeDemoState extends State duration: const Duration(milliseconds: 240), curve: Curves.easeOut, alignment: Alignment.centerLeft, - child: _resolved - ? FadeTransition( - opacity: _chip, - child: ScaleTransition( - scale: Tween(begin: 0.92, end: 1).animate( - CurvedAnimation(parent: _chip, curve: Curves.easeOut), + child: + _resolved + ? FadeTransition( + opacity: _chip, + child: ScaleTransition( + scale: Tween(begin: 0.92, end: 1).animate( + CurvedAnimation(parent: _chip, curve: Curves.easeOut), + ), + alignment: Alignment.centerLeft, + child: _resultChip(), ), - alignment: Alignment.centerLeft, - child: _resultChip(), - ), - ) - : const SizedBox(height: 0, width: double.infinity), + ) + : const SizedBox(height: 0, width: double.infinity), ), ], ), diff --git a/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart b/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart index c594f3f2..fa1df607 100644 --- a/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart +++ b/apps/mobile-flutter/lib/features/dashboard/widgets/adherence_heatmap.dart @@ -30,6 +30,7 @@ List _weekdayInitials(String locale) { for (var i = 0; i < 7; i++) fmt.format(monday.add(Duration(days: i))), ]; } + const double _gap90d = 2; // GAP['90d'] const double _dayLabelWidth = 16; const double _dayLabelGutter = NhamSpacing.sp1; // gap-1 (4px) @@ -52,35 +53,37 @@ class AdherenceHeatmap extends ConsumerWidget { // Empty/loaded both render the grid; the server always returns a full // grid for the range (unlogged days are the "not logged" track). data: (data) => _HeatmapBody(data: data), - error: (_, __) => Container( - constraints: const BoxConstraints(minHeight: 180), - alignment: Alignment.center, - padding: const EdgeInsets.all(NhamSpacing.sp4), - decoration: BoxDecoration( - color: kCardSurface, - borderRadius: BorderRadius.circular(kCardRadius), - boxShadow: const [kCardShadow], - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 280), - child: Text( - tr('dashboard.heatmapLoadError'), - textAlign: TextAlign.center, - style: dashMeta(color: kInkDisabled), - ), + error: + (_, __) => Container( + constraints: const BoxConstraints(minHeight: 180), + alignment: Alignment.center, + padding: const EdgeInsets.all(NhamSpacing.sp4), + decoration: BoxDecoration( + color: kCardSurface, + borderRadius: BorderRadius.circular(kCardRadius), + boxShadow: const [kCardShadow], ), - const SizedBox(height: NhamSpacing.sp3), - NhamButton( - title: tr('dashboard.retry'), - variant: NhamButtonVariant.ghost, - onPressed: () => ref.invalidate(dashboardBundleProvider(args)), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: Text( + tr('dashboard.heatmapLoadError'), + textAlign: TextAlign.center, + style: dashMeta(color: kInkDisabled), + ), + ), + const SizedBox(height: NhamSpacing.sp3), + NhamButton( + title: tr('dashboard.retry'), + variant: NhamButtonVariant.ghost, + onPressed: + () => ref.invalidate(dashboardBundleProvider(args)), + ), + ], ), - ], - ), - ), + ), ); } } @@ -108,8 +111,10 @@ class _HeatmapBodyState extends State<_HeatmapBody> // 0.16s with a stagger delay of wi*0.01 + di*0.005. The controller spans the // full timeline (max delay + 0.16s); the painter derives each cell's local // progress from [_reveal].value. Total ≈ (numWeeks-1)*0.01 + 6*0.005 + 0.16s. - late final int _staggerMs = ((_numWeeks - 1) * 10 + 6 * 5 + 160) - .clamp(160, 5000); + late final int _staggerMs = ((_numWeeks - 1) * 10 + 6 * 5 + 160).clamp( + 160, + 5000, + ); late final AnimationController _reveal = AnimationController( vsync: this, duration: Duration(milliseconds: _staggerMs), @@ -148,7 +153,8 @@ class _HeatmapBodyState extends State<_HeatmapBody> double _cellSize(double contentWidth, int numWeeks) { if (numWeeks <= 0) return 10; - final available = contentWidth - + final available = + contentWidth - _dayLabelWidth - _dayLabelGutter - (numWeeks - 1) * _gap90d; @@ -177,6 +183,7 @@ class _HeatmapBodyState extends State<_HeatmapBody> final gridWidth = numWeeks > 0 ? numWeeks * sq + (numWeeks - 1) * _gap90d : 0.0; final gridHeight = 7 * sq + 6 * _gap90d; + final adherence = _adherence; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -188,9 +195,11 @@ class _HeatmapBodyState extends State<_HeatmapBody> Padding( padding: const EdgeInsets.only(bottom: NhamSpacing.sp2), child: Text( - (data != null && _adherence.loggedDays >= 3) - ? tr('dashboard.adherenceHeatmap.onTrack', - namedArgs: {'percent': '${_adherence.percent}'}) + (data != null && adherence.loggedDays >= 3) + ? tr( + 'dashboard.adherenceHeatmap.onTrack', + namedArgs: {'percent': '${adherence.percent}'}, + ) : ' ', style: dashMeta(color: kInk, tabular: true), ), @@ -210,17 +219,19 @@ class _HeatmapBodyState extends State<_HeatmapBody> Container( height: sq, margin: EdgeInsets.only( - top: i == 0 - ? _monthStripHeight + NhamSpacing.sp1 - : _gap90d, + top: + i == 0 + ? _monthStripHeight + NhamSpacing.sp1 + : _gap90d, ), padding: const EdgeInsets.only(right: 4), alignment: Alignment.centerRight, child: Text( dayLabels[i], style: dashEyebrow( - color: kInkSecondary, - weight: FontWeight.w600), + color: kInkSecondary, + weight: FontWeight.w600, + ), ), ), ], @@ -237,20 +248,23 @@ class _HeatmapBodyState extends State<_HeatmapBody> width: gridWidth, child: Stack( children: [ - for (final h in data?.monthHeaders ?? - const []) + for (final h + in data?.monthHeaders ?? + const []) Positioned( top: 0, left: h.startColumn * step, - width: h.span * sq + + width: + h.span * sq + (h.span - 1 > 0 ? h.span - 1 : 0) * _gap90d, child: Text( h.month, maxLines: 1, overflow: TextOverflow.clip, style: dashEyebrow( - color: kInkSecondary, - weight: FontWeight.w600), + color: kInkSecondary, + weight: FontWeight.w600, + ), ), ), ], @@ -263,9 +277,10 @@ class _HeatmapBodyState extends State<_HeatmapBody> children: [ GestureDetector( behavior: HitTestBehavior.opaque, - onTapUp: data == null - ? null - : (details) => _handleTap( + onTapUp: + data == null + ? null + : (details) => _handleTap( details.localPosition, data, sq, @@ -274,37 +289,44 @@ class _HeatmapBodyState extends State<_HeatmapBody> ), child: AnimatedBuilder( animation: _reveal, - builder: (context, _) => CustomPaint( - size: Size(gridWidth, gridHeight), - painter: _GridPainter( - data: data, - numWeeks: numWeeks, - sq: sq, - step: step, - // Drive the per-cell wave reveal. - reveal: _reveal.value, - totalMs: _staggerMs, - ), - ), + builder: + (context, _) => CustomPaint( + size: Size(gridWidth, gridHeight), + painter: _GridPainter( + data: data, + numWeeks: numWeeks, + sq: sq, + step: step, + // Drive the per-cell wave reveal. + reveal: _reveal.value, + totalMs: _staggerMs, + ), + ), ), ), if (_bubble != null) Positioned( left: (_bubble!.x - _bubbleHalfW).clamp( 0, - (gridWidth - _bubbleHalfW * 2) - .clamp(0, double.infinity), + (gridWidth - _bubbleHalfW * 2).clamp( + 0, + double.infinity, + ), ), bottom: gridHeight - _bubble!.y + NhamSpacing.sp1, child: Container( constraints: const BoxConstraints( - maxWidth: _bubbleHalfW * 2), + maxWidth: _bubbleHalfW * 2, + ), padding: const EdgeInsets.symmetric( - vertical: NhamSpacing.sp1, horizontal: 6), + vertical: NhamSpacing.sp1, + horizontal: 6, + ), decoration: BoxDecoration( color: NhamColors.text, - borderRadius: - BorderRadius.circular(NhamRadii.sm), + borderRadius: BorderRadius.circular( + NhamRadii.sm, + ), ), child: Text( _bubble!.text, @@ -332,8 +354,9 @@ class _HeatmapBodyState extends State<_HeatmapBody> const SizedBox(width: NhamSpacing.sp2), Expanded( child: ClipRRect( - borderRadius: - BorderRadius.circular(_legendBarHeight / 2), + borderRadius: BorderRadius.circular( + _legendBarHeight / 2, + ), child: Container( height: _legendBarHeight, decoration: const BoxDecoration( @@ -378,15 +401,17 @@ class _HeatmapBodyState extends State<_HeatmapBody> final wi = (pos.dx / step).floor(); final di = (pos.dy / step).floor(); if (wi < 0 || wi >= numWeeks || di < 0 || di >= 7) return; - final cell = (di < data.cells.length && wi < data.cells[di].length) - ? data.cells[di][wi] - : null; + final cell = + (di < data.cells.length && wi < data.cells[di].length) + ? data.cells[di][wi] + : null; if (cell == null) return; final isLogged = cell.status == HeatmapCellStatus.logged && cell.ratio != null; final isPartial = cell.status == HeatmapCellStatus.partial; - final isMuted = cell.status == HeatmapCellStatus.future || + final isMuted = + cell.status == HeatmapCellStatus.future || cell.status == HeatmapCellStatus.outside; if (!((isLogged || isPartial) && !isMuted)) return; @@ -432,7 +457,8 @@ class _HeatmapBodyState extends State<_HeatmapBody> if (isLogged) { return (fill: getHeatmapColor(ratio).bg ?? kTrack, stroke: null); } - final isMuted = cell?.status == HeatmapCellStatus.future || + final isMuted = + cell?.status == HeatmapCellStatus.future || cell?.status == HeatmapCellStatus.outside; if (isMuted) { return (fill: kPage, stroke: null); // out-of-range @@ -468,9 +494,10 @@ class _GridPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final fillPaint = Paint()..style = PaintingStyle.fill; - final strokePaint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 1; + final strokePaint = + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1; final elapsed = reveal * totalMs; @@ -484,20 +511,28 @@ class _GridPainter extends CustomPainter { final scale = 0.6 + 0.4 * p; // scale 0.6 → 1 final cellAlpha = p; // opacity 0 → 1 - final cell = (data != null && - di < data!.cells.length && - wi < data!.cells[di].length) - ? data!.cells[di][wi] - : null; + final cell = + (data != null && + di < data!.cells.length && + wi < data!.cells[di].length) + ? data!.cells[di][wi] + : null; final props = _cellRectProps(cell); // Scale about the cell center. final cx = wi * step + sq / 2; final cy = di * step + sq / 2; final half = (sq * scale) / 2; - final rect = Rect.fromLTWH(cx - half, cy - half, sq * scale, sq * scale); - final rrect = - RRect.fromRectAndRadius(rect, const Radius.circular(_cellRadius)); + final rect = Rect.fromLTWH( + cx - half, + cy - half, + sq * scale, + sq * scale, + ); + final rrect = RRect.fromRectAndRadius( + rect, + const Radius.circular(_cellRadius), + ); // Solid fill; cellAlpha is only the per-cell reveal fade (→ 1). fillPaint.color = props.fill.withValues(alpha: cellAlpha); diff --git a/apps/mobile-flutter/lib/features/logging/data/logging_keys.dart b/apps/mobile-flutter/lib/features/logging/data/logging_keys.dart index 59237f30..a58a72b0 100644 --- a/apps/mobile-flutter/lib/features/logging/data/logging_keys.dart +++ b/apps/mobile-flutter/lib/features/logging/data/logging_keys.dart @@ -8,21 +8,26 @@ library; abstract final class LoggingDayKeys { static const List all = ['logging-day']; - static List byUserDate(String userId, String date) => - ['logging-day', userId, date]; + static List byUserDate(String userId, String date) => [ + 'logging-day', + userId, + date, + ]; static List byUserDateOffset( String userId, String date, int timezoneOffset, - ) => - ['logging-day', userId, date, timezoneOffset]; + ) => ['logging-day', userId, date, timezoneOffset]; } /// `['meal-dates', userId, timezoneOffset]`. Invalidations elsewhere use the /// `['meal-dates']` prefix — never exact. -List mealDatesKey(String userId, int timezoneOffset) => - ['meal-dates', userId, timezoneOffset]; +List mealDatesKey(String userId, int timezoneOffset) => [ + 'meal-dates', + userId, + timezoneOffset, +]; /// Profile cache key — mirrors RN `onboardingKeys.profile`. const List onboardingProfileKey = ['onboarding', 'profile']; @@ -43,7 +48,7 @@ String addDays(String date, int delta) { int.parse(parts[1]), int.parse(parts[2]), ); - return todayDateString(base.add(Duration(days: delta))); + return todayDateString(DateTime(base.year, base.month, base.day + delta)); } /// Local timezone offset in MINUTES, matching JS `Date.getTimezoneOffset()` diff --git a/apps/mobile-flutter/lib/features/logging/screens/logging_screen.dart b/apps/mobile-flutter/lib/features/logging/screens/logging_screen.dart index 3c3abad9..5986b0c8 100644 --- a/apps/mobile-flutter/lib/features/logging/screens/logging_screen.dart +++ b/apps/mobile-flutter/lib/features/logging/screens/logging_screen.dart @@ -27,8 +27,7 @@ class LoggingScreen extends ConsumerStatefulWidget { } class _LoggingScreenState extends ConsumerState { - final String _today = todayDateString(); - late String _selectedDate = _today; + late String _selectedDate = todayDateString(); bool _pickerExpanded = false; @override @@ -62,6 +61,8 @@ class _LoggingScreenState extends ConsumerState { carbsTargetG: (data?['carbsTargetG'] as num?)?.toInt() ?? 250, fatTargetG: (data?['fatTargetG'] as num?)?.toInt() ?? 65, ); + final today = todayDateString(); + final yesterday = addDays(today, -1); // Header in normal flow at the top; the collapse scrim overlays ONLY the // feed region below it (so the strip's cells/chevrons stay tappable while @@ -74,30 +75,26 @@ class _LoggingScreenState extends ConsumerState { child: Column( children: [ Padding( - padding: - const EdgeInsets.symmetric(horizontal: NhamSpacing.sp3), + padding: const EdgeInsets.symmetric(horizontal: NhamSpacing.sp3), child: AppHeader( expanded: _pickerExpanded, child: TimelinePicker( dates: mealDates, - today: _today, + today: today, selectedDate: _selectedDate, expanded: _pickerExpanded, - onSelectDate: (date) => - setState(() => _selectedDate = date), - onExpandedChange: (v) => - setState(() => _pickerExpanded = v), + onSelectDate: (date) => setState(() => _selectedDate = date), + onExpandedChange: (v) => setState(() => _pickerExpanded = v), ), ), ), // A once-daily nudge when *yesterday* was under-logged — only while // viewing today, and only until dismissed this session. - if (_selectedDate == _today && - !ref.watch(yesterdayPromptDismissedProvider( - addDays(_today, -1)))) + if (_selectedDate == today && + !ref.watch(yesterdayPromptDismissedProvider(yesterday))) PartialYesterdayPrompt( userId: userId, - yesterday: addDays(_today, -1), + yesterday: yesterday, calorieTarget: profile.calorieTarget, onOpenDay: (date) => setState(() => _selectedDate = date), ), @@ -111,8 +108,7 @@ class _LoggingScreenState extends ConsumerState { Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.translucent, - onTap: () => - setState(() => _pickerExpanded = false), + onTap: () => setState(() => _pickerExpanded = false), ), ), ], diff --git a/apps/mobile-flutter/lib/features/logging/widgets/count_up.dart b/apps/mobile-flutter/lib/features/logging/widgets/count_up.dart index 0dec1230..d143f552 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/count_up.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/count_up.dart @@ -38,14 +38,16 @@ class CountUpText extends StatefulWidget { class _CountUpTextState extends State with SingleTickerProviderStateMixin { - late final AnimationController _c = - AnimationController(vsync: this, duration: widget.duration); + late final AnimationController _c = AnimationController( + vsync: this, + duration: widget.duration, + ); late Animation _anim = _build(widget.from, widget.value); - Animation _build(double from, double to) => - Tween(begin: from, end: to) - .chain(CurveTween(curve: widget.curve)) - .animate(_c); + Animation _build(double from, double to) => Tween( + begin: from, + end: to, + ).chain(CurveTween(curve: widget.curve)).animate(_c); @override void initState() { @@ -62,9 +64,15 @@ class _CountUpTextState extends State super.didUpdateWidget(old); if (old.value != widget.value) { _anim = _build(_anim.value, widget.value); - _c - ..reset() - ..forward(); + if (widget.enabled) { + _c + ..reset() + ..forward(); + } else { + _c.value = 1; + } + } else if (old.enabled != widget.enabled && !widget.enabled) { + _c.value = 1; } } @@ -77,16 +85,20 @@ class _CountUpTextState extends State @override Widget build(BuildContext context) { if (!widget.enabled) { - return NhamText(widget.format(widget.value), - variant: widget.variant, style: widget.style); + return NhamText( + widget.format(widget.value), + variant: widget.variant, + style: widget.style, + ); } return AnimatedBuilder( animation: _anim, - builder: (context, _) => NhamText( - widget.format(_anim.value), - variant: widget.variant, - style: widget.style, - ), + builder: + (context, _) => NhamText( + widget.format(_anim.value), + variant: widget.variant, + style: widget.style, + ), ); } } diff --git a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart index 36e8461d..2b0bc20e 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/feed_area.dart @@ -176,41 +176,60 @@ class _FeedAreaState extends ConsumerState { ) .closed .then((_) async { - if (undone || !mounted) return; - try { - await ref.read(apiClientProvider).delete( - '/api/v1/meals/${Uri.encodeComponent(meal.id)}', + if (undone || !mounted) return; + try { + await ref + .read(apiClientProvider) + .delete('/api/v1/meals/${Uri.encodeComponent(meal.id)}'); + } catch (_) { + // The server rejected the delete — releasing the id makes the card + // reappear (the cache was never mutated), keeping the feed truthful. + ref.invalidate(loggingDayProvider(_dayArgs)); + ref.invalidate( + dash.dashboardBundleProvider(( + userId: widget.profile.userId, + date: widget.date, + )), ); - } catch (_) { - // The server rejected the delete — releasing the id makes the card - // reappear (the cache was never mutated), keeping the feed truthful. - if (mounted) { - setState(() { - _pendingRemovalIds.remove(meal.id); - _errorText = 'errors.internal'.tr(); - }); - } - return; - } - if (!mounted) return; - // The delete landed — heal every cache that carries this date before - // releasing the id, so the refetched day (sans meal) is what renders. - ref.invalidate(mealDatesProvider(widget.profile.userId)); - ref.invalidate(dash.dashboardBundleProvider( - (userId: widget.profile.userId, date: widget.date), - )); - ref.invalidate(dash.dashboardDayProvider( - (userId: widget.profile.userId, date: widget.date), - )); - try { - await ref.read(loggingDayProvider(_dayArgs).notifier).refresh(); - } catch (_) { - // The refetch failing doesn't un-delete the meal — keep the id - // filtered (a harmless no-op once a later fetch succeeds). - return; - } - if (mounted) setState(() => _pendingRemovalIds.remove(meal.id)); - }); + ref.invalidate( + dash.dashboardDayProvider(( + userId: widget.profile.userId, + date: widget.date, + )), + ); + if (mounted) { + setState(() { + _pendingRemovalIds.remove(meal.id); + _errorText = 'errors.internal'.tr(); + }); + } + return; + } + if (!mounted) return; + // The delete landed — heal every cache that carries this date before + // releasing the id, so the refetched day (sans meal) is what renders. + ref.invalidate(mealDatesProvider(widget.profile.userId)); + ref.invalidate( + dash.dashboardBundleProvider(( + userId: widget.profile.userId, + date: widget.date, + )), + ); + ref.invalidate( + dash.dashboardDayProvider(( + userId: widget.profile.userId, + date: widget.date, + )), + ); + try { + await ref.read(loggingDayProvider(_dayArgs).notifier).refresh(); + } catch (_) { + // The refetch failing doesn't un-delete the meal — keep the id + // filtered (a harmless no-op once a later fetch succeeds). + return; + } + if (mounted) setState(() => _pendingRemovalIds.remove(meal.id)); + }); } /// Pull-to-refresh: refetch the day + the meal-dates strip. Awaited so the @@ -269,20 +288,23 @@ class _FeedAreaState extends ConsumerState { // Swiped-away meals inside the undo window are filtered out here (not // removed from the cache), so totals heal immediately and a mid-window // refetch cannot resurrect the card. - final persistedMeals = (day?.persistedMeals ?? const []) - .where((m) => !_pendingRemovalIds.contains(m.id)) - .toList() - ..sort((a, b) => a.loggedAt.compareTo(b.loggedAt)); + final persistedMeals = + (day?.persistedMeals ?? const []) + .where((m) => !_pendingRemovalIds.contains(m.id)) + .toList() + ..sort((a, b) => a.loggedAt.compareTo(b.loggedAt)); final pendingConfirmations = day?.pendingConfirmations ?? const []; // Legacy meals can carry unknown macros — when any do, the daily summary // can't be totalled honestly, so we show a quiet note instead of the ring. - final hasUnknownDailyMacros = persistedMeals.any((m) => - m.nutrition.caloriesKcal == null || - m.nutrition.proteinG == null || - m.nutrition.carbohydrateG == null || - m.nutrition.fatG == null); + final hasUnknownDailyMacros = persistedMeals.any( + (m) => + m.nutrition.caloriesKcal == null || + m.nutrition.proteinG == null || + m.nutrition.carbohydrateG == null || + m.nutrition.fatG == null, + ); // Pin the live stream/reveal cards to the day they were submitted on, so // switching the selected date doesn't render them on the wrong day's feed. @@ -331,7 +353,8 @@ class _FeedAreaState extends ConsumerState { !isRevealing && !hasFailedAttempt; - final hasFooterItems = pendingConfirmations.isNotEmpty || + final hasFooterItems = + pendingConfirmations.isNotEmpty || isStreaming || isRevealing || hasFailedAttempt; @@ -340,7 +363,8 @@ class _FeedAreaState extends ConsumerState { // under-logged; the trends set it aside, so we say so (and offer to fold it // back in by adding what was missed). Only when nothing is mid-flight. final isPastDay = widget.date.compareTo(todayDateString()) < 0; - final showPartialDayNotice = isPastDay && + final showPartialDayNotice = + isPastDay && !isLoading && !dayAsync.hasError && !hasUnknownDailyMacros && @@ -386,21 +410,22 @@ class _FeedAreaState extends ConsumerState { NhamSpacing.sp3, NhamSpacing.sp2, ), - child: isLoading - ? const _MacroSummarySkeleton() - : hasUnknownDailyMacros + child: + isLoading + ? const _MacroSummarySkeleton() + : hasUnknownDailyMacros // Some legacy meals have unknown macros — the day can't be // totalled, so say so plainly instead of showing a wrong ring. ? Align( - alignment: Alignment.centerLeft, - child: NhamText( - 'logging.feedArea.legacyMacroWarning'.tr(), - variant: NhamTextVariant.small, - style: NhamTextStyles.sansMedium( - fontSize: NhamFontSize.eyebrow + 1, - ).copyWith(color: NhamColors.textMuted80), - ), - ) + alignment: Alignment.centerLeft, + child: NhamText( + 'logging.feedArea.legacyMacroWarning'.tr(), + variant: NhamTextVariant.small, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.eyebrow + 1, + ).copyWith(color: NhamColors.textMuted80), + ), + ) : Row( children: [ Column( @@ -594,43 +619,43 @@ class _FeedAreaState extends ConsumerState { onRefresh: _refresh, color: NhamColors.accent, child: ListView.separated( - controller: _scrollController, - physics: const AlwaysScrollableScrollPhysics(), - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - padding: const EdgeInsets.only( - top: NhamSpacing.sp3, - bottom: NhamSpacing.sp3, - left: NhamSpacing.sp3 + NhamSpacing.sp6, // padding + timeline gutter - right: NhamSpacing.sp3, - ), - itemCount: persistedMeals.length + (hasFooterItems ? 1 : 0), - separatorBuilder: (_, __) => const SizedBox(height: NhamSpacing.sp2), - itemBuilder: (context, index) { - if (index < persistedMeals.length) { - final meal = persistedMeals[index]; - return FadeIn( - key: ValueKey(meal.id), - child: PersistedMealCard( - meal: meal, - isLast: !hasFooterItems && index == persistedMeals.length - 1, - onRemove: () => _removeMeal(meal), - ), + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.only( + top: NhamSpacing.sp3, + bottom: NhamSpacing.sp3, + left: NhamSpacing.sp3 + NhamSpacing.sp6, // padding + timeline gutter + right: NhamSpacing.sp3, + ), + itemCount: persistedMeals.length + (hasFooterItems ? 1 : 0), + separatorBuilder: (_, __) => const SizedBox(height: NhamSpacing.sp2), + itemBuilder: (context, index) { + if (index < persistedMeals.length) { + final meal = persistedMeals[index]; + return FadeIn( + key: ValueKey(meal.id), + child: PersistedMealCard( + meal: meal, + isLast: !hasFooterItems && index == persistedMeals.length - 1, + onRemove: () => _removeMeal(meal), + ), + ); + } + return _Footer( + pendingConfirmations: pendingConfirmations, + isStreaming: isStreaming, + isRevealing: isRevealing, + stream: stream, + confirmPending: confirmPending, + onConfirm: _confirm, + onConfirmReveal: _confirmReveal, + revealRawInput: _revealRawInput, + failedText: failedText, + onRetry: onRetry, + onDiscardFailed: onDiscardFailed, ); - } - return _Footer( - pendingConfirmations: pendingConfirmations, - isStreaming: isStreaming, - isRevealing: isRevealing, - stream: stream, - confirmPending: confirmPending, - onConfirm: _confirm, - onConfirmReveal: _confirmReveal, - revealRawInput: _revealRawInput, - failedText: failedText, - onRetry: onRetry, - onDiscardFailed: onDiscardFailed, - ); - }, + }, ), ); } @@ -669,7 +694,9 @@ class _FeedAreaState extends ConsumerState { /// refetched persisted card — one continuous object from typed words to saved /// meal. On failure the stream stays so the user can retry the confirm. Future _confirmReveal( - String analysisId, List edits) async { + String analysisId, + List edits, + ) async { // Confirm against the day the analysis was submitted on (the reveal is // date-guarded to that day), so the ORIGIN date's caches are updated. final originDate = @@ -681,15 +708,16 @@ class _FeedAreaState extends ConsumerState { analysisId: analysisId, mealId: _uuid.v4(), originDate: originDate, - edits: edits.isEmpty - ? null - : [ - for (final e in edits) - { - 'mealItemOrder': e.mealItemOrder, - 'newGrams': e.newGrams, - }, - ], + edits: + edits.isEmpty + ? null + : [ + for (final e in edits) + { + 'mealItemOrder': e.mealItemOrder, + 'newGrams': e.newGrams, + }, + ], ); HapticFeedback.mediumImpact(); _revealRawInput = null; @@ -741,7 +769,8 @@ class _Footer extends StatelessWidget { rawInput: pendingConfirmations[i].rawInput, parsedMeal: pendingConfirmations[i].parsedMeal, busy: confirmPending, - isLast: !isStreaming && + isLast: + !isStreaming && !isRevealing && !hasFailed && i == pendingConfirmations.length - 1, @@ -837,9 +866,7 @@ class _FailedAttemptCard extends StatelessWidget { const SizedBox(height: NhamSpacing.sp4), Row( children: [ - Expanded( - child: _RetryButton(onTap: onRetry), - ), + Expanded(child: _RetryButton(onTap: onRetry)), const SizedBox(width: NhamSpacing.sp2), _DiscardButton(onTap: onDiscard), ], @@ -891,8 +918,9 @@ class _RetryButtonState extends State<_RetryButton> { NhamText( 'logging.failedAttempt.tryAgain'.tr(), variant: NhamTextVariant.body, - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.xs) - .copyWith(color: Colors.white), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.xs, + ).copyWith(color: Colors.white), ), ], ), @@ -934,8 +962,9 @@ class _DiscardButtonState extends State<_DiscardButton> { child: NhamText( 'logging.discard'.tr(), variant: NhamTextVariant.body, - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.xs) - .copyWith(color: NhamColors.textMuted), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.xs, + ).copyWith(color: NhamColors.textMuted), ), ), ), @@ -973,10 +1002,12 @@ class _PartialDayNotice extends StatelessWidget { ), const SizedBox(height: 4), // mt-1 NhamText( - 'logging.feedArea.partialDayNotice.body'.tr(namedArgs: { - 'calories': formatCount(calories, locale), - 'target': formatCount(target, locale), - }), + 'logging.feedArea.partialDayNotice.body'.tr( + namedArgs: { + 'calories': formatCount(calories, locale), + 'target': formatCount(target, locale), + }, + ), variant: NhamTextVariant.small, style: const TextStyle(color: NhamColors.textMuted), ), @@ -1093,9 +1124,8 @@ class _MacroBarState extends State<_MacroBar> builder: (context, _) => FractionallySizedBox( alignment: Alignment.centerLeft, - widthFactor: - ((reduceMotion ? widget.pct : _anim.value) / 100) - .clamp(0, 1), + widthFactor: ((reduceMotion ? widget.pct : _anim.value) / 100) + .clamp(0, 1), child: Container( decoration: BoxDecoration( color: widget.color, diff --git a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart index 2d2965c7..c65080b4 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/meal_entry.dart @@ -78,8 +78,7 @@ class _MealEntryState extends State { }); } - bool get _confirmDisabled => - widget.busy || (_editing && _confirmCoolingDown); + bool get _confirmDisabled => widget.busy || (_editing && _confirmCoolingDown); /// Wrap the confirm CTA in a slide-up entrance only on the reveal morph's /// opening frame (the spinner row has just slid out of the same slot). @@ -186,7 +185,8 @@ class _MealEntryState extends State { CountUpText( value: totals.calories, // Reduced motion: the reveal total lands in place. - enabled: _countUp && + enabled: + _countUp && !MediaQuery.disableAnimationsOf(context), format: (v) => fmtKcal(v), variant: NhamTextVariant.numStrong, @@ -204,9 +204,10 @@ class _MealEntryState extends State { _ConfirmButton( editing: _editing, disabled: _confirmDisabled, - onTap: _confirmDisabled - ? null - : () => widget.onConfirm( + onTap: + _confirmDisabled + ? null + : () => widget.onConfirm( deriveQuantityEdits(_items, _original), ), ), @@ -242,15 +243,20 @@ class _ItemRow extends StatelessWidget { return AnimatedContainer( duration: const Duration(milliseconds: 150), - padding: editing - ? const EdgeInsets.symmetric(vertical: 10, horizontal: 8) // py-2.5 px-2 - : const EdgeInsets.symmetric(vertical: 10), - decoration: editing - ? BoxDecoration( - color: NhamColors.surface80, // surface/80 - borderRadius: BorderRadius.circular(NhamRadii.md), // rounded-md - ) - : null, + padding: + editing + ? const EdgeInsets.symmetric( + vertical: 10, + horizontal: 8, + ) // py-2.5 px-2 + : const EdgeInsets.symmetric(vertical: 10), + decoration: + editing + ? BoxDecoration( + color: NhamColors.surface80, // surface/80 + borderRadius: BorderRadius.circular(NhamRadii.md), // rounded-md + ) + : null, child: Row( children: [ Expanded( @@ -264,9 +270,10 @@ class _ItemRow extends StatelessWidget { _Stepper( icon: LucideIcons.minus, // lucide Minus disabled: minusDisabled, - onTap: minusDisabled - ? null - : () => onChange(item.id, -step), + onTap: + minusDisabled + ? null + : () => onChange(item.id, -step), ), const SizedBox(width: 2), // gap-0.5 SizedBox( @@ -275,8 +282,9 @@ class _ItemRow extends StatelessWidget { item.quantity.round().toString(), variant: NhamTextVariant.numStrong, textAlign: TextAlign.center, - style: NhamTextStyles.sansSemiBold(fontSize: 11) - .copyWith(color: NhamColors.text), + style: NhamTextStyles.sansSemiBold( + fontSize: 11, + ).copyWith(color: NhamColors.text), ), ), const SizedBox(width: 2), @@ -294,13 +302,14 @@ class _ItemRow extends StatelessWidget { variant: NhamTextVariant.itemName, maxLines: 1, overflow: TextOverflow.ellipsis, - style: struck - ? const TextStyle( - decoration: TextDecoration.lineThrough, - decorationColor: NhamColors.textMuted, - color: NhamColors.textMuted, - ) - : null, + style: + struck + ? const TextStyle( + decoration: TextDecoration.lineThrough, + decorationColor: NhamColors.textMuted, + color: NhamColors.textMuted, + ) + : null, ), ), ], @@ -311,17 +320,29 @@ class _ItemRow extends StatelessWidget { opacity: struck ? 0.4 : 1, child: Row( children: [ - NhamText('P: ${fmtG(item.macros.protein)}', - variant: NhamTextVariant.itemMacro, maxLines: 1), + NhamText( + 'P: ${fmtG(item.macros.protein)}', + variant: NhamTextVariant.itemMacro, + maxLines: 1, + ), const SizedBox(width: NhamSpacing.sp2), - NhamText('C: ${fmtG(item.macros.carbs)}', - variant: NhamTextVariant.itemMacro, maxLines: 1), + NhamText( + 'C: ${fmtG(item.macros.carbs)}', + variant: NhamTextVariant.itemMacro, + maxLines: 1, + ), const SizedBox(width: NhamSpacing.sp2), - NhamText('F: ${fmtG(item.macros.fat)}', - variant: NhamTextVariant.itemMacro, maxLines: 1), + NhamText( + 'F: ${fmtG(item.macros.fat)}', + variant: NhamTextVariant.itemMacro, + maxLines: 1, + ), const SizedBox(width: NhamSpacing.sp3), // gap-3 - NhamText(fmtKcal(item.macros.calories), - variant: NhamTextVariant.itemCalories, maxLines: 1), + NhamText( + fmtKcal(item.macros.calories), + variant: NhamTextVariant.itemCalories, + maxLines: 1, + ), ], ), ), @@ -362,19 +383,19 @@ class _StepperState extends State<_Stepper> { height: 40, child: Center( child: Opacity( - opacity: widget.disabled ? 0.4 : 1, // opacity-40 - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), // transition-colors - width: 28, - height: 28, - alignment: Alignment.center, - decoration: BoxDecoration( - color: _pressed ? NhamColors.hover : NhamColors.elev, - borderRadius: BorderRadius.circular(NhamRadii.md), - border: Border.all(color: NhamColors.borderSoft), + opacity: widget.disabled ? 0.4 : 1, // opacity-40 + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), // transition-colors + width: 28, + height: 28, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _pressed ? NhamColors.hover : NhamColors.elev, + borderRadius: BorderRadius.circular(NhamRadii.md), + border: Border.all(color: NhamColors.borderSoft), + ), + child: Icon(widget.icon, size: 10, color: NhamColors.textMuted), ), - child: Icon(widget.icon, size: 10, color: NhamColors.textMuted), - ), ), ), ), @@ -409,7 +430,9 @@ class _EditPill extends StatelessWidget { child: Container( key: ValueKey(editing ? 'done' : 'edit'), padding: const EdgeInsets.symmetric( - vertical: 4, horizontal: 10), // py-1 px-2.5 + vertical: 4, + horizontal: 10, + ), // py-1 px-2.5 decoration: BoxDecoration( color: editing ? NhamColors.accent10 : Colors.transparent, borderRadius: BorderRadius.circular(NhamRadii.pill), @@ -421,7 +444,9 @@ class _EditPill extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon( - editing ? LucideIcons.check : LucideIcons.pencil, // Check / Pencil + editing + ? LucideIcons.check + : LucideIcons.pencil, // Check / Pencil size: 12, color: editing ? NhamColors.accent : NhamColors.textMuted, ), @@ -479,44 +504,56 @@ class _ConfirmButtonState extends State<_ConfirmButton> { } else { bg = active ? NhamColors.btnHover : NhamColors.btn; } - final BoxBorder? border = editing - ? Border.all( - color: active ? NhamColors.btn : NhamColors.btnBorderGhost, - ) - : null; - final List? shadow = editing - ? null - : [active ? NhamShadows.md : NhamShadows.sm]; - - return Opacity( - opacity: widget.disabled ? 0.5 : 1, // opacity-50 - child: GestureDetector( - onTapDown: tappable ? (_) => setState(() => _pressed = true) : null, - onTapUp: tappable ? (_) => setState(() => _pressed = false) : null, - onTapCancel: tappable ? () => setState(() => _pressed = false) : null, - onTap: widget.onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), // transition-all duration-200 - padding: - const EdgeInsets.symmetric(vertical: 10, horizontal: 12), // py-2.5 px-3 - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(NhamRadii.xl), // rounded-xl - border: border, - boxShadow: shadow, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(LucideIcons.check, size: 14, color: fg), - const SizedBox(width: 6), // gap-1.5 - NhamText( - 'logging.confirm'.tr(), - variant: NhamTextVariant.body, - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.xs) - .copyWith(color: fg), - ), - ], + final BoxBorder? border = + editing + ? Border.all( + color: active ? NhamColors.btn : NhamColors.btnBorderGhost, + ) + : null; + final List? shadow = + editing ? null : [active ? NhamShadows.md : NhamShadows.sm]; + + return Semantics( + button: true, + enabled: tappable, + excludeSemantics: true, + label: 'logging.confirm'.tr(), + onTap: widget.onTap, + child: Opacity( + opacity: widget.disabled ? 0.5 : 1, // opacity-50 + child: GestureDetector( + onTapDown: tappable ? (_) => setState(() => _pressed = true) : null, + onTapUp: tappable ? (_) => setState(() => _pressed = false) : null, + onTapCancel: tappable ? () => setState(() => _pressed = false) : null, + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration( + milliseconds: 200, + ), // transition-all duration-200 + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 12, + ), // py-2.5 px-3 + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(NhamRadii.xl), // rounded-xl + border: border, + boxShadow: shadow, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(LucideIcons.check, size: 14, color: fg), + const SizedBox(width: 6), // gap-1.5 + NhamText( + 'logging.confirm'.tr(), + variant: NhamTextVariant.body, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.xs, + ).copyWith(color: fg), + ), + ], + ), ), ), ), diff --git a/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart b/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart index 01027866..7ad2258c 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/meal_input.dart @@ -178,9 +178,11 @@ class _MealInputState extends State ), ), const SizedBox(width: NhamSpacing.sp3), - if (widget.analyzing && widget.onCancel != null) + if (!_canSubmit && widget.analyzing && widget.onCancel != null) _ActionButton( - icon: LucideIcons.square, // lucide Square (filled) → LucideIcons.square + icon: + LucideIcons + .square, // lucide Square (filled) → LucideIcons.square iconSize: 14, label: 'common.cancel'.tr(), onTap: widget.onCancel, diff --git a/apps/mobile-flutter/lib/features/logging/widgets/partial_yesterday_prompt.dart b/apps/mobile-flutter/lib/features/logging/widgets/partial_yesterday_prompt.dart index ad43a41c..7883c14d 100644 --- a/apps/mobile-flutter/lib/features/logging/widgets/partial_yesterday_prompt.dart +++ b/apps/mobile-flutter/lib/features/logging/widgets/partial_yesterday_prompt.dart @@ -37,20 +37,25 @@ class PartialYesterdayPrompt extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final dayAsync = - ref.watch(loggingDayProvider(LoggingDayArgs(userId, yesterday))); - final meals = dayAsync.valueOrNull?.persistedMeals ?? const []; + final dismissed = ref.watch(yesterdayPromptDismissedProvider(yesterday)); + final dayAsync = ref.watch( + loggingDayProvider(LoggingDayArgs(userId, yesterday)), + ); + final meals = + dayAsync.valueOrNull?.persistedMeals ?? const []; final hasMeals = meals.isNotEmpty; // A meal with unknown calories makes the day's total untrustworthy, so the // partial-day check would be misleading — suppress the prompt then. - final hasUnknownCalories = - meals.any((m) => m.nutrition.caloriesKcal == null); + final hasUnknownCalories = meals.any( + (m) => m.nutrition.caloriesKcal == null, + ); final calories = round0( meals.fold(0, (s, m) => s + (m.nutrition.caloriesKcal ?? 0)), ); - if (!hasMeals || + if (dismissed || + !hasMeals || hasUnknownCalories || !isLikelyPartialDay(calories.toDouble(), calorieTarget)) { return const SizedBox.shrink(); @@ -87,10 +92,12 @@ class PartialYesterdayPrompt extends ConsumerWidget { ), const SizedBox(height: 4), // mt-1 NhamText( - '$t.body'.tr(namedArgs: { - 'calories': formatCount(calories, locale), - 'target': formatCount(calorieTarget, locale), - }), + '$t.body'.tr( + namedArgs: { + 'calories': formatCount(calories, locale), + 'target': formatCount(calorieTarget, locale), + }, + ), variant: NhamTextVariant.small, style: const TextStyle(color: NhamColors.textMuted), ), @@ -105,9 +112,15 @@ class PartialYesterdayPrompt extends ConsumerWidget { const SizedBox(width: NhamSpacing.sp3), _DismissButton( label: '$t.dismiss'.tr(), - onTap: () => ref - .read(yesterdayPromptDismissedProvider(yesterday).notifier) - .state = true, + onTap: + () => + ref + .read( + yesterdayPromptDismissedProvider( + yesterday, + ).notifier, + ) + .state = true, ), ], ), @@ -157,13 +170,18 @@ class _OpenButtonState extends State<_OpenButton> { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(LucideIcons.arrowLeft, size: 16, color: NhamColors.text), + const Icon( + LucideIcons.arrowLeft, + size: 16, + color: NhamColors.text, + ), const SizedBox(width: 8), // gap-2 NhamText( widget.label, variant: NhamTextVariant.body, - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) - .copyWith(color: NhamColors.text), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), ), ], ), diff --git a/apps/mobile-flutter/lib/features/nutrition/providers/nutrition_overview_provider.dart b/apps/mobile-flutter/lib/features/nutrition/providers/nutrition_overview_provider.dart index 4f845498..ca19464f 100644 --- a/apps/mobile-flutter/lib/features/nutrition/providers/nutrition_overview_provider.dart +++ b/apps/mobile-flutter/lib/features/nutrition/providers/nutrition_overview_provider.dart @@ -11,6 +11,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../data/api_client.dart'; import '../../../data/query.dart'; +import '../../../data/session_provider.dart'; import '../../../models/nutrition.dart'; /// Raw `getTimezoneOffset()` parity: JS returns minutes POSITIVE west of UTC. @@ -24,28 +25,34 @@ int nutritionTimezoneOffset() => -DateTime.now().timeZoneOffset.inMinutes; /// the new provider instance keeps the previously-resolved overview as its /// initial value so the layout doesn't collapse to a skeleton mid-refetch. final nutritionOverviewProvider = AsyncNotifierProvider.family< - NutritionOverviewNotifier, NutritionOverview, NutritionRangeInput>( - NutritionOverviewNotifier.new, -); + NutritionOverviewNotifier, + NutritionOverview, + NutritionRangeInput +>(NutritionOverviewNotifier.new); /// Holds the last successful overview across the family so a range switch can -/// seed the next instance (the RN `keepPreviousData` behavior). Riverpod -/// rebuilds a fresh notifier per family key, so we stash the previous value -/// here at the Provider scope. -NutritionOverview? _lastOverview; +/// seed the next instance (the RN `keepPreviousData` behavior). It is keyed by +/// user + timezone so an account switch can never seed another user's nutrition +/// pattern into the placeholder state. +final Map _lastOverviewByAccount = {}; + +String _overviewCacheKey(String? userId) => + '${userId ?? 'signed-out'}:${nutritionTimezoneOffset()}'; class NutritionOverviewNotifier extends FamilyAsyncNotifier { @override Future build(NutritionRangeInput arg) async { - final previous = _lastOverview; + final userId = ref.watch(currentSessionProvider)?.user.id; + final cacheKey = _overviewCacheKey(userId); + final previous = _lastOverviewByAccount[cacheKey]; if (previous != null) { // Seed with the prior overview so consumers keep rendering the editorial // stack while the new range loads (placeholderData: keepPreviousData). state = AsyncData(previous); } final overview = await _fetch(arg); - _lastOverview = overview; + _lastOverviewByAccount[cacheKey] = overview; return overview; } @@ -71,17 +78,21 @@ class NutritionOverviewNotifier final range = arg; final previous = state; // Show the in-flight (isLoading) state over the current value. - state = const AsyncValue.loading() - .copyWithPrevious(previous); + state = const AsyncValue.loading().copyWithPrevious( + previous, + ); try { final overview = await _fetch(range); - _lastOverview = overview; + final userId = ref.read(currentSessionProvider)?.user.id; + _lastOverviewByAccount[_overviewCacheKey(userId)] = overview; state = AsyncData(overview); return true; } catch (error, stack) { // Retain the previous data under the error. - state = AsyncError(error, stack) - .copyWithPrevious(previous); + state = AsyncError( + error, + stack, + ).copyWithPrevious(previous); return false; } } diff --git a/apps/mobile-flutter/lib/features/nutrition/screens/nutrient_detail_screen.dart b/apps/mobile-flutter/lib/features/nutrition/screens/nutrient_detail_screen.dart index d45e6923..98455083 100644 --- a/apps/mobile-flutter/lib/features/nutrition/screens/nutrient_detail_screen.dart +++ b/apps/mobile-flutter/lib/features/nutrition/screens/nutrient_detail_screen.dart @@ -32,15 +32,19 @@ class NutrientDetailScreen extends ConsumerWidget { final label = tr(card.labelKey); final hasTarget = card.percentOfTarget != null; final percent = card.percentOfTarget ?? 0; - final showExceed = shouldShowExceed(card.nutrientType, card.percentOfTarget); + final showExceed = shouldShowExceed( + card.nutrientType, + card.percentOfTarget, + ); final limited = card.displayState == ConfidenceDisplayState.limitedData || - card.displayState == ConfidenceDisplayState.insufficientData; + card.displayState == ConfidenceDisplayState.insufficientData; // The point figure: today's resolved average, the single trustworthy number. - final figure = card.averagePerDay == null - ? '—' - : formatLocalizedNumber(card.averagePerDay!, locale); + final figure = + card.averagePerDay == null + ? '—' + : formatLocalizedNumber(card.averagePerDay!, locale); return CupertinoPageScaffold( backgroundColor: kPage, @@ -126,8 +130,10 @@ class NutrientDetailScreen extends ConsumerWidget { // the screen. if (_coveragePoints(card, limited).isNotEmpty) ...[ const SizedBox(height: 24), - Text(tr('nutrition.detail.averageVsTarget').toUpperCase(), - style: dashEyebrow()), + Text( + tr('nutrition.detail.averageVsTarget').toUpperCase(), + style: dashEyebrow(), + ), const SizedBox(height: 12), Container( padding: const EdgeInsets.all(20), @@ -146,8 +152,10 @@ class NutrientDetailScreen extends ConsumerWidget { // ── Band 3: food candidates as full rows ────────────────── if (card.supportsCandidates) ...[ const SizedBox(height: 24), - Text(tr('nutrition.candidates.title').toUpperCase(), - style: dashEyebrow()), + Text( + tr('nutrition.candidates.title').toUpperCase(), + style: dashEyebrow(), + ), const SizedBox(height: 6), Text( tr('nutrition.candidates.description'), @@ -187,15 +195,17 @@ class _FoodCandidates extends ConsumerWidget { final async = ref.watch(foodCandidatesProvider(nutrient)); return async.when( - loading: () => Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text(tr('nutrition.candidates.loading'), style: dashMeta()), - ), - error: (_, __) => _ErrorLine( - message: tr('nutrition.candidates.error'), - retryLabel: tr('nutrition.candidates.retry'), - onRetry: () => ref.invalidate(foodCandidatesProvider(nutrient)), - ), + loading: + () => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text(tr('nutrition.candidates.loading'), style: dashMeta()), + ), + error: + (_, __) => _ErrorLine( + message: tr('nutrition.candidates.error'), + retryLabel: tr('nutrition.candidates.retry'), + onRetry: () => ref.invalidate(foodCandidatesProvider(nutrient)), + ), data: (response) { final candidates = response?.candidates ?? const []; if (candidates.isEmpty) { @@ -232,14 +242,22 @@ class _ErrorLine extends StatelessWidget { Widget build(BuildContext context) { return Row( children: [ - Expanded( - child: Text(message, style: dashMeta(color: kInkSecondary)), - ), + Expanded(child: Text(message, style: dashMeta(color: kInkSecondary))), const SizedBox(width: 12), - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onRetry, - child: Text(retryLabel, style: dashMeta(color: kInk)), + Semantics( + button: true, + label: retryLabel, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onRetry, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 44, minHeight: 44), + child: Align( + alignment: Alignment.center, + child: Text(retryLabel, style: dashMeta(color: kInk)), + ), + ), + ), ), ], ); diff --git a/apps/mobile-flutter/lib/features/nutrition/screens/nutrition_screen.dart b/apps/mobile-flutter/lib/features/nutrition/screens/nutrition_screen.dart index 7e145231..545e57d4 100644 --- a/apps/mobile-flutter/lib/features/nutrition/screens/nutrition_screen.dart +++ b/apps/mobile-flutter/lib/features/nutrition/screens/nutrition_screen.dart @@ -11,20 +11,17 @@ import '../../../theme/nham_colors.dart'; import '../providers/nutrition_overview_provider.dart'; import '../widgets/background_section.dart'; import '../widgets/daily_rhythm.dart'; -import '../widgets/editorial_header.dart'; import '../widgets/empty_state.dart'; import '../widgets/focus_section.dart'; import '../widgets/inline_error.dart'; import '../widgets/nutrition_skeleton.dart'; import '../widgets/pull_quote.dart'; import '../widgets/steady_section.dart'; -import '../widgets/verdict_hero.dart'; /// Nutrition screen — mobile port of the web `NutritionShell` /// (`apps/mobile/src/app/(app)/(tabs)/nutrition.tsx`). A purely client-query -/// editorial stack: header (with interactive 7d/30d/90d range toggle + verdict) -/// → calorie rhythm card → focus spotlights → steady nutrient list → "other -/// nutrients" toggle → vitamin-D pull-quote. +/// editorial stack: calorie rhythm card with range toggle → focus spotlights → +/// steady nutrient list → "other nutrients" toggle → vitamin-D pull-quote. /// /// Single column throughout (the web's lg:two-up + sm:2-col grids collapse on /// phone); the AnimatePresence height-collapse becomes an `AnimatedSize` fade. @@ -92,9 +89,11 @@ class _NutritionScreenState extends ConsumerState { // Native bounce physics (the only screen that was forced to // Clamping) + pull-to-refresh, consistent with the rest of the app. child: RefreshIndicator( - onRefresh: () => ref - .read(nutritionOverviewProvider(_range).notifier) - .refetch(), + onRefresh: + () => + ref + .read(nutritionOverviewProvider(_range).notifier) + .refetch(), color: NhamColors.accent, child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 24, 20, 80), @@ -123,9 +122,7 @@ class _NutritionScreenState extends ConsumerState { message: tr('nutrition.errors.overview'), retryLabel: tr('nutrition.errors.retry'), onRetry: () { - ref - .read(nutritionOverviewProvider(_range).notifier) - .refetch(); + ref.read(nutritionOverviewProvider(_range).notifier).refetch(); }, ); } @@ -137,32 +134,24 @@ class _NutritionScreenState extends ConsumerState { message: tr('nutrition.errors.overview'), retryLabel: tr('nutrition.errors.retry'), onRetry: () { - ref - .read(nutritionOverviewProvider(_range).notifier) - .refetch(); + ref.read(nutritionOverviewProvider(_range).notifier).refetch(); }, ); } - final isEmpty = overview.loggedDays == 0; - return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - EditorialHeader( - resolvedRange: overview.resolvedRange, - onRangeChange: (range) => setState(() => _range = range), - startDate: overview.period.startDate, - endDate: overview.period.endDate, - disabled: isFetching, - verdict: isEmpty ? null : VerdictHero(overview: overview), - ), - if (isEmpty) ...[ + if (overview.loggedDays == 0) ...[ const SizedBox(height: 48), const EmptyState(), ] else ...[ - const SizedBox(height: 48), - DailyRhythm(macros: overview.macros), + DailyRhythm( + macros: overview.macros, + resolvedRange: overview.resolvedRange, + onRangeChange: (range) => setState(() => _range = range), + disabled: isFetching, + ), const SizedBox(height: 48), FocusSection(cards: overview.spotlight), const SizedBox(height: 48), diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/background_section.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/background_section.dart index 6ce9765b..bf25874c 100644 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/background_section.dart +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/background_section.dart @@ -66,16 +66,20 @@ class _BackgroundSectionState extends State children: [ Text( tr('nutrition.background.eyebrow'), - style: NhamTextStyles.sansMedium(fontSize: 14) - .copyWith(color: NhamColors.textMuted), + style: NhamTextStyles.sansMedium( + fontSize: 14, + ).copyWith(color: NhamColors.textMuted), ), const Spacer(), const SizedBox(width: 16), _ToggleButton( - label: _open - ? tr('nutrition.background.hide') - : tr('nutrition.background.show', - namedArgs: {'count': cards.length.toString()}), + label: + _open + ? tr('nutrition.background.hide') + : tr( + 'nutrition.background.show', + namedArgs: {'count': cards.length.toString()}, + ), onTap: _toggle, ), ], @@ -94,8 +98,9 @@ class _BackgroundSectionState extends State children: [ Text( tr('nutrition.background.hint'), - style: NhamTextStyles.sansRegular(fontSize: 11) - .copyWith(color: NhamColors.textMuted), + style: NhamTextStyles.sansRegular( + fontSize: 11, + ).copyWith(color: NhamColors.textMuted), ), const SizedBox(height: 8), ClipRRect( @@ -142,19 +147,29 @@ class _ToggleButtonState extends State<_ToggleButton> { @override Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - onTap: widget.onTap, - child: Text( - widget.label, - style: NhamTextStyles.sansMedium(fontSize: 12).copyWith( - color: NhamColors.text, - decoration: _pressed ? TextDecoration.underline : null, - decorationColor: NhamColors.text, - decorationThickness: 1, + return Semantics( + button: true, + label: widget.label, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: widget.onTap, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 44, minHeight: 44), + child: Align( + alignment: Alignment.centerRight, + child: Text( + widget.label, + style: NhamTextStyles.sansMedium(fontSize: 12).copyWith( + color: NhamColors.text, + decoration: _pressed ? TextDecoration.underline : null, + decorationColor: NhamColors.text, + decorationThickness: 1, + ), + ), + ), ), ), ); diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/daily_rhythm.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/daily_rhythm.dart index 38abf41b..456005c2 100644 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/daily_rhythm.dart +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/daily_rhythm.dart @@ -9,12 +9,22 @@ import '../../../theme/nham_typography.dart'; import '../logic/helpers.dart'; import '../logic/rhythm_logic.dart'; import 'fade_in_down.dart'; +import 'range_selector.dart'; /// RN port of `apps/mobile/src/components/nutrition/sections/daily-rhythm.tsx`. class DailyRhythm extends StatelessWidget { - const DailyRhythm({super.key, required this.macros}); + const DailyRhythm({ + super.key, + required this.macros, + required this.resolvedRange, + required this.onRangeChange, + this.disabled = false, + }); final List macros; + final String resolvedRange; + final ValueChanged onRangeChange; + final bool disabled; @override Widget build(BuildContext context) { @@ -39,6 +49,15 @@ class DailyRhythm extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Align( + alignment: Alignment.topCenter, + child: NutritionRangeSelector( + resolvedRange: resolvedRange, + onRangeChange: onRangeChange, + disabled: disabled, + ), + ), + const SizedBox(height: 20), // Calorie hero. Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -47,23 +66,30 @@ class DailyRhythm extends StatelessWidget { TextSpan( children: [ TextSpan( - text: calories != null - ? formatLocalizedNumber( - calories.averagePerDay, locale) - : '—', - style: - NhamTextStyles.serifMedium(fontSize: 36).copyWith( + text: + calories != null + ? formatLocalizedNumber( + calories.averagePerDay, + locale, + ) + : '—', + style: NhamTextStyles.serifMedium( + fontSize: 36, + ).copyWith( height: 40 / 36, letterSpacing: -0.7, color: NhamColors.text, - fontFeatures: const [FontFeature.tabularFigures()], + fontFeatures: const [ + FontFeature.tabularFigures(), + ], ), ), const WidgetSpan(child: SizedBox(width: 8)), TextSpan( text: tr('nutrition.rhythm.calories'), - style: NhamTextStyles.sansRegular(fontSize: 16) - .copyWith(color: NhamColors.textMuted), + style: NhamTextStyles.sansRegular( + fontSize: 16, + ).copyWith(color: NhamColors.textMuted), ), ], ), @@ -104,11 +130,12 @@ class DailyRhythm extends StatelessWidget { const SizedBox(width: 6), Text( '${kCompositionShort[segment.key]} ${segment.pct.round()}%', - style: NhamTextStyles.sansRegular(fontSize: 11) - .copyWith( + style: NhamTextStyles.sansRegular( + fontSize: 11, + ).copyWith( color: NhamColors.textMuted, fontFeatures: const [ - FontFeature.tabularFigures() + FontFeature.tabularFigures(), ], ), ), @@ -132,8 +159,10 @@ class DailyRhythm extends StatelessWidget { consistencyLabel: tr( 'nutrition.${consistencyLabelKey(macroRows[i].consistencyPct)}', ), - barAriaLabel: tr('nutrition.rhythm.barAria', - namedArgs: {'macro': tr(macroRows[i].labelKey)}), + barAriaLabel: tr( + 'nutrition.rhythm.barAria', + namedArgs: {'macro': tr(macroRows[i].labelKey)}, + ), ), ], ], @@ -163,10 +192,7 @@ class _DashedTopDivider extends StatelessWidget { height: 1, child: CustomPaint(painter: _DashedLinePainter()), ), - Padding( - padding: const EdgeInsets.only(top: 20), - child: child, - ), + Padding(padding: const EdgeInsets.only(top: 20), child: child), ], ); } @@ -177,9 +203,10 @@ class _DashedLinePainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = NhamColors.borderBiscotti40 - ..strokeWidth = 1; + final paint = + Paint() + ..color = NhamColors.borderBiscotti40 + ..strokeWidth = 1; const dash = 4.0; const gap = 3.0; var x = 0.0; @@ -247,14 +274,15 @@ class _CompositionPillState extends State<_CompositionPill> color: NhamColors.track, child: AnimatedBuilder( animation: _scale, - builder: (context, child) => Align( - alignment: Alignment.centerLeft, - child: Transform.scale( - scaleX: _scale.value, - alignment: Alignment.centerLeft, - child: child, - ), - ), + builder: + (context, child) => Align( + alignment: Alignment.centerLeft, + child: Transform.scale( + scaleX: _scale.value, + alignment: Alignment.centerLeft, + child: child, + ), + ), // Proportional flex so the segments always fill the bar exactly — // fixed pixel widths summed to ~0.8% over (float rounding) and // overflowed the Row by a couple px. @@ -292,15 +320,17 @@ class _MacroRow extends StatelessWidget { @override Widget build(BuildContext context) { - final ratio = macro.target != null && macro.target! > 0 - ? macro.averagePerDay / macro.target! - : null; + final ratio = + macro.target != null && macro.target! > 0 + ? macro.averagePerDay / macro.target! + : null; final pctOfTarget = ratio == null ? null : ratio * 100; final showExceed = shouldShowExceed(macro.nutrientType, pctOfTarget); String figure; if (macro.target == null || macro.target! <= 0) { - figure = '${formatLocalizedNumber(macro.averagePerDay, locale)} ${macro.unit}'; + figure = + '${formatLocalizedNumber(macro.averagePerDay, locale)} ${macro.unit}'; } else { final pct = (macro.averagePerDay / macro.target! * 100).round(); figure = showExceed && pct > 100 ? '+${pct - 100}%' : '$pct%'; @@ -315,8 +345,9 @@ class _MacroRow extends StatelessWidget { label, maxLines: 1, overflow: TextOverflow.ellipsis, - style: NhamTextStyles.sansMedium(fontSize: 14) - .copyWith(color: NhamColors.text), + style: NhamTextStyles.sansMedium( + fontSize: 14, + ).copyWith(color: NhamColors.text), ), ), const SizedBox(width: 12), @@ -340,25 +371,34 @@ class _MacroRow extends StatelessWidget { ), const SizedBox(width: 12), SizedBox( - width: 88, - child: Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text( - consistencyLabel, - style: NhamTextStyles.sansRegular(fontSize: 12) - .copyWith(color: NhamColors.text), - ), - const SizedBox(width: 6), - Text( - figure, - style: NhamTextStyles.sansRegular(fontSize: 11).copyWith( - color: showExceed ? NhamColors.danger : NhamColors.textMuted, - fontFeatures: const [FontFeature.tabularFigures()], - ), + width: 96, + child: Align( + alignment: Alignment.centerRight, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + consistencyLabel, + style: NhamTextStyles.sansRegular( + fontSize: 12, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(width: 6), + Text( + figure, + style: NhamTextStyles.sansRegular(fontSize: 11).copyWith( + color: + showExceed ? NhamColors.danger : NhamColors.textMuted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], ), - ], + ), ), ), ], diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/editorial_header.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/editorial_header.dart index 79a23219..424da700 100644 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/editorial_header.dart +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/editorial_header.dart @@ -1,52 +1,36 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import '../../../models/nutrition.dart'; -import '../../../shared/widgets/section_eyebrow.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_typography.dart'; import '../logic/format_date.dart'; import 'fade_in_down.dart'; -const List _ranges = ['7d', '30d', '90d']; - /// RN port of `apps/mobile/src/components/nutrition/sections/editorial-header.tsx`. class EditorialHeader extends StatelessWidget { const EditorialHeader({ super.key, - required this.resolvedRange, - required this.onRangeChange, required this.startDate, required this.endDate, - this.disabled = false, this.verdict, }); - /// Raw '7d' | '30d' | '90d'. - final String resolvedRange; - final ValueChanged onRangeChange; final String startDate; final String endDate; - final bool disabled; /// The verdict line — rendered full-width below the title row on phone. final Widget? verdict; - NutritionRangeInput _inputFor(String range) => switch (range) { - '7d' => NutritionRangeInput.d7, - '30d' => NutritionRangeInput.d30, - '90d' => NutritionRangeInput.d90, - _ => NutritionRangeInput.auto, - }; - @override Widget build(BuildContext context) { final locale = context.locale.languageCode; - final dateRange = tr('nutrition.editorial.dateRange', namedArgs: { - 'start': formatDate(startDate, locale), - 'end': formatDate(endDate, locale), - }); + final dateRange = tr( + 'nutrition.editorial.dateRange', + namedArgs: { + 'start': formatDate(startDate, locale), + 'end': formatDate(endDate, locale), + }, + ); return Container( decoration: const BoxDecoration( @@ -56,64 +40,15 @@ class EditorialHeader extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Title block: web opacity/y:6 duration 0.5 (no delay). - Flexible( - child: FadeInDown( - offset: 6, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionEyebrow( - label: tr('nutrition.editorial.eyebrow'), - trailing: tr('nutrition.range.$resolvedRange'), - ), - const SizedBox(height: 8), - Text( - dateRange, - style: NhamTextStyles.sansRegular(fontSize: 14).copyWith( - color: NhamColors.textMuted, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ], - ), - ), + FadeInDown( + offset: 6, + child: Text( + dateRange, + style: NhamTextStyles.sansRegular(fontSize: 14).copyWith( + color: NhamColors.textMuted, + fontFeatures: const [FontFeature.tabularFigures()], ), - const SizedBox(width: 16), - // Range toggle: web opacity/y:6 duration 0.5 delay 0.05. - FadeInDown( - offset: 6, - delay: const Duration(milliseconds: 50), - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(9999), - border: Border.all(color: NhamColors.borderSoft), - color: NhamColors.cardWhite40, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (var i = 0; i < _ranges.length; i++) ...[ - if (i > 0) const SizedBox(width: 1), - _RangePill( - label: tr('nutrition.range.${_ranges[i]}'), - active: resolvedRange == _ranges[i], - disabled: disabled, - onTap: () { - HapticFeedback.selectionClick(); - onRangeChange(_inputFor(_ranges[i])); - }, - ), - ], - ], - ), - ), - ), - ], + ), ), if (verdict != null) ...[ const SizedBox(height: 12), @@ -129,68 +64,3 @@ class EditorialHeader extends StatelessWidget { ); } } - -class _RangePill extends StatefulWidget { - const _RangePill({ - required this.label, - required this.active, - required this.disabled, - required this.onTap, - }); - - final String label; - final bool active; - final bool disabled; - final VoidCallback onTap; - - @override - State<_RangePill> createState() => _RangePillState(); -} - -class _RangePillState extends State<_RangePill> { - bool _pressed = false; - - @override - Widget build(BuildContext context) { - // Inactive pills: web `text-nham-text-muted hover:text-nham-text - // transition-colors` — the touch affordance shifts the text toward - // nham-text on press-down. - final textColor = widget.active - ? NhamColors.surface - : _pressed - ? NhamColors.text - : NhamColors.textMuted; - - final pill = AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(9999), - color: widget.active ? NhamColors.text : null, - ), - child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 150), - style: NhamTextStyles.sansMedium(fontSize: 12).copyWith( - color: textColor, - fontFeatures: const [FontFeature.tabularFigures()], - ), - child: Text(widget.label), - ), - ); - - return Opacity( - opacity: widget.disabled ? 0.6 : 1, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: - widget.disabled ? null : (_) => setState(() => _pressed = true), - onTapUp: - widget.disabled ? null : (_) => setState(() => _pressed = false), - onTapCancel: - widget.disabled ? null : () => setState(() => _pressed = false), - onTap: widget.disabled ? null : widget.onTap, - child: pill, - ), - ); - } -} diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/inline_error.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/inline_error.dart index 016b4343..350ebd7a 100644 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/inline_error.dart +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/inline_error.dart @@ -42,37 +42,49 @@ class _InlineErrorState extends State { const SizedBox(height: 12), Align( alignment: Alignment.centerLeft, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: widget.isRetrying - ? null - : (_) => setState(() => _pressed = true), - onTapUp: widget.isRetrying - ? null - : (_) => setState(() => _pressed = false), - onTapCancel: widget.isRetrying - ? null - : () => setState(() => _pressed = false), + child: Semantics( + button: true, + enabled: !widget.isRetrying, + excludeSemantics: true, + label: widget.retryLabel, onTap: widget.isRetrying ? null : widget.onRetry, - child: Opacity( - opacity: widget.isRetrying ? 0.5 : 1, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: NhamColors.borderSoft), - color: (_pressed && !widget.isRetrying) - ? NhamColors.hover - : null, - ), - child: NhamText( - widget.retryLabel, - variant: NhamTextVariant.small, - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) - .copyWith(color: NhamColors.text), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: + widget.isRetrying + ? null + : (_) => setState(() => _pressed = true), + onTapUp: + widget.isRetrying + ? null + : (_) => setState(() => _pressed = false), + onTapCancel: + widget.isRetrying + ? null + : () => setState(() => _pressed = false), + onTap: widget.isRetrying ? null : widget.onRetry, + child: Opacity( + opacity: widget.isRetrying ? 0.5 : 1, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: NhamColors.borderSoft), + color: + (_pressed && !widget.isRetrying) + ? NhamColors.hover + : null, + ), + child: NhamText( + widget.retryLabel, + variant: NhamTextVariant.small, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), + ), ), ), ), diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_row.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_row.dart index a346e20c..c4bde838 100644 --- a/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_row.dart +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/nutrient_row.dart @@ -42,13 +42,17 @@ class _NutrientRowState extends State { final label = tr(card.labelKey); final isLimited = card.displayState == ConfidenceDisplayState.limitedData || - card.displayState == ConfidenceDisplayState.insufficientData; + card.displayState == ConfidenceDisplayState.insufficientData; final hasNoTarget = card.percentOfTarget == null; - final dotColor = isLimited || hasNoTarget - ? NhamColors.stone - : kStatusColors[statusKeyFor(card)]!; + final dotColor = + isLimited || hasNoTarget + ? NhamColors.stone + : kStatusColors[statusKeyFor(card)]!; - final showExceed = shouldShowExceed(card.nutrientType, card.percentOfTarget); + final showExceed = shouldShowExceed( + card.nutrientType, + card.percentOfTarget, + ); String figure; if (card.displayState == ConfidenceDisplayState.insufficientData) { @@ -58,67 +62,79 @@ class _NutrientRowState extends State { } else if (showExceed && card.percentOfTarget! > 100) { figure = '+${(card.percentOfTarget! - 100).round()}%'; } else { - figure = tr('nutrition.steady.percent', - namedArgs: {'value': card.percentOfTarget!.round().toString()}); + figure = tr( + 'nutrition.steady.percent', + namedArgs: {'value': card.percentOfTarget!.round().toString()}, + ); } - final figureColor = showExceed - ? NhamColors.danger - : isLimited + final figureColor = + showExceed + ? NhamColors.danger + : isLimited ? NhamColors.textMuted : NhamColors.text; return DecoratedBox( decoration: const BoxDecoration( - border: Border( - bottom: BorderSide(color: NhamColors.borderBiscotti40), - ), + border: Border(bottom: BorderSide(color: NhamColors.borderBiscotti40)), ), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - onTap: _open, - child: ColoredBox( - color: _pressed ? NhamColors.hover40 : Colors.transparent, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: dotColor, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NhamTextStyles.sansRegular(fontSize: 14) - .copyWith(color: NhamColors.text), - ), - ), - const SizedBox(width: 12), - Text( - figure, - style: NhamTextStyles.sansRegular(fontSize: 12).copyWith( - color: figureColor, - fontFeatures: const [FontFeature.tabularFigures()], - ), + child: Semantics( + button: true, + label: label, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: _open, + child: ColoredBox( + color: _pressed ? NhamColors.hover40 : Colors.transparent, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 44), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, ), - const SizedBox(width: 12), - const Icon( - LucideIcons.chevronRight, - size: 16, - color: NhamColors.stone, + child: Row( + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NhamTextStyles.sansRegular( + fontSize: 14, + ).copyWith(color: NhamColors.text), + ), + ), + const SizedBox(width: 12), + Text( + figure, + style: NhamTextStyles.sansRegular(fontSize: 12).copyWith( + color: figureColor, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const SizedBox(width: 12), + const Icon( + LucideIcons.chevronRight, + size: 16, + color: NhamColors.stone, + ), + ], ), - ], + ), ), ), ), diff --git a/apps/mobile-flutter/lib/features/nutrition/widgets/range_selector.dart b/apps/mobile-flutter/lib/features/nutrition/widgets/range_selector.dart new file mode 100644 index 00000000..3c7407ed --- /dev/null +++ b/apps/mobile-flutter/lib/features/nutrition/widgets/range_selector.dart @@ -0,0 +1,129 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../models/nutrition.dart'; +import '../../../theme/nham_colors.dart'; +import '../../../theme/nham_typography.dart'; + +const List _ranges = ['7d', '30d', '90d']; + +class NutritionRangeSelector extends StatelessWidget { + const NutritionRangeSelector({ + super.key, + required this.resolvedRange, + required this.onRangeChange, + this.disabled = false, + }); + + final String resolvedRange; + final ValueChanged onRangeChange; + final bool disabled; + + NutritionRangeInput _inputFor(String range) => switch (range) { + '7d' => NutritionRangeInput.d7, + '30d' => NutritionRangeInput.d30, + '90d' => NutritionRangeInput.d90, + _ => NutritionRangeInput.auto, + }; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9999), + border: Border.all(color: NhamColors.borderSoft), + color: NhamColors.cardWhite40, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < _ranges.length; i++) ...[ + if (i > 0) const SizedBox(width: 1), + _RangePill( + label: tr('nutrition.range.${_ranges[i]}'), + active: resolvedRange == _ranges[i], + disabled: disabled, + onTap: () { + HapticFeedback.selectionClick(); + onRangeChange(_inputFor(_ranges[i])); + }, + ), + ], + ], + ), + ); + } +} + +class _RangePill extends StatefulWidget { + const _RangePill({ + required this.label, + required this.active, + required this.disabled, + required this.onTap, + }); + + final String label; + final bool active; + final bool disabled; + final VoidCallback onTap; + + @override + State<_RangePill> createState() => _RangePillState(); +} + +class _RangePillState extends State<_RangePill> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final textColor = + widget.active + ? NhamColors.surface + : _pressed + ? NhamColors.text + : NhamColors.textMuted; + + final pill = AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9999), + color: widget.active ? NhamColors.text : null, + ), + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 150), + style: NhamTextStyles.sansMedium(fontSize: 12).copyWith( + color: textColor, + fontFeatures: const [FontFeature.tabularFigures()], + ), + child: Text(widget.label), + ), + ); + + return Semantics( + button: true, + enabled: !widget.disabled, + selected: widget.active, + excludeSemantics: true, + label: widget.label, + onTap: widget.disabled ? null : widget.onTap, + child: Opacity( + opacity: widget.disabled ? 0.6 : 1, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: + widget.disabled ? null : (_) => setState(() => _pressed = true), + onTapUp: + widget.disabled ? null : (_) => setState(() => _pressed = false), + onTapCancel: + widget.disabled ? null : () => setState(() => _pressed = false), + onTap: widget.disabled ? null : widget.onTap, + child: SizedBox(height: 44, child: Center(child: pill)), + ), + ), + ); + } +} diff --git a/apps/mobile-flutter/lib/features/onboarding/screens/screen_body_metrics.dart b/apps/mobile-flutter/lib/features/onboarding/screens/screen_body_metrics.dart index d0d778cb..1b5a61e2 100644 --- a/apps/mobile-flutter/lib/features/onboarding/screens/screen_body_metrics.dart +++ b/apps/mobile-flutter/lib/features/onboarding/screens/screen_body_metrics.dart @@ -6,6 +6,7 @@ import '../../../shared/widgets/decimal_input.dart'; import '../../../theme/nham_colors.dart'; import '../../../theme/nham_theme.dart'; import '../../../theme/nham_typography.dart'; +import '../../dashboard/logic/dashboard_format.dart' show formatCount; import '../logic/tdee.dart'; import '../widgets/aggression_slider.dart'; import '../widgets/custom_select.dart'; @@ -48,21 +49,21 @@ class ScreenTwoValues { }); Map toJson() => { - 'biologicalSex': biologicalSex, - 'weightKg': weightKg, - 'heightCm': heightCm, - 'age': age, - 'activityLevel': activityLevel, - 'goal': goal, - 'aggression': aggression, - 'carbSplit': carbSplit, - 'deficitOverride': deficitOverride, - 'tdeeKcal': tdeeKcal, - 'calorieTarget': calorieTarget, - 'proteinTargetG': proteinTargetG, - 'carbsTargetG': carbsTargetG, - 'fatTargetG': fatTargetG, - }; + 'biologicalSex': biologicalSex, + 'weightKg': weightKg, + 'heightCm': heightCm, + 'age': age, + 'activityLevel': activityLevel, + 'goal': goal, + 'aggression': aggression, + 'carbSplit': carbSplit, + 'deficitOverride': deficitOverride, + 'tdeeKcal': tdeeKcal, + 'calorieTarget': calorieTarget, + 'proteinTargetG': proteinTargetG, + 'carbsTargetG': carbsTargetG, + 'fatTargetG': fatTargetG, + }; } /// Partial seed for step 2 (from a saved profile / re-entered wizard state). @@ -129,13 +130,15 @@ class _ScreenBodyMetricsState extends State { int? get _tdee { if (!_allMetricsFilled) return null; - final bmr = calcBMR(BodyMetrics( - biologicalSex: BiologicalSex.values.byName(_sex!), - weightKg: _weight!, - heightCm: _height!, - age: _age!, - activityLevel: activityLevelFromString(_activity), - )); + final bmr = calcBMR( + BodyMetrics( + biologicalSex: BiologicalSex.values.byName(_sex!), + weightKg: _weight!, + heightCm: _height!, + age: _age!, + activityLevel: activityLevelFromString(_activity), + ), + ); return calcTDEE(bmr, activityLevelFromString(_activity)); } @@ -193,22 +196,24 @@ class _ScreenBodyMetricsState extends State { final targets = _finalTargets; if (tdee == null || targets == null) return; if (!_validate()) return; - widget.onChange(ScreenTwoValues( - biologicalSex: _sex!, - weightKg: _weight!, - heightCm: _height!, - age: _age!, - activityLevel: _activity, - goal: _goal, - aggression: _aggression, - carbSplit: _carbSplit, - deficitOverride: widget.defaultValues.deficitOverride, - tdeeKcal: tdee, - calorieTarget: targets.calories.round(), - proteinTargetG: targets.proteinG.round(), - carbsTargetG: targets.carbsG.round(), - fatTargetG: targets.fatG.round(), - )); + widget.onChange( + ScreenTwoValues( + biologicalSex: _sex!, + weightKg: _weight!, + heightCm: _height!, + age: _age!, + activityLevel: _activity, + goal: _goal, + aggression: _aggression, + carbSplit: _carbSplit, + deficitOverride: widget.defaultValues.deficitOverride, + tdeeKcal: tdee, + calorieTarget: targets.calories.round(), + proteinTargetG: targets.proteinG.round(), + carbsTargetG: targets.carbsG.round(), + fatTargetG: targets.fatG.round(), + ), + ); } @override @@ -220,17 +225,19 @@ class _ScreenBodyMetricsState extends State { Text( tr('onboarding.bodyMetrics.title'), // tracking-tight: -0.025em × 24px ≈ -0.6 - style: NhamTextStyles.serifMedium(fontSize: 24) - .copyWith(letterSpacing: -0.6, color: NhamColors.text), + style: NhamTextStyles.serifMedium( + fontSize: 24, + ).copyWith(letterSpacing: -0.6, color: NhamColors.text), ), const SizedBox(height: NhamSpacing.sp1), Text( tr('onboarding.bodyMetrics.subtitle'), - style: NhamTextStyles.sansRegular(fontSize: 14, height: 22 / 14) - .copyWith(color: NhamColors.textHelp), + style: NhamTextStyles.sansRegular( + fontSize: 14, + height: 22 / 14, + ).copyWith(color: NhamColors.textHelp), ), const SizedBox(height: NhamSpacing.sp5), // space-y-5 - // Metrics card. Container( padding: const EdgeInsets.all(NhamSpacing.sp5), @@ -245,14 +252,17 @@ class _ScreenBodyMetricsState extends State { // "About you" header block (mb-4 before the grid). Text( tr('onboarding.bodyMetrics.aboutYou').toUpperCase(), - style: NhamTextStyles.sansBold(fontSize: 11) - .copyWith(letterSpacing: 1.5, color: NhamColors.stone), + style: NhamTextStyles.sansBold( + fontSize: 11, + ).copyWith(letterSpacing: 1.5, color: NhamColors.stone), ), const SizedBox(height: NhamSpacing.sp1), // mt-1 Text( tr('onboarding.bodyMetrics.aboutYouHint'), - style: NhamTextStyles.sansRegular(fontSize: 13, height: 1.625) - .copyWith(color: NhamColors.textHelp), + style: NhamTextStyles.sansRegular( + fontSize: 13, + height: 1.625, + ).copyWith(color: NhamColors.textHelp), ), const SizedBox(height: NhamSpacing.sp4), // mb-4 _buildGrid(), @@ -260,12 +270,8 @@ class _ScreenBodyMetricsState extends State { ), ), const SizedBox(height: NhamSpacing.sp5), // space-y-5 - // Goal card (when TDEE known) or the dashed unlock placeholder. - if (tdee != null) - _buildGoalCard(tdee) - else - _buildUnlockPlaceholder(), + if (tdee != null) _buildGoalCard(tdee) else _buildUnlockPlaceholder(), ], ); } @@ -278,14 +284,17 @@ class _ScreenBodyMetricsState extends State { children: [ Text( tr('onboarding.bodyMetrics.unlockTitle'), - style: NhamTextStyles.sansMedium(fontSize: 14) - .copyWith(color: NhamColors.text), + style: NhamTextStyles.sansMedium( + fontSize: 14, + ).copyWith(color: NhamColors.text), ), const SizedBox(height: NhamSpacing.sp1), // mt-1 Text( tr('onboarding.bodyMetrics.unlockHint'), - style: NhamTextStyles.sansRegular(fontSize: 13, height: 1.625) - .copyWith(color: NhamColors.textHelp), + style: NhamTextStyles.sansRegular( + fontSize: 13, + height: 1.625, + ).copyWith(color: NhamColors.textHelp), ), ], ), @@ -304,9 +313,13 @@ class _ScreenBodyMetricsState extends State { value: _sex ?? '', options: [ OptionStripItem( - label: tr('onboarding.bodyMetrics.male'), value: 'male'), + label: tr('onboarding.bodyMetrics.male'), + value: 'male', + ), OptionStripItem( - label: tr('onboarding.bodyMetrics.female'), value: 'female'), + label: tr('onboarding.bodyMetrics.female'), + value: 'female', + ), ], onChange: (v) { setState(() => _sex = v); @@ -387,15 +400,21 @@ class _ScreenBodyMetricsState extends State { value: _activity, options: [ CustomSelectOption( - label: tr('onboarding.bodyMetrics.sedentary'), - value: 'sedentary'), + label: tr('onboarding.bodyMetrics.sedentary'), + value: 'sedentary', + ), CustomSelectOption( - label: tr('onboarding.bodyMetrics.light'), value: 'light'), + label: tr('onboarding.bodyMetrics.light'), + value: 'light', + ), CustomSelectOption( - label: tr('onboarding.bodyMetrics.moderate'), value: 'moderate'), + label: tr('onboarding.bodyMetrics.moderate'), + value: 'moderate', + ), CustomSelectOption( - label: tr('onboarding.bodyMetrics.veryActive'), - value: 'very_active'), + label: tr('onboarding.bodyMetrics.veryActive'), + value: 'very_active', + ), ], onChange: (v) { setState(() => _activity = v); @@ -411,19 +430,24 @@ class _ScreenBodyMetricsState extends State { // surface them live alongside the report so the hero stays in sync). setState(() { String tv(String k) => tr('validation.bodyMetrics.$k'); - _weightError = _weight == null - ? null - : (_weight! < 30 - ? tv('weightMin') - : (_weight! > 300 ? tv('weightMax') : null)); - _heightError = _height == null - ? null - : (_height! < 100 - ? tv('heightMin') - : (_height! > 250 ? tv('heightMax') : null)); - _ageError = _age == null - ? null - : (_age! < 13 ? tv('ageMin') : (_age! > 100 ? tv('ageMax') : null)); + _weightError = + _weight == null + ? null + : (_weight! < 30 + ? tv('weightMin') + : (_weight! > 300 ? tv('weightMax') : null)); + _heightError = + _height == null + ? null + : (_height! < 100 + ? tv('heightMin') + : (_height! > 250 ? tv('heightMax') : null)); + _ageError = + _age == null + ? null + : (_age! < 13 + ? tv('ageMin') + : (_age! > 100 ? tv('ageMax') : null)); }); _report(); } @@ -526,31 +550,26 @@ class _DailyTargetCard extends StatelessWidget { final String goal; final MacroTargets? macros; - String _fmt(num n) { - final s = n.round().toString(); - final buf = StringBuffer(); - for (var i = 0; i < s.length; i++) { - if (i > 0 && (s.length - i) % 3 == 0) buf.write(','); - buf.write(s[i]); - } - return buf.toString(); - } - @override Widget build(BuildContext context) { - final caption = StringBuffer() - ..write(tr('onboarding.bodyMetrics.basedOnTdee')) - ..write(' ~${_fmt(tdee)} ${tr('onboarding.bodyMetrics.kcal')}'); + final locale = context.locale.toString(); + final caption = + StringBuffer() + ..write(tr('onboarding.bodyMetrics.basedOnTdee')) + ..write( + ' ~${formatCount(tdee, locale)} ${tr('onboarding.bodyMetrics.kcal')}', + ); if (goal == 'maintaining') { caption.write(' · ${tr('onboarding.bodyMetrics.maintenance')}'); } else { final sign = goal == 'cutting' ? '−' : '+'; final delta = (tdee - calorieTarget).abs(); - final kind = goal == 'cutting' - ? tr('onboarding.bodyMetrics.aggressionDeficit') - : tr('onboarding.bodyMetrics.aggressionSurplus'); + final kind = + goal == 'cutting' + ? tr('onboarding.bodyMetrics.aggressionDeficit') + : tr('onboarding.bodyMetrics.aggressionSurplus'); caption.write( - ' · $sign${_fmt(delta)} ${tr('onboarding.bodyMetrics.perDay')} $kind', + ' · $sign${formatCount(delta.round(), locale)} ${tr('onboarding.bodyMetrics.perDay')} $kind', ); } @@ -568,30 +587,35 @@ class _DailyTargetCard extends StatelessWidget { Text( tr('onboarding.bodyMetrics.calorieTarget').toUpperCase(), textAlign: TextAlign.center, - style: NhamTextStyles.sansBold(fontSize: 11) - .copyWith(color: NhamColors.stone, letterSpacing: 1.5), + style: NhamTextStyles.sansBold( + fontSize: 11, + ).copyWith(color: NhamColors.stone, letterSpacing: 1.5), ), const SizedBox(height: NhamSpacing.sp2), Text.rich( TextSpan( children: [ - TextSpan(text: _fmt(calorieTarget)), + TextSpan(text: formatCount(calorieTarget.round(), locale)), TextSpan( text: ' ${tr('onboarding.bodyMetrics.kcal')}', - style: NhamTextStyles.sansRegular(fontSize: 18) - .copyWith(color: NhamColors.textHelp), + style: NhamTextStyles.sansRegular( + fontSize: 18, + ).copyWith(color: NhamColors.textHelp), ), ], ), - style: NhamTextStyles.serifRegular(fontSize: 36) - .copyWith(color: NhamColors.text), + style: NhamTextStyles.serifRegular( + fontSize: 36, + ).copyWith(color: NhamColors.text), ), const SizedBox(height: NhamSpacing.sp2), Text( caption.toString(), textAlign: TextAlign.center, - style: NhamTextStyles.sansRegular(fontSize: 12, height: 16 / 12) - .copyWith(color: NhamColors.textHelp), + style: NhamTextStyles.sansRegular( + fontSize: 12, + height: 16 / 12, + ).copyWith(color: NhamColors.textHelp), ), if (m != null) ...[ const SizedBox(height: NhamSpacing.sp4), @@ -614,14 +638,16 @@ class _DailyTargetCard extends StatelessWidget { children: [ Text( label.toUpperCase(), - style: NhamTextStyles.sansMedium(fontSize: 10) - .copyWith(color: NhamColors.textMuted, letterSpacing: 0.5), + style: NhamTextStyles.sansMedium( + fontSize: 10, + ).copyWith(color: NhamColors.textMuted, letterSpacing: 0.5), ), const SizedBox(height: 2), Text( '${grams.round()}${tr('onboarding.bodyMetrics.grams')}', - style: NhamTextStyles.sansSemiBold(fontSize: 15) - .copyWith(color: NhamColors.text), + style: NhamTextStyles.sansSemiBold( + fontSize: 15, + ).copyWith(color: NhamColors.text), ), ], ); @@ -633,18 +659,9 @@ class _TdeeHero extends StatelessWidget { const _TdeeHero({required this.tdee}); final int tdee; - String _fmt(num n) { - final s = n.round().toString(); - final buf = StringBuffer(); - for (var i = 0; i < s.length; i++) { - if (i > 0 && (s.length - i) % 3 == 0) buf.write(','); - buf.write(s[i]); - } - return buf.toString(); - } - @override Widget build(BuildContext context) { + final locale = context.locale.toString(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -653,17 +670,19 @@ class _TdeeHero extends StatelessWidget { Text.rich( TextSpan( children: [ - TextSpan(text: '~${_fmt(tdee)} '), + TextSpan(text: '~${formatCount(tdee, locale)} '), TextSpan( text: tr('onboarding.bodyMetrics.kcal'), - style: NhamTextStyles.sansRegular(fontSize: 18) - .copyWith(color: NhamColors.textHelp), + style: NhamTextStyles.sansRegular( + fontSize: 18, + ).copyWith(color: NhamColors.textHelp), ), ], ), // text-4xl (36px) tracking-tighter - style: NhamTextStyles.serifRegular(fontSize: 36) - .copyWith(letterSpacing: -1, color: NhamColors.text), + style: NhamTextStyles.serifRegular( + fontSize: 36, + ).copyWith(letterSpacing: -1, color: NhamColors.text), ), ], ); @@ -675,8 +694,7 @@ class _Divider extends StatelessWidget { final Color color; @override - Widget build(BuildContext context) => - Container(height: 1, color: color); + Widget build(BuildContext context) => Container(height: 1, color: color); } /// A dashed (dotted) 1px border panel: rounded-[28px], bg #FFFCF8, p-5. @@ -687,10 +705,7 @@ class DottedBorderBox extends StatelessWidget { @override Widget build(BuildContext context) { return CustomPaint( - painter: _DashedBorderPainter( - color: NhamColors.inputBorder, - radius: 28, - ), + painter: _DashedBorderPainter(color: NhamColors.inputBorder, radius: 28), child: Container( padding: const EdgeInsets.all(NhamSpacing.sp5), decoration: BoxDecoration( @@ -710,10 +725,11 @@ class _DashedBorderPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color - ..strokeWidth = 1 - ..style = PaintingStyle.stroke; + final paint = + Paint() + ..color = color + ..strokeWidth = 1 + ..style = PaintingStyle.stroke; final rrect = RRect.fromRectAndRadius( Offset.zero & size, Radius.circular(radius), @@ -746,8 +762,9 @@ class _FieldLabel extends StatelessWidget { Widget build(BuildContext context) { return Text( text.toUpperCase(), - style: NhamTextStyles.sansBold(fontSize: 11) - .copyWith(letterSpacing: 1.5, color: NhamColors.stone), + style: NhamTextStyles.sansBold( + fontSize: 11, + ).copyWith(letterSpacing: 1.5, color: NhamColors.stone), ); } } @@ -771,8 +788,9 @@ class _Cell extends StatelessWidget { const SizedBox(height: 6), Text( error!, - style: NhamTextStyles.sansRegular(fontSize: 12) - .copyWith(color: NhamColors.danger), + style: NhamTextStyles.sansRegular( + fontSize: 12, + ).copyWith(color: NhamColors.danger), ), ], ], @@ -838,9 +856,10 @@ class _GoalButtonState extends State<_GoalButton> { @override Widget build(BuildContext context) { // active bg-white #2C2416 shadow-sm; inactive #8B8682 + hover:text-#2C2416. - final color = widget.active - ? NhamColors.text - : (_pressed ? NhamColors.text : NhamColors.textHelp); + final color = + widget.active + ? NhamColors.text + : (_pressed ? NhamColors.text : NhamColors.textHelp); return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (_) => setState(() => _pressed = true), @@ -855,13 +874,14 @@ class _GoalButtonState extends State<_GoalButton> { vertical: NhamSpacing.sp1_5, // py-1.5 horizontal: NhamSpacing.sp3, // px-3 ), - decoration: widget.active - ? BoxDecoration( - color: NhamColors.elev, - borderRadius: BorderRadius.circular(NhamRadii.md), - boxShadow: const [NhamShadows.sm], // shadow-sm - ) - : const BoxDecoration(), + decoration: + widget.active + ? BoxDecoration( + color: NhamColors.elev, + borderRadius: BorderRadius.circular(NhamRadii.md), + boxShadow: const [NhamShadows.sm], // shadow-sm + ) + : const BoxDecoration(), child: Text( widget.label, style: NhamTextStyles.sansMedium(fontSize: 14).copyWith(color: color), @@ -892,21 +912,23 @@ class _CarbCardState extends State<_CarbCard> { bool _pressed = false; String get _label => switch (widget.id) { - 'moderate_carb' => tr('onboarding.bodyMetrics.moderateCarb'), - 'lower_carb' => tr('onboarding.bodyMetrics.lowerCarb'), - _ => tr('onboarding.bodyMetrics.higherCarb'), - }; + 'moderate_carb' => tr('onboarding.bodyMetrics.moderateCarb'), + 'lower_carb' => tr('onboarding.bodyMetrics.lowerCarb'), + _ => tr('onboarding.bodyMetrics.higherCarb'), + }; String get _desc => switch (widget.id) { - 'moderate_carb' => tr('onboarding.bodyMetrics.moderateCarbDescription'), - 'lower_carb' => tr('onboarding.bodyMetrics.lowerCarbDescription'), - _ => tr('onboarding.bodyMetrics.higherCarbDescription'), - }; + 'moderate_carb' => tr('onboarding.bodyMetrics.moderateCarbDescription'), + 'lower_carb' => tr('onboarding.bodyMetrics.lowerCarbDescription'), + _ => tr('onboarding.bodyMetrics.higherCarbDescription'), + }; @override Widget build(BuildContext context) { - final macros = - calcMacroGrams(widget.targetCalories, carbSplitFromString(widget.id)); + final macros = calcMacroGrams( + widget.targetCalories, + carbSplitFromString(widget.id), + ); final active = widget.active; final grams = tr('onboarding.bodyMetrics.grams'); final rows = <(String, num)>[ @@ -915,9 +937,10 @@ class _CarbCardState extends State<_CarbCard> { (tr('onboarding.bodyMetrics.carbs'), macros.carbsG.round()), ]; - final borderColor = active - ? NhamColors.accent - : (_pressed ? NhamColors.accent50 : NhamColors.inputBorder); + final borderColor = + active + ? NhamColors.accent + : (_pressed ? NhamColors.accent50 : NhamColors.inputBorder); return GestureDetector( behavior: HitTestBehavior.opaque, @@ -931,25 +954,28 @@ class _CarbCardState extends State<_CarbCard> { color: active ? NhamColors.selectedCard : NhamColors.elev, // #FFF8EF borderRadius: BorderRadius.circular(NhamRadii.xxxl), // rounded-[22px] border: Border.all(color: borderColor), - boxShadow: active - ? const [ - // shadow-[0_10px_24px_rgba(201,168,124,0.14)] - BoxShadow( - color: Color(0x24C9A87C), - blurRadius: 24, - offset: Offset(0, 10), - ), - ] - : null, + boxShadow: + active + ? const [ + // shadow-[0_10px_24px_rgba(201,168,124,0.14)] + BoxShadow( + color: Color(0x24C9A87C), + blurRadius: 24, + offset: Offset(0, 10), + ), + ] + : null, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Header band (px-3.5 py-2.5). Container( - color: active - ? NhamColors.selectedSegment // #FBF2E6 - : NhamColors.track, // #F5F4F0 + color: + active + ? NhamColors + .selectedSegment // #FBF2E6 + : NhamColors.track, // #F5F4F0 padding: const EdgeInsets.symmetric( horizontal: NhamSpacing.sp3_5, // px-3.5 vertical: NhamSpacing.sp2_5, // py-2.5 @@ -959,16 +985,19 @@ class _CarbCardState extends State<_CarbCard> { children: [ Text( _label, - style: NhamTextStyles.sansMedium(fontSize: 13) - .copyWith(color: NhamColors.text), + style: NhamTextStyles.sansMedium( + fontSize: 13, + ).copyWith(color: NhamColors.text), ), const SizedBox(height: 2), // mt-0.5 Text( _desc, style: NhamTextStyles.sansRegular(fontSize: 10).copyWith( - color: active - ? NhamColors.textSelected // #6F6556 - : NhamColors.textHelp, // #8B8682 + color: + active + ? NhamColors + .textSelected // #6F6556 + : NhamColors.textHelp, // #8B8682 ), ), ], @@ -989,16 +1018,18 @@ class _CarbCardState extends State<_CarbCard> { children: [ Text( rows[i].$1.toUpperCase(), - style: NhamTextStyles.sansRegular(fontSize: 10) - .copyWith( + style: NhamTextStyles.sansRegular( + fontSize: 10, + ).copyWith( letterSpacing: 0.4, // tracking-wide color: NhamColors.textSelected, // #6F6556 ), ), Text( '${rows[i].$2}$grams', - style: NhamTextStyles.sansSemiBold(fontSize: 12) - .copyWith(color: NhamColors.text), + style: NhamTextStyles.sansSemiBold( + fontSize: 12, + ).copyWith(color: NhamColors.text), ), ], ), diff --git a/apps/mobile-flutter/lib/features/onboarding/widgets/language_toggle.dart b/apps/mobile-flutter/lib/features/onboarding/widgets/language_toggle.dart index 28e2d9d9..9e90422c 100644 --- a/apps/mobile-flutter/lib/features/onboarding/widgets/language_toggle.dart +++ b/apps/mobile-flutter/lib/features/onboarding/widgets/language_toggle.dart @@ -70,59 +70,73 @@ class _LangButtonState extends State<_LangButton> { @override Widget build(BuildContext context) { // Unselected: hover:border-[#C9A87C]/50 (border lightens toward accent). - final borderColor = widget.selected - ? NhamColors.accent - : (_pressed ? NhamColors.accent50 : NhamColors.inputBorder); - return GestureDetector( - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), + final borderColor = + widget.selected + ? NhamColors.accent + : (_pressed ? NhamColors.accent50 : NhamColors.inputBorder); + return Semantics( + button: true, + selected: widget.selected, + excludeSemantics: true, + label: widget.label, onTap: widget.onTap, - child: AnimatedContainer( - // transition-colors ~150ms ease. - duration: const Duration(milliseconds: 150), - curve: const Cubic(0.25, 0.1, 0.25, 1), - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp4, - vertical: NhamSpacing.sp3, - ), - decoration: BoxDecoration( - color: widget.selected ? NhamColors.accent10 : NhamColors.cream, - borderRadius: BorderRadius.circular(NhamRadii.containerLg), - border: Border.all(color: borderColor), - ), - child: Row( - children: [ - // h-5 w-7 rounded flag on web → Lora language-code monogram disc. - Container( - width: 28, - height: 20, - alignment: Alignment.center, - decoration: BoxDecoration( - color: NhamColors.accent10, - borderRadius: BorderRadius.circular(NhamRadii.sm), - ), - child: Text( - widget.mono, - style: NhamTextStyles.serifRegular(fontSize: 11) - .copyWith(color: NhamColors.text, letterSpacing: 0.5), - ), - ), - const SizedBox(width: NhamSpacing.sp3), - Expanded( - child: Text( - widget.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NhamTextStyles.sansMedium(fontSize: 14) - .copyWith(color: NhamColors.text), + child: GestureDetector( + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: widget.onTap, + child: AnimatedContainer( + // transition-colors ~150ms ease. + duration: const Duration(milliseconds: 150), + curve: const Cubic(0.25, 0.1, 0.25, 1), + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp4, + vertical: NhamSpacing.sp3, + ), + decoration: BoxDecoration( + color: widget.selected ? NhamColors.accent10 : NhamColors.cream, + borderRadius: BorderRadius.circular(NhamRadii.containerLg), + border: Border.all(color: borderColor), + ), + child: Row( + children: [ + // h-5 w-7 rounded flag on web → Lora language-code monogram disc. + Container( + width: 28, + height: 20, + alignment: Alignment.center, + decoration: BoxDecoration( + color: NhamColors.accent10, + borderRadius: BorderRadius.circular(NhamRadii.sm), + ), + child: Text( + widget.mono, + style: NhamTextStyles.serifRegular( + fontSize: 11, + ).copyWith(color: NhamColors.text, letterSpacing: 0.5), + ), ), - ), - if (widget.selected) ...[ const SizedBox(width: NhamSpacing.sp3), - const Icon(LucideIcons.check, size: 16, color: NhamColors.accent), + Expanded( + child: Text( + widget.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NhamTextStyles.sansMedium( + fontSize: 14, + ).copyWith(color: NhamColors.text), + ), + ), + if (widget.selected) ...[ + const SizedBox(width: NhamSpacing.sp3), + const Icon( + LucideIcons.check, + size: 16, + color: NhamColors.accent, + ), + ], ], - ], + ), ), ), ); diff --git a/apps/mobile-flutter/lib/features/settings/controls/country_select.dart b/apps/mobile-flutter/lib/features/settings/controls/country_select.dart index 61a4e245..f33569f0 100644 --- a/apps/mobile-flutter/lib/features/settings/controls/country_select.dart +++ b/apps/mobile-flutter/lib/features/settings/controls/country_select.dart @@ -50,16 +50,17 @@ class _CountrySelectState extends State { final box = _triggerKey.currentContext?.findRenderObject() as RenderBox?; final width = box?.size.width ?? 0; _entry = OverlayEntry( - builder: (_) => _CountryDropdown( - link: _link, - width: width, - selectedValue: widget.value, - onPick: (v) { - widget.onChange(v); - _close(); - }, - onDismiss: _close, - ), + builder: + (_) => _CountryDropdown( + link: _link, + width: width, + selectedValue: widget.value, + onPick: (v) { + widget.onChange(v); + _close(); + }, + onDismiss: _close, + ), ); Overlay.of(context).insert(_entry!); setState(() => _open = true); @@ -78,84 +79,105 @@ class _CountrySelectState extends State { @override Widget build(BuildContext context) { final selected = - kCountries.where((c) => c.value == widget.value).map((c) => c.vi).firstOrNull; + kCountries + .where((c) => c.value == widget.value) + .map((c) => c.vi) + .firstOrNull; final hasValue = widget.value != null; + final triggerLabel = + hasValue + ? (selected != null ? '${widget.value} ($selected)' : widget.value!) + : tr('onboarding.origin.selectCountry'); - final borderColor = _open - ? NhamColors.accent - : (_pressed ? NhamColors.accent50 : NhamColors.inputBorder); + final borderColor = + _open + ? NhamColors.accent + : (_pressed ? NhamColors.accent50 : NhamColors.inputBorder); - return CompositedTransformTarget( - link: _link, - child: Stack( - children: [ - GestureDetector( - onTap: _toggle, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - child: Container( - key: _triggerKey, - // py-2.5 pl-4 pr-(10|4) - padding: EdgeInsets.fromLTRB( - NhamSpacing.sp4, - 10, - hasValue ? 40 : NhamSpacing.sp4, - 10, - ), - decoration: BoxDecoration( - color: _open ? NhamColors.elev : NhamColors.cream, - borderRadius: BorderRadius.circular(NhamRadii.buttonXl), - border: Border.all(color: borderColor), - ), - child: Text( - hasValue - ? (selected != null - ? '${widget.value} ($selected)' - : widget.value!) - : tr('onboarding.origin.selectCountry'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: - NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm).copyWith( - color: hasValue ? NhamColors.text : NhamColors.textWarm, + return Semantics( + button: true, + excludeSemantics: true, + label: triggerLabel, + onTap: _toggle, + child: CompositedTransformTarget( + link: _link, + child: Stack( + children: [ + GestureDetector( + onTap: _toggle, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: Container( + key: _triggerKey, + width: double.infinity, + // py-2.5 pl-4 pr-(10|4) + padding: EdgeInsets.fromLTRB( + NhamSpacing.sp4, + 10, + hasValue ? 40 : NhamSpacing.sp4, + 10, + ), + decoration: BoxDecoration( + color: _open ? NhamColors.elev : NhamColors.cream, + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + border: Border.all(color: borderColor), + ), + child: Text( + triggerLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith( + color: hasValue ? NhamColors.text : NhamColors.textWarm, + ), ), ), ), - ), - if (hasValue) - // Clear button overlay: right 10px, vertically centered, rounded-md - // p-1, '×' glyph, color textWarm → text on press. - Positioned( - right: 10, - top: 0, - bottom: 0, - child: Center( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => widget.onChange(null), - onTapDown: (_) => setState(() => _clearPressed = true), - onTapUp: (_) => setState(() => _clearPressed = false), - onTapCancel: () => setState(() => _clearPressed = false), - child: Container( - padding: const EdgeInsets.all(NhamSpacing.sp1), // p-1 - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(NhamRadii.md), - ), - child: Text( - '×', // × - style: NhamTextStyles.sansRegular(fontSize: 16).copyWith( - height: 1, - color: _clearPressed - ? NhamColors.text - : NhamColors.textWarm, + if (hasValue) + // Clear button overlay: right 10px, vertically centered, rounded-md + // p-1, '×' glyph, color textWarm → text on press. + Positioned( + right: 10, + top: 0, + bottom: 0, + child: Center( + child: Semantics( + button: true, + excludeSemantics: true, + label: tr('common.remove'), + onTap: () => widget.onChange(null), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => widget.onChange(null), + onTapDown: (_) => setState(() => _clearPressed = true), + onTapUp: (_) => setState(() => _clearPressed = false), + onTapCancel: () => setState(() => _clearPressed = false), + child: Container( + padding: const EdgeInsets.all(NhamSpacing.sp1), // p-1 + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(NhamRadii.md), + ), + child: Text( + '×', // × + style: NhamTextStyles.sansRegular( + fontSize: 16, + ).copyWith( + height: 1, + color: + _clearPressed + ? NhamColors.text + : NhamColors.textWarm, + ), + ), ), ), ), ), ), - ), - ], + ], + ), ), ); } @@ -192,13 +214,14 @@ class _CountryDropdownState extends State<_CountryDropdown> { @override Widget build(BuildContext context) { - final filtered = _query.isEmpty - ? kCountries - : kCountries.where((c) { - final q = _query.toLowerCase(); - return c.value.toLowerCase().contains(q) || - c.vi.toLowerCase().contains(q); - }).toList(); + final filtered = + _query.isEmpty + ? kCountries + : kCountries.where((c) { + final q = _query.toLowerCase(); + return c.value.toLowerCase().contains(q) || + c.vi.toLowerCase().contains(q); + }).toList(); return Stack( children: [ @@ -246,24 +269,25 @@ class _CountryDropdownState extends State<_CountryDropdown> { // Search header — p-2, border-b Container( padding: const EdgeInsets.all(NhamSpacing.sp2), - decoration: const Border( - bottom: BorderSide(color: NhamColors.inputBorder), - ).toBoxDecoration(), + decoration: + const Border( + bottom: BorderSide(color: NhamColors.inputBorder), + ).toBoxDecoration(), child: TextField( controller: _search, autofocus: true, onChanged: (v) => setState(() => _query = v), style: NhamTextStyles.sansRegular( - fontSize: NhamFontSize.detail) - .copyWith(color: NhamColors.text), + fontSize: NhamFontSize.detail, + ).copyWith(color: NhamColors.text), decoration: InputDecoration( isDense: true, filled: true, fillColor: NhamColors.track, hintText: tr('onboarding.origin.searchCountry'), hintStyle: NhamTextStyles.sansRegular( - fontSize: NhamFontSize.detail) - .copyWith(color: NhamColors.textWarm), + fontSize: NhamFontSize.detail, + ).copyWith(color: NhamColors.textWarm), contentPadding: const EdgeInsets.symmetric( horizontal: NhamSpacing.sp3, vertical: NhamSpacing.sp2, @@ -277,36 +301,39 @@ class _CountryDropdownState extends State<_CountryDropdown> { // List — max-h-48 (192px), p-1 ConstrainedBox( constraints: const BoxConstraints(maxHeight: 192), - child: filtered.isEmpty - ? Padding( - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: NhamSpacing.sp2, - ), - child: Text( - tr('onboarding.origin.noCountries'), - textAlign: TextAlign.center, - style: NhamTextStyles.sansRegular( - fontSize: NhamFontSize.detail) - .copyWith(color: NhamColors.textWarm), + child: + filtered.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: NhamSpacing.sp2, + ), + child: Text( + tr('onboarding.origin.noCountries'), + textAlign: TextAlign.center, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.detail, + ).copyWith(color: NhamColors.textWarm), + ), + ) + : ListView.builder( + padding: const EdgeInsets.all( + NhamSpacing.sp1, + ), + shrinkWrap: true, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + itemCount: filtered.length, + itemBuilder: (_, i) { + final c = filtered[i]; + return _CountryRow( + label: c.value, + vi: c.vi, + selected: widget.selectedValue == c.value, + onTap: () => widget.onPick(c.value), + ); + }, ), - ) - : ListView.builder( - padding: const EdgeInsets.all(NhamSpacing.sp1), - shrinkWrap: true, - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - itemCount: filtered.length, - itemBuilder: (_, i) { - final c = filtered[i]; - return _CountryRow( - label: c.value, - vi: c.vi, - selected: widget.selectedValue == c.value, - onTap: () => widget.onPick(c.value), - ); - }, - ), ), ], ), @@ -320,9 +347,9 @@ class _CountryDropdownState extends State<_CountryDropdown> { } OutlineInputBorder _searchBorder() => OutlineInputBorder( - borderRadius: BorderRadius.circular(NhamRadii.lg), // rounded-lg = 10 - borderSide: BorderSide.none, - ); + borderRadius: BorderRadius.circular(NhamRadii.lg), // rounded-lg = 10 + borderSide: BorderSide.none, + ); } class _CountryRow extends StatefulWidget { @@ -347,44 +374,57 @@ class _CountryRowState extends State<_CountryRow> { @override Widget build(BuildContext context) { - final Color? bg = widget.selected - ? NhamColors.accent10 - : (_pressed ? NhamColors.track : null); + final Color? bg = + widget.selected + ? NhamColors.accent10 + : (_pressed ? NhamColors.track : null); - return GestureDetector( + return Semantics( + button: true, + selected: widget.selected, + excludeSemantics: true, + label: '${widget.label}, ${widget.vi}', onTap: widget.onTap, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(NhamRadii.lg), // rounded-lg = 10 - ), - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: NhamSpacing.sp2, - ), - child: Row( - children: [ - Expanded( - child: Text( - widget.label, - style: (widget.selected - ? NhamTextStyles.sansMedium( - fontSize: NhamFontSize.detail) - : NhamTextStyles.sansRegular( - fontSize: NhamFontSize.detail)) - .copyWith(color: NhamColors.text), + child: GestureDetector( + onTap: widget.onTap, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular( + NhamRadii.lg, + ), // rounded-lg = 10 + ), + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: NhamSpacing.sp2, + ), + child: Row( + children: [ + Expanded( + child: Text( + widget.label, + style: (widget.selected + ? NhamTextStyles.sansMedium( + fontSize: NhamFontSize.detail, + ) + : NhamTextStyles.sansRegular( + fontSize: NhamFontSize.detail, + )) + .copyWith(color: NhamColors.text), + ), ), - ), - Text( - widget.vi, - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.xxs) - .copyWith(color: NhamColors.textWarm), - ), - ], + Text( + widget.vi, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.xxs, + ).copyWith(color: NhamColors.textWarm), + ), + ], + ), ), ), ); diff --git a/apps/mobile-flutter/lib/features/settings/controls/custom_select.dart b/apps/mobile-flutter/lib/features/settings/controls/custom_select.dart index 259c5de1..a8296945 100644 --- a/apps/mobile-flutter/lib/features/settings/controls/custom_select.dart +++ b/apps/mobile-flutter/lib/features/settings/controls/custom_select.dart @@ -43,7 +43,7 @@ class CustomSelect extends StatefulWidget { } class _CustomSelectState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { final LayerLink _link = LayerLink(); final GlobalKey _triggerKey = GlobalKey(); OverlayEntry? _entry; @@ -79,18 +79,19 @@ class _CustomSelectState extends State final box = _triggerKey.currentContext?.findRenderObject() as RenderBox?; final width = box?.size.width ?? 0; _entry = OverlayEntry( - builder: (_) => _DropdownOverlay( - link: _link, - width: width, - animation: _popover, - options: widget.options, - value: widget.value, - onPick: (v) { - widget.onChange(v); - _close(); - }, - onDismiss: _close, - ), + builder: + (_) => _DropdownOverlay( + link: _link, + width: width, + animation: _popover, + options: widget.options, + value: widget.value, + onPick: (v) { + widget.onChange(v); + _close(); + }, + onDismiss: _close, + ), ); Overlay.of(context).insert(_entry!); setState(() => _open = true); @@ -111,72 +112,81 @@ class _CustomSelectState extends State @override Widget build(BuildContext context) { - final selected = widget.options - .where((o) => o.value == widget.value) - .map((o) => o.label) - .firstOrNull; + final selected = + widget.options + .where((o) => o.value == widget.value) + .map((o) => o.label) + .firstOrNull; - final borderColor = _open - ? NhamColors.accent - : (_pressed ? NhamColors.accent50 : NhamColors.inputBorder); + final borderColor = + _open + ? NhamColors.accent + : (_pressed ? NhamColors.accent50 : NhamColors.inputBorder); - return CompositedTransformTarget( - link: _link, - child: GestureDetector( - onTap: _toggle, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - child: Container( - key: _triggerKey, - // Outer ring: `ring-1 ring-accent/20` when open, sitting 1px outside - // the 1px border at the same `rounded-lg` (8px) radius. - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(NhamRadii.md + 1), - border: Border.all( - color: _open ? NhamColors.accent20 : Colors.transparent, - width: 1, - ), - ), + return Semantics( + button: true, + excludeSemantics: true, + label: selected ?? widget.placeholder ?? '', + onTap: _toggle, + child: CompositedTransformTarget( + link: _link, + child: GestureDetector( + onTap: _toggle, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), child: Container( + key: _triggerKey, + // Outer ring: `ring-1 ring-accent/20` when open, sitting 1px outside + // the 1px border at the same `rounded-lg` (8px) radius. decoration: BoxDecoration( - color: NhamColors.elev, - borderRadius: BorderRadius.circular(NhamRadii.md), - border: Border.all(color: borderColor, width: 1), - boxShadow: _open ? const [NhamShadows.sm] : null, - ), - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: NhamSpacing.sp2, + borderRadius: BorderRadius.circular(NhamRadii.md + 1), + border: Border.all( + color: _open ? NhamColors.accent20 : Colors.transparent, + width: 1, + ), ), - child: Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(right: NhamSpacing.sp2), - child: Text( - selected ?? widget.placeholder ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: - NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm) - .copyWith( - color: selected != null - ? NhamColors.text - : NhamColors.textHelp, + child: Container( + decoration: BoxDecoration( + color: NhamColors.elev, + borderRadius: BorderRadius.circular(NhamRadii.md), + border: Border.all(color: borderColor, width: 1), + boxShadow: _open ? const [NhamShadows.sm] : null, + ), + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: NhamSpacing.sp2, + ), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: NhamSpacing.sp2), + child: Text( + selected ?? widget.placeholder ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith( + color: + selected != null + ? NhamColors.text + : NhamColors.textHelp, + ), ), ), ), - ), - RotationTransition( - turns: Tween(begin: 0, end: 0.5).animate(_chevron), - child: const Icon( - LucideIcons.chevronDown, - size: 16, - color: NhamColors.textWarm, + RotationTransition( + turns: Tween(begin: 0, end: 0.5).animate(_chevron), + child: const Icon( + LucideIcons.chevronDown, + size: 16, + color: NhamColors.textWarm, + ), ), - ), - ], + ], + ), ), ), ), @@ -246,8 +256,7 @@ class _DropdownOverlay extends StatelessWidget { child: Material( color: Colors.transparent, child: Container( - padding: - const EdgeInsets.symmetric(vertical: 6), // py-1.5 + padding: const EdgeInsets.symmetric(vertical: 6), // py-1.5 decoration: BoxDecoration( color: NhamColors.elev, borderRadius: BorderRadius.circular(NhamRadii.buttonXl), @@ -303,34 +312,47 @@ class _DropdownRowState extends State<_DropdownRow> { @override Widget build(BuildContext context) { - return GestureDetector( + return Semantics( + button: true, + selected: widget.selected, + excludeSemantics: true, + label: widget.option.label, onTap: widget.onTap, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), // transition-colors - color: _pressed ? NhamColors.track : Colors.transparent, - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: NhamSpacing.sp2 + 2, // py-2.5 = 10 - ), - child: Row( - children: [ - Expanded( - child: Text( - widget.option.label, - style: (widget.selected - ? NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) - : NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm)) - .copyWith(color: NhamColors.text), + child: GestureDetector( + onTap: widget.onTap, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), // transition-colors + color: _pressed ? NhamColors.track : Colors.transparent, + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: NhamSpacing.sp2 + 2, // py-2.5 = 10 + ), + child: Row( + children: [ + Expanded( + child: Text( + widget.option.label, + style: (widget.selected + ? NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) + : NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + )) + .copyWith(color: NhamColors.text), + ), ), - ), - if (widget.selected) - const Icon(LucideIcons.check, size: 16, color: NhamColors.accent) - else - const SizedBox(width: 16, height: 16), - ], + if (widget.selected) + const Icon( + LucideIcons.check, + size: 16, + color: NhamColors.accent, + ) + else + const SizedBox(width: 16, height: 16), + ], + ), ), ), ); diff --git a/apps/mobile-flutter/lib/features/settings/controls/option_strip.dart b/apps/mobile-flutter/lib/features/settings/controls/option_strip.dart index 5bfa7fe4..85395629 100644 --- a/apps/mobile-flutter/lib/features/settings/controls/option_strip.dart +++ b/apps/mobile-flutter/lib/features/settings/controls/option_strip.dart @@ -77,44 +77,59 @@ class _OptionButtonState extends State<_OptionButton> { @override Widget build(BuildContext context) { // Inactive `hover:text-[#2C2416]` — text darkens to `text` on press. - final color = widget.active || _pressed - ? NhamColors.text - : NhamColors.textWarm; - return GestureDetector( + final color = + widget.active || _pressed ? NhamColors.text : NhamColors.textWarm; + final label = + widget.option.hint == null + ? widget.option.label + : '${widget.option.label}, ${widget.option.hint}'; + return Semantics( + button: true, + selected: widget.active, + excludeSemantics: true, + label: label, onTap: widget.onTap, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), // transition-all - alignment: Alignment.center, - // py-2 - padding: const EdgeInsets.symmetric(vertical: NhamSpacing.sp2), - decoration: BoxDecoration( - color: widget.active ? NhamColors.elev : Colors.transparent, - borderRadius: BorderRadius.circular(NhamRadii.md), // rounded-lg = 8 - boxShadow: widget.active ? const [NhamShadows.sm] : null, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.option.label, - textAlign: TextAlign.center, - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.detail) - .copyWith(color: color), - ), - if (widget.option.hint != null) - Padding( - padding: const EdgeInsets.only(top: 2), // mt-0.5 - child: Text( - widget.option.hint!, - textAlign: TextAlign.center, - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.eyebrow) - .copyWith(height: 13 / 10, color: color.withValues(alpha: 0.7)), - ), + child: GestureDetector( + onTap: widget.onTap, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), // transition-all + alignment: Alignment.center, + // py-2 + padding: const EdgeInsets.symmetric(vertical: NhamSpacing.sp2), + decoration: BoxDecoration( + color: widget.active ? NhamColors.elev : Colors.transparent, + borderRadius: BorderRadius.circular(NhamRadii.md), // rounded-lg = 8 + boxShadow: widget.active ? const [NhamShadows.sm] : null, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.option.label, + textAlign: TextAlign.center, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.detail, + ).copyWith(color: color), ), - ], + if (widget.option.hint != null) + Padding( + padding: const EdgeInsets.only(top: 2), // mt-0.5 + child: Text( + widget.option.hint!, + textAlign: TextAlign.center, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.eyebrow, + ).copyWith( + height: 13 / 10, + color: color.withValues(alpha: 0.7), + ), + ), + ), + ], + ), ), ), ); diff --git a/apps/mobile-flutter/lib/features/settings/screens/account_section.dart b/apps/mobile-flutter/lib/features/settings/screens/account_section.dart index f599c29e..b6972319 100644 --- a/apps/mobile-flutter/lib/features/settings/screens/account_section.dart +++ b/apps/mobile-flutter/lib/features/settings/screens/account_section.dart @@ -168,51 +168,58 @@ class _AccountRowState extends State<_AccountRow> { ? const Color(0x1AD37B69) // danger @ 10% : NhamColors.hover50; - return Opacity( - opacity: widget.enabled ? 1.0 : 0.6, - child: GestureDetector( - onTap: widget.enabled ? widget.onTap : null, - onTapDown: - widget.enabled ? (_) => setState(() => _pressed = true) : null, - onTapUp: - widget.enabled ? (_) => setState(() => _pressed = false) : null, - onTapCancel: - widget.enabled ? () => setState(() => _pressed = false) : null, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: 10, - ), - decoration: BoxDecoration( - color: _pressed ? fill : Colors.transparent, - borderRadius: BorderRadius.circular(NhamRadii.buttonXl), - ), - child: Row( - children: [ - Icon( - widget.icon, - size: 16, - color: _pressed ? pressedColor : color, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - widget.label, - style: NhamTextStyles.sansMedium( - fontSize: NhamFontSize.sm, - ).copyWith(color: _pressed ? pressedColor : color), + return Semantics( + button: true, + enabled: widget.enabled, + excludeSemantics: true, + label: widget.label, + onTap: widget.enabled ? widget.onTap : null, + child: Opacity( + opacity: widget.enabled ? 1.0 : 0.6, + child: GestureDetector( + onTap: widget.enabled ? widget.onTap : null, + onTapDown: + widget.enabled ? (_) => setState(() => _pressed = true) : null, + onTapUp: + widget.enabled ? (_) => setState(() => _pressed = false) : null, + onTapCancel: + widget.enabled ? () => setState(() => _pressed = false) : null, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: 10, + ), + decoration: BoxDecoration( + color: _pressed ? fill : Colors.transparent, + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + ), + child: Row( + children: [ + Icon( + widget.icon, + size: 16, + color: _pressed ? pressedColor : color, ), - ), - if (widget.busy) - const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - color: NhamColors.textMuted, + const SizedBox(width: 10), + Expanded( + child: Text( + widget.label, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: _pressed ? pressedColor : color), ), ), - ], + if (widget.busy) + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: NhamColors.textMuted, + ), + ), + ], + ), ), ), ), @@ -255,17 +262,25 @@ class _AccountDeleteScreenState extends ConsumerState<_AccountDeleteScreen> { setState(() => _deleting = true); try { await ref.read(apiClientProvider).deleteAccount(); - // The account (and all data) is gone; clear the local session and leave. - await ref.read(authControllerProvider).signOut(); - if (!mounted) return; - context.go('/sign-in'); } catch (_) { if (!mounted) return; setState(() => _deleting = false); ScaffoldMessenger.maybeOf(context)?.showSnackBar( SnackBar(content: Text(tr('settings.account.deleteError'))), ); + return; + } + + // The account is gone server-side. A local sign-out failure should not make + // the destructive action look like it failed. + try { + await ref.read(authControllerProvider).signOut(); + } catch (_) { + // Ignore: routing to sign-in clears the user's path out of the deleted + // account state, and Supabase will refresh/reject the stale session. } + if (!mounted) return; + context.go('/sign-in'); } @override diff --git a/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart b/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart index 1f4b3e99..7ff7f42b 100644 --- a/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart +++ b/apps/mobile-flutter/lib/features/settings/screens/settings_screen.dart @@ -1,5 +1,7 @@ import 'dart:ui'; +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -78,10 +80,12 @@ class _SettingsList extends ConsumerWidget { children: [ Text( tr('settings.title'), - style: NhamTextStyles.serifRegular(fontSize: NhamFontSize.lg) - .copyWith( - letterSpacing: NhamTracking.tight, - color: NhamColors.text), + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.lg, + ).copyWith( + letterSpacing: NhamTracking.tight, + color: NhamColors.text, + ), ), const SizedBox(height: NhamSpacing.sp4), @@ -138,16 +142,16 @@ class _SettingsList extends ConsumerWidget { } void _push(BuildContext context, _EditorKind kind) { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => _ProfileScreen(kind: kind)), - ); + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => _ProfileScreen(kind: kind))); } void _copyLink(BuildContext context, String url) { Clipboard.setData(ClipboardData(text: url)); - ScaffoldMessenger.maybeOf(context)?.showSnackBar( - SnackBar(content: Text(tr('common.copied'))), - ); + ScaffoldMessenger.maybeOf( + context, + )?.showSnackBar(SnackBar(content: Text(tr('common.copied')))); } /// "Cutting · 0.50 kg/wk" — the saved goal + pace, or "Not set" when no @@ -166,13 +170,14 @@ class _SettingsList extends ConsumerWidget { if (aggression == null) return goalLabel; final unit = tr('onboarding.bodyMetrics.weightUnit'); // Locale decimal separator (vi "0,50") + localized per-week suffix. - final paceFmt = NumberFormat.decimalPattern(context.locale.languageCode) - ..minimumFractionDigits = 2 - ..maximumFractionDigits = 2; - final pace = tr('settings.rows.pacePerWeek', namedArgs: { - 'pace': paceFmt.format(aggression), - 'unit': unit, - }); + final paceFmt = + NumberFormat.decimalPattern(context.locale.languageCode) + ..minimumFractionDigits = 2 + ..maximumFractionDigits = 2; + final pace = tr( + 'settings.rows.pacePerWeek', + namedArgs: {'pace': paceFmt.format(aggression), 'unit': unit}, + ); return '$goalLabel · $pace'; } @@ -207,8 +212,9 @@ class _GroupLabel extends StatelessWidget { padding: const EdgeInsets.only(left: NhamSpacing.sp3, bottom: 4), child: Text( text.toUpperCase(), - style: NhamTextStyles.sansBold(fontSize: 10) - .copyWith(letterSpacing: 2.0, color: NhamColors.textMuted), + style: NhamTextStyles.sansBold( + fontSize: 10, + ).copyWith(letterSpacing: 2.0, color: NhamColors.textMuted), ), ); } @@ -238,54 +244,62 @@ class _PreferenceRowState extends State<_PreferenceRow> { @override Widget build(BuildContext context) { - return GestureDetector( + return Semantics( + button: true, + excludeSemantics: true, + label: '${widget.label}, ${widget.subline}', onTap: widget.onTap, - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp3, - vertical: 10, - ), - decoration: BoxDecoration( - color: _pressed ? NhamColors.hover50 : Colors.transparent, - borderRadius: BorderRadius.circular(NhamRadii.buttonXl), - ), - child: Row( - children: [ - Icon( - widget.icon, - size: 16, - color: _pressed ? NhamColors.text : NhamColors.textMuted, - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.label, - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) - .copyWith(color: NhamColors.text), - ), - const SizedBox(height: 2), - Text( - widget.subline, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.xs) - .copyWith(color: NhamColors.textMuted), - ), - ], + child: GestureDetector( + onTap: widget.onTap, + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp3, + vertical: 10, + ), + decoration: BoxDecoration( + color: _pressed ? NhamColors.hover50 : Colors.transparent, + borderRadius: BorderRadius.circular(NhamRadii.buttonXl), + ), + child: Row( + children: [ + Icon( + widget.icon, + size: 16, + color: _pressed ? NhamColors.text : NhamColors.textMuted, ), - ), - const Icon( - LucideIcons.chevronRight, - size: 16, - color: NhamColors.textMuted50, - ), - ], + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.label, + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), + ), + const SizedBox(height: 2), + Text( + widget.subline, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.xs, + ).copyWith(color: NhamColors.textMuted), + ), + ], + ), + ), + const Icon( + LucideIcons.chevronRight, + size: 16, + color: NhamColors.textMuted50, + ), + ], + ), ), ), ); @@ -319,16 +333,19 @@ class _InfoRow extends StatelessWidget { Expanded( child: Text( label, - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.sm) - .copyWith(color: NhamColors.text), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), ), ), Text( value, - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm) - .copyWith( - color: NhamColors.textMuted, - fontFeatures: const [FontFeature.tabularFigures()]), + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith( + color: NhamColors.textMuted, + fontFeatures: const [FontFeature.tabularFigures()], + ), ), ], ), @@ -380,9 +397,14 @@ class _ProfileScreen extends ConsumerWidget { // genuinely-null profile (onboarding never ran) gets the // re-onboarding empty state. An error offers a retry, not // a misleading "Start setup". - error: (_, __) => _ProfileLoadError( - onRetry: () => ref.invalidate(profileProvider(true)), - ), + error: + (_, __) => _ProfileLoadError( + onRetry: () { + unawaited( + ref.refresh(profileProvider(true).future), + ); + }, + ), data: (profile) => profile != null @@ -396,15 +418,15 @@ class _ProfileScreen extends ConsumerWidget { } Widget _editor(ProfileRow profile) => switch (kind) { - _EditorKind.goal => ProfileForm(profile: profile), - _EditorKind.cooking => InstantCommitEditor( - profile: profile, - title: tr('settings.rows.cooking'), - subtitle: tr('settings.profilePanel.cookingSubtitle'), - child: const Cooking(), - ), - _EditorKind.region => RegionEditor(profile: profile), - }; + _EditorKind.goal => ProfileForm(profile: profile), + _EditorKind.cooking => InstantCommitEditor( + profile: profile, + title: tr('settings.rows.cooking'), + subtitle: tr('settings.profilePanel.cookingSubtitle'), + child: const Cooking(), + ), + _EditorKind.region => RegionEditor(profile: profile), + }; } class _Centered extends StatelessWidget { @@ -454,22 +476,28 @@ class _ProfileEmpty extends StatelessWidget { // descendant context, so this crosses tabs correctly. Align( alignment: Alignment.centerLeft, - child: GestureDetector( + child: Semantics( + button: true, + excludeSemantics: true, + label: tr('settings.profilePage.startSetup'), onTap: () => context.go('/logging'), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp5, - vertical: 10, - ), - decoration: BoxDecoration( - color: NhamColors.text, - borderRadius: BorderRadius.circular(NhamRadii.pill), - ), - child: Text( - tr('settings.profilePage.startSetup'), - style: NhamTextStyles.sansMedium( - fontSize: NhamFontSize.sm, - ).copyWith(color: Colors.white), + child: GestureDetector( + onTap: () => context.go('/logging'), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp5, + vertical: 10, + ), + decoration: BoxDecoration( + color: NhamColors.text, + borderRadius: BorderRadius.circular(NhamRadii.pill), + ), + child: Text( + tr('settings.profilePage.startSetup'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: Colors.white), + ), ), ), ), @@ -508,22 +536,28 @@ class _ProfileLoadError extends StatelessWidget { const SizedBox(height: NhamSpacing.sp4), Align( alignment: Alignment.centerLeft, - child: GestureDetector( + child: Semantics( + button: true, + excludeSemantics: true, + label: tr('common.retry'), onTap: onRetry, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp5, - vertical: 10, - ), - decoration: BoxDecoration( - color: NhamColors.text, - borderRadius: BorderRadius.circular(NhamRadii.pill), - ), - child: Text( - tr('common.retry'), - style: NhamTextStyles.sansMedium( - fontSize: NhamFontSize.sm, - ).copyWith(color: Colors.white), + child: GestureDetector( + onTap: onRetry, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp5, + vertical: 10, + ), + decoration: BoxDecoration( + color: NhamColors.text, + borderRadius: BorderRadius.circular(NhamRadii.pill), + ), + child: Text( + tr('common.retry'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: Colors.white), + ), ), ), ), @@ -553,33 +587,41 @@ class _BackHeaderState extends State<_BackHeader> { return ClipRect( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), // backdrop-blur-sm - child: GestureDetector( - behavior: HitTestBehavior.opaque, + child: Semantics( + button: true, + excludeSemantics: true, + label: tr('settings.title'), onTap: () => Navigator.of(context).pop(), - onTapDown: (_) => setState(() => _pressed = true), - onTapUp: (_) => setState(() => _pressed = false), - onTapCancel: () => setState(() => _pressed = false), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: NhamSpacing.sp4, - vertical: NhamSpacing.sp3, - ), - decoration: const BoxDecoration( - color: Color(0xE6FDFCF8), // cream @ 90% - border: Border(bottom: BorderSide(color: NhamColors.inputBorder)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(LucideIcons.arrowLeft, size: 16, color: color), - const SizedBox(width: 6), // gap-1.5 - Text( - tr('settings.title'), - style: NhamTextStyles.sansMedium( - fontSize: NhamFontSize.sm, - ).copyWith(color: color), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.of(context).pop(), + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: NhamSpacing.sp4, + vertical: NhamSpacing.sp3, + ), + decoration: const BoxDecoration( + color: Color(0xE6FDFCF8), // cream @ 90% + border: Border( + bottom: BorderSide(color: NhamColors.inputBorder), ), - ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.arrowLeft, size: 16, color: color), + const SizedBox(width: 6), // gap-1.5 + Text( + tr('settings.title'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: color), + ), + ], + ), ), ), ), diff --git a/apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart b/apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart index c5244abd..d93b2dcc 100644 --- a/apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart +++ b/apps/mobile-flutter/lib/features/settings/widgets/instant_commit_editor.dart @@ -40,13 +40,15 @@ class InstantCommitEditor extends ConsumerStatefulWidget { } class _InstantCommitEditorState extends ConsumerState { - late final ProfileFormController _controller = - ProfileFormController(ProfileFormValues.fromRow(widget.profile)); + late final ProfileFormController _controller = ProfileFormController( + ProfileFormValues.fromRow(widget.profile), + ); String? _errorText; /// The app locale at the last successful save. A locale change is committed /// even when no form field is dirty (preferredLocale lives outside the form). - late String _savedLocale = widget.profile.raw['preferredLocale'] as String? ?? + late String _savedLocale = + widget.profile.raw['preferredLocale'] as String? ?? WidgetsBinding.instance.platformDispatcher.locale.languageCode; @override @@ -94,9 +96,7 @@ class _InstantCommitEditorState extends ConsumerState { await context.setLocale(Locale(_savedLocale)); } if (mounted) { - setState( - () => _errorText = tr('settings.profilePanel.incompleteHint'), - ); + setState(() => _errorText = tr('settings.profilePanel.incompleteHint')); } return; } @@ -122,8 +122,7 @@ class _InstantCommitEditorState extends ConsumerState { _failedLocale = null; if (_errorText != null) setState(() => _errorText = null); _committing = false; - if (_controller.isDirty || - context.locale.languageCode != _savedLocale) { + if (_controller.isDirty || context.locale.languageCode != _savedLocale) { await _commit(); } } else { @@ -174,17 +173,21 @@ class _InstantCommitEditorState extends ConsumerState { children: [ Text( widget.title, - style: NhamTextStyles.serifRegular(fontSize: NhamFontSize.h3) - .copyWith( - letterSpacing: NhamTracking.tight, color: NhamColors.text), + style: NhamTextStyles.serifRegular( + fontSize: NhamFontSize.h3, + ).copyWith( + letterSpacing: NhamTracking.tight, + color: NhamColors.text, + ), ), const SizedBox(height: NhamSpacing.sp1), Padding( padding: const EdgeInsets.only(bottom: NhamSpacing.sp4), child: Text( widget.subtitle, - style: NhamTextStyles.sansRegular(fontSize: NhamFontSize.detail) - .copyWith(height: 20 / 13, color: NhamColors.textWarm), + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.detail, + ).copyWith(height: 20 / 13, color: NhamColors.textWarm), ), ), if (_errorText != null) @@ -196,21 +199,27 @@ class _InstantCommitEditorState extends ConsumerState { Expanded( child: Text( _errorText!, - style: - NhamTextStyles.sansRegular(fontSize: NhamFontSize.sm) - .copyWith(color: NhamColors.danger), + style: NhamTextStyles.sansRegular( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.danger), ), ), if (_failedValues != null) ...[ const SizedBox(width: NhamSpacing.sp3), - GestureDetector( - behavior: HitTestBehavior.opaque, + Semantics( + button: true, + excludeSemantics: true, + label: tr('settings.profilePanel.retry'), onTap: _retry, - child: Text( - tr('settings.profilePanel.retry'), - style: NhamTextStyles.sansMedium( - fontSize: NhamFontSize.sm, - ).copyWith(color: NhamColors.text), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _retry, + child: Text( + tr('settings.profilePanel.retry'), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.sm, + ).copyWith(color: NhamColors.text), + ), ), ), ], diff --git a/apps/mobile-flutter/lib/features/settings/widgets/region_editor.dart b/apps/mobile-flutter/lib/features/settings/widgets/region_editor.dart index 9781e7e4..ee635a61 100644 --- a/apps/mobile-flutter/lib/features/settings/widgets/region_editor.dart +++ b/apps/mobile-flutter/lib/features/settings/widgets/region_editor.dart @@ -59,12 +59,17 @@ class _LanguageFieldState extends State<_LanguageField> { children: [ Row( children: [ - const Icon(LucideIcons.languages, size: 16, color: NhamColors.accent), + const Icon( + LucideIcons.languages, + size: 16, + color: NhamColors.accent, + ), const SizedBox(width: NhamSpacing.sp2), Text( tr('settings.language'), - style: NhamTextStyles.sansMedium(fontSize: NhamFontSize.detail) - .copyWith(color: NhamColors.text), + style: NhamTextStyles.sansMedium( + fontSize: NhamFontSize.detail, + ).copyWith(color: NhamColors.text), ), ], ), @@ -73,11 +78,13 @@ class _LanguageFieldState extends State<_LanguageField> { value: current, onChange: (v) { if (v == current) return; - context.setLocale(Locale(v)); - // Nudge the controller to fire a notification. preferredLocale lives - // outside the form, so InstantCommitEditor detects the locale change - // and commits even though no field value changed (identity assign). - form.update((f) => f.countryOfResidence = f.countryOfResidence); + context.setLocale(Locale(v)).then((_) { + if (!mounted) return; + // Nudge the controller to fire a notification. preferredLocale + // lives outside the form, so InstantCommitEditor detects the + // locale change and commits even though no field value changed. + form.update((f) => f.countryOfResidence = f.countryOfResidence); + }); }, ), ], diff --git a/apps/mobile-flutter/lib/router.dart b/apps/mobile-flutter/lib/router.dart index 750f49c1..e09b441b 100644 --- a/apps/mobile-flutter/lib/router.dart +++ b/apps/mobile-flutter/lib/router.dart @@ -152,8 +152,9 @@ final routerProvider = Provider((ref) { GoRoute( path: '/settings', parentNavigatorKey: _rootKey, - pageBuilder: (context, state) => - const CupertinoPage(child: SettingsScreen()), + pageBuilder: + (context, state) => + const CupertinoPage(child: SettingsScreen()), ), // The primary destinations — each its own branch so state/scroll persist @@ -287,6 +288,8 @@ class _SplashScreenState extends State<_SplashScreen> .accessibilityFeatures .disableAnimations) { _controller.repeat(reverse: true); + } else { + _controller.value = 1; } } diff --git a/apps/mobile-flutter/lib/shell/tab_scaffold.dart b/apps/mobile-flutter/lib/shell/tab_scaffold.dart index cf5a2877..505a1c53 100644 --- a/apps/mobile-flutter/lib/shell/tab_scaffold.dart +++ b/apps/mobile-flutter/lib/shell/tab_scaffold.dart @@ -28,7 +28,8 @@ class TabScaffold extends StatelessWidget { final StatefulNavigationShell navigationShell; // Branch indices in the router's StatefulShellRoute (declaration order): - // 0 dashboard · 1 nutrition · 2 logging · 3 groups · 4 admin · 5 settings. + // 0 dashboard · 1 nutrition · 2 logging · 3 groups · 4 admin. + // Settings is a standalone root route, not a shell branch. static const int _branchDashboard = 0; static const int _branchNutrition = 1; static const int _branchLogging = 2; @@ -82,9 +83,10 @@ class TabScaffold extends StatelessWidget { child: Scaffold( backgroundColor: NhamColors.surface, body: navigationShell, - bottomNavigationBar: showBar - ? _BottomBar(tabs: _tabs, currentBranch: current, onTap: _onTap) - : null, + bottomNavigationBar: + showBar + ? _BottomBar(tabs: _tabs, currentBranch: current, onTap: _onTap) + : null, ), ); } @@ -121,9 +123,7 @@ class _BottomBar extends StatelessWidget { return DecoratedBox( decoration: const BoxDecoration( color: NhamColors.surface, - border: Border( - top: BorderSide(color: NhamColors.borderSoft, width: 1), - ), + border: Border(top: BorderSide(color: NhamColors.borderSoft, width: 1)), ), child: Padding( padding: EdgeInsets.only(bottom: bottomInset), diff --git a/apps/mobile-flutter/lib/theme/nham_typography.dart b/apps/mobile-flutter/lib/theme/nham_typography.dart index 62627695..e50f6818 100644 --- a/apps/mobile-flutter/lib/theme/nham_typography.dart +++ b/apps/mobile-flutter/lib/theme/nham_typography.dart @@ -55,8 +55,8 @@ abstract final class NhamTextStyles { height: height, ); - // No serifSemiBold: Lora is never above 400 (the bible's hardest type rule). - // Deleted so a bold-Lora regression can't be reintroduced by reaching for it. + // No serifSemiBold: Lora is allowed up to w500 for select display/number + // treatments, but never w600+ bold weight. static TextStyle serifItalic({double? fontSize, double? height}) => GoogleFonts.lora( @@ -99,57 +99,39 @@ abstract final class NhamTextStyles { // ── Semantic presets ────────────────────────────────────────────── static TextStyle displayLarge() => serifRegular( - fontSize: NhamFontSize.display, - height: NhamLeading.display, - ).copyWith(letterSpacing: NhamTracking.display); + fontSize: NhamFontSize.display, + height: NhamLeading.display, + ).copyWith(letterSpacing: NhamTracking.display); - static TextStyle heading1() => serifRegular( - fontSize: NhamFontSize.h1, - height: NhamLeading.tight, - ); + static TextStyle heading1() => + serifRegular(fontSize: NhamFontSize.h1, height: NhamLeading.tight); - static TextStyle heading2() => serifRegular( - fontSize: NhamFontSize.h2, - height: NhamLeading.tight, - ); + static TextStyle heading2() => + serifRegular(fontSize: NhamFontSize.h2, height: NhamLeading.tight); - static TextStyle heading3() => serifMedium( - fontSize: NhamFontSize.h3, - height: NhamLeading.snug, - ); + static TextStyle heading3() => + serifMedium(fontSize: NhamFontSize.h3, height: NhamLeading.snug); - static TextStyle heading4() => serifMedium( - fontSize: NhamFontSize.h4, - height: NhamLeading.snug, - ); + static TextStyle heading4() => + serifMedium(fontSize: NhamFontSize.h4, height: NhamLeading.snug); - static TextStyle bodyLarge() => sansRegular( - fontSize: NhamFontSize.lg, - height: NhamLeading.normal, - ); + static TextStyle bodyLarge() => + sansRegular(fontSize: NhamFontSize.lg, height: NhamLeading.normal); - static TextStyle body() => sansRegular( - fontSize: NhamFontSize.md, - height: NhamLeading.normal, - ); + static TextStyle body() => + sansRegular(fontSize: NhamFontSize.md, height: NhamLeading.normal); - static TextStyle bodySmall() => sansRegular( - fontSize: NhamFontSize.sm, - height: NhamLeading.normal, - ); + static TextStyle bodySmall() => + sansRegular(fontSize: NhamFontSize.sm, height: NhamLeading.normal); - static TextStyle caption() => sansRegular( - fontSize: NhamFontSize.xs, - height: NhamLeading.normal, - ); + static TextStyle caption() => + sansRegular(fontSize: NhamFontSize.xs, height: NhamLeading.normal); static TextStyle eyebrow() => sansSemiBold( - fontSize: NhamFontSize.eyebrow, - height: NhamLeading.normal, - ).copyWith(letterSpacing: NhamTracking.eyebrow); + fontSize: NhamFontSize.eyebrow, + height: NhamLeading.normal, + ).copyWith(letterSpacing: NhamTracking.eyebrow); - static TextStyle buttonLabel() => sansSemiBold( - fontSize: NhamFontSize.sm, - height: NhamLeading.snug, - ); + static TextStyle buttonLabel() => + sansSemiBold(fontSize: NhamFontSize.sm, height: NhamLeading.snug); } diff --git a/apps/mobile-flutter/test/widget_test.dart b/apps/mobile-flutter/test/widget_test.dart index 2ceaf944..bee56555 100644 --- a/apps/mobile-flutter/test/widget_test.dart +++ b/apps/mobile-flutter/test/widget_test.dart @@ -1,11 +1,43 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:nham_mobile/app.dart'; +import 'package:nham_mobile/services/supabase_service.dart'; void main() { - testWidgets('Nham placeholder renders', (WidgetTester tester) async { - await tester.pumpWidget(const NhamApp()); + TestWidgetsFlutterBinding.ensureInitialized(); - expect(find.text('Nham'), findsOneWidget); + setUpAll(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/shared_preferences'), + (call) async => call.method == 'getAll' ? {} : null, + ); + await EasyLocalization.ensureInitialized(); + await SupabaseService.initialize( + url: 'https://example.supabase.co', + anonKey: 'test-anon-key', + ); + }); + + testWidgets('branded auth welcome renders', (WidgetTester tester) async { + await tester.pumpWidget( + EasyLocalization( + supportedLocales: const [Locale('en'), Locale('vi')], + path: 'assets/l10n', + fallbackLocale: const Locale('en'), + child: const ProviderScope(child: NhamApp()), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Nhẩm'), findsOneWidget); + expect( + find.text('Track Vietnamese meals without the guesswork'), + findsOneWidget, + ); }); } From 7860fd1a5ab6dbd1cdba7c4a79702cfdbabed4e9 Mon Sep 17 00:00:00 2001 From: VoMinhKhoii Date: Sun, 28 Jun 2026 11:35:03 +0700 Subject: [PATCH 44/57] feat(mobile-flutter): overhaul logging UX + global Be Vietnam Pro font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logging surface redesign: - Restore left slide-in nav drawer; remove bottom tab bar so the input bar owns the bottom edge (sidebar.dart, tab_scaffold.dart, app_header.dart) - Date chip morphs in place into the week strip (fixed-height cross-dissolve, full-width, hamburger hidden when expanded) — feed never shifts (DateMorph) - Two-line meal input: text on line 1, mode selector + send on line 2; minimal Claude-Code-style mode sheet (Normal/Cheat/Manual) - Optimistic save with keep-previous-data + top toast (no full reload) - Streaming meal card: single loading step, streams items like web, lightning icon for Normal mode - Meal time as centered divider (── 1:04 AM ──); consistent subtle borderFaint separators; removed left timeline rail/gutter - Empty state: rotating "What did you eat?" prompts, no chips/subtitle - Manual log sheet: Lucide icons, macros with units, prominent save Font: swap DM Sans -> Be Vietnam Pro globally (native font family) to fix broken Vietnamese diacritics; Lora serif unchanged. Co-Authored-By: Claude Opus 4.8 --- .../assets/google_fonts/BeVietnamPro-Bold.ttf | Bin 0 -> 140300 bytes .../google_fonts/BeVietnamPro-Medium.ttf | Bin 0 -> 135980 bytes .../google_fonts/BeVietnamPro-Regular.ttf | Bin 0 -> 132948 bytes .../google_fonts/BeVietnamPro-SemiBold.ttf | Bin 0 -> 136736 bytes .../assets/google_fonts/DMSans-Bold.ttf | Bin 48200 -> 0 bytes .../assets/google_fonts/DMSans-Medium.ttf | Bin 48308 -> 0 bytes .../assets/google_fonts/DMSans-Regular.ttf | Bin 48280 -> 0 bytes .../assets/google_fonts/DMSans-SemiBold.ttf | Bin 48280 -> 0 bytes apps/mobile-flutter/assets/l10n/en.json | 23 +- apps/mobile-flutter/assets/l10n/vi.json | 23 +- .../features/auth/widgets/apple_button.dart | 79 +- .../lib/features/auth/widgets/auth_page.dart | 104 ++- .../auth/widgets/auth_submit_button.dart | 2 +- .../auth/widgets/auth_text_field.dart | 8 +- .../auth/widgets/confirm_email_view.dart | 2 +- .../features/auth/widgets/google_button.dart | 2 +- .../dashboard/screens/dashboard_screen.dart | 66 +- .../dashboard/widgets/adherence_heatmap.dart | 4 +- .../dashboard/widgets/compact_weight_log.dart | 50 +- .../dashboard/widgets/dashboard_tokens.dart | 51 +- .../dashboard/widgets/section_header.dart | 23 +- .../features/dashboard/widgets/skeleton.dart | 240 ++++++ .../dashboard/widgets/today_section.dart | 183 +++-- .../dashboard/widgets/weight_chart.dart | 111 ++- .../logging/screens/logging_screen.dart | 31 +- .../logging/widgets/dashed_divider.dart | 69 -- .../features/logging/widgets/empty_state.dart | 113 +-- .../features/logging/widgets/feed_area.dart | 120 +-- .../logging/widgets/manual_log_sheet.dart | 131 +++- .../features/logging/widgets/meal_entry.dart | 17 +- .../features/logging/widgets/meal_input.dart | 191 +++-- .../logging/widgets/meal_mode_sheet.dart | 240 ++++++ .../logging/widgets/persisted_meal_card.dart | 52 +- .../logging/widgets/streaming_entry.dart | 340 ++------- .../logging/widgets/timeline_picker.dart | 483 +++++++----- .../logging/widgets/timeline_rail.dart | 78 -- apps/mobile-flutter/lib/main.dart | 7 +- .../lib/shared/widgets/top_toast.dart | 128 ++++ apps/mobile-flutter/lib/shell/app_header.dart | 260 ++----- apps/mobile-flutter/lib/shell/sidebar.dart | 697 ++++++++++++++++++ .../lib/shell/tab_scaffold.dart | 264 +++---- .../lib/theme/nham_typography.dart | 28 +- apps/mobile-flutter/pubspec.yaml | 22 +- 43 files changed, 2758 insertions(+), 1484 deletions(-) create mode 100644 apps/mobile-flutter/assets/google_fonts/BeVietnamPro-Bold.ttf create mode 100644 apps/mobile-flutter/assets/google_fonts/BeVietnamPro-Medium.ttf create mode 100644 apps/mobile-flutter/assets/google_fonts/BeVietnamPro-Regular.ttf create mode 100644 apps/mobile-flutter/assets/google_fonts/BeVietnamPro-SemiBold.ttf delete mode 100644 apps/mobile-flutter/assets/google_fonts/DMSans-Bold.ttf delete mode 100644 apps/mobile-flutter/assets/google_fonts/DMSans-Medium.ttf delete mode 100644 apps/mobile-flutter/assets/google_fonts/DMSans-Regular.ttf delete mode 100644 apps/mobile-flutter/assets/google_fonts/DMSans-SemiBold.ttf create mode 100644 apps/mobile-flutter/lib/features/dashboard/widgets/skeleton.dart delete mode 100644 apps/mobile-flutter/lib/features/logging/widgets/dashed_divider.dart create mode 100644 apps/mobile-flutter/lib/features/logging/widgets/meal_mode_sheet.dart delete mode 100644 apps/mobile-flutter/lib/features/logging/widgets/timeline_rail.dart create mode 100644 apps/mobile-flutter/lib/shared/widgets/top_toast.dart create mode 100644 apps/mobile-flutter/lib/shell/sidebar.dart diff --git a/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-Bold.ttf b/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..52aadc6da089ed8ae647b5e0c8cac4f8b3e24750 GIT binary patch literal 140300 zcmd44349ynnLqx%GrBLIl5ESCB}=~L`%GdxitpSP5IY>PbB6{9M>$$ZO}N8N3xq(~ zLc2gIrAoL`8YoZ-l&h2l3X}y>Sjx8BF6~lEX`=kU&pR{nNR}JI=;$Jc8fbx8e(nQ+^EB8*v@pddgYn-nZ$b8yJ%;c)V--X&c7RtUh!u z-nSj^51cZ7?hg4Z*K4@{a@-#{b^Me~TYh$R4&!fQe1@?dr=5A$2WPzX3FGe>(4U)k zoUv)gZ@zb&i?Na=xc@Og29L6fuKxb@AFs>m`YX$lKgKugoj<(#6Z-u3o|ArZbbI{o zhKCK$;yx3T=v(~5bMnXWzu_JKd35`cmkbY!mXzPbUnz^e!Qa!ES^0w1ur=&zoZsal zSm9A=gLIG?ao28X1lNm|Pks_><~`yL_0M-0T4C&X?Ym{mmkqG*FlX5&wGV&fR&*(k zewT8k5AhLTCjKe<$eI8D#bL;0e(C3|Uv{vn)I<7je4owor2k>V;&T`4mo8<)|EuF- zwCTaSQV!`Ie4hhqFJt|@h7I$Yqi^DP3Fq^04C4GR_$*RCd-3@P{s&eiy@)$0Q2k-l~GsC+K2|A^zS_)b0KF1+*q!2$SHNq6I2IOMrFe-7s_;=Oe2#kKyR-|lCX zNyl&T-S2Vl|MvJ#mTL&IVuO`s>krUOlk_t4%y`_2d-TT*tVOz+wf$co@aNZ{qkfj{9(&{J%f=Ei5eBycuojk1_ONC$sP>Hka44#maH?ZC-!$mpJdn z(S!3(@mZ{X;=7}Nm+<)cN z@8r+$9l=mJTKEq!hM%G@KV>1Qkkv~itTA!?0{wfKm5Ae@@+mDrKS$BebjOMreoMW7 z74ynxO+BPB(EPEiXvV|JB9i_XWVOFgKJjn_P)wE-RC3@!5mVC9F*Ph)H5j`UrCg4*66Tl_pr1JfE%Q%h@WvmL02o z%Inw&KUO)UQ&=kpp`%h479T3&?9(CI2;Wyr1o*LwXhcp@U@2t*lwP0?%Cl z9LHF(bURxh|;Pu9ZN(Jzt@Rse|WkEOU?fdhI1^e8A!IMmI@t*m0ty!buKI9bpIci7tfHUBjd=&UuZJ;G#E%X z+RmcrON2RD1DnHkax>54J|5(?yot}{J-nX}^QHV)emwsQ-^uUbG5$O0S?L|wAUot- z*(ZnODu2*_iT`&0-vjx9qJTe87N`g`2IdC(13Loe1+EC(7c2{&RF+%jE*mZzE8BGR zDBkLXM$^dVvhQ#k_i#V2;PreC@8%2m5MRPq@DY9zKb3!n|A>DiJtMs-GufoRt->Gh zPvC9e3FHL|0>y!Vc-tKDwzC2gfgj*)$0y#l@#s;05p*enFN-)(L*;X-yb^b(1Amj9_l~jI^=r)_lGLqzx(~B_nvw0$@liYcRubQxDu`|Nq(N!3v=uWOlTMb-kiLfd#Xo5$D2YkXd*x2KOO7hv zrvEF068QD}R{kWvlmCkUjz7(x;y>W`0YAUyH}kvrP5irj55I-K%YVcFoqwDE41FtL zh0G6nYhkUx^gK4mhS)G$!j`g?Y%BWx6?Q5+6B6kPwwqncu48)u{qF(a|H1E-jO;e{ z0DF-A7&`XL?3e5{_6GYc`+$AOK4$;P{>Tm71j@1TEKpB5uvyC^yp{iuzrgS3AMhLa zZTy$~HU0{JoxjMRk!1cXU&$ZiAM(5TbCQEU&kyklexYRIAMu~_f03O08+;XiobTpW z@GCL5=74&w%)_#n8?;=^>cJW1td=c+H0WkMtP~t|JX^(9v(0Rjzr{AO^VzxVJoYtq zJ=?}z>^}Bw_C21*9OYE=wZO(ZC z&*yGXW*s;32Jqhf;F{aPGe2TQ>>*ad4lo~k7&QD77GwuO$uEF|o@Gt!MaZq6vNrY# z>tL^fzF%hp>=5f?@3DUNKAX>e%@(r%V2dDw7qj27Wsq^p!JivJI2+hewuQ@VJGZb? zp#Pu7?d){!WIMQnoy~LES9uP2Jeyt4!|W0sWS4=aFXjQzS{b_vddLmXQ@+V=;ce_z z-T~>?&Q4^12R|3FcJ?dDDj6gjDACUUhkwdH<6rQ9fI9!e|I82bzw^KHzp+mCZ|oZA z)>raMb~UetZ2AG@&<`=+{VQ{@yTP&dFc#aJW|W5#d9NNGve6xh^I}Lcvh5 zE+$6;8y}GK-K@7Sme&^;8|&RC<@fdOE0_CXQr|J>24ap7PWr|-#tbXY-7iTJnu!HB z6$k0t{Vq4}Ee_x`)Vn{A=i#?djI9{iG`ioz>CU>CArg~oWA45YdSA@b*Qeg#3v3L; zo>&nxRIJ@!#hrZv8wO&gfstTLE+1WW{0MIL?H&olR;<8R(NSL@)=6ibqoaX+%H8Nu z6~0oh0 zb_aIjUHcl1<$%!2k+BuN@l~TEq0!)IAQoLcg5P`u7qu&OF>@ql?W^4n@gksZ!DXm7 z1ab-Wj>n|+n`3+fIubM2)Wxik0QJ^|VHwzZ+=Bi_$42Rvv3}88TV%i0#rg(%Yk~>F zu}6{#$)U8uYte;141FvxusbwPNEN_lK0-(=;6o?1UIMkD@qXnM&S@Txh4B;~P7F=| zkc;R))cb(b!Q=s;$QKHZ)_{n!BKst1AhvP5zb=*?!4Lw0SXSR68W~Q2p;$Iut-@8d zz*i0$$`(Kg00tY-@mNmZSYY>9AeIBL)x~lni;o%EXV}<38jd+Ph0d*uKz*e$Eg`t~W6DNI?O|#FWQ+dwU52`S^{G@5{H;#;&aOm4UoH7^Azk zF6NEwl*MUj05`t(Ki8R@e)vd=`HC6Rq*`t(QkS?IGgvd>DNwGlu= zfq%?221thj^)Y@TaZ6n+qWjU4`0;e*$2#4Q6^S3uP<{+VSS+h{+Hqn~4=F>Xaq7nx z#P|Z}R~g1fpCOEoKEoIveU@W<^jU%N(Pt&bN1s&~AAMG1eDqm^@zG~}B+w-osUZ>= zixrFoAoTbc(Zo1DWA#L{jgeSGZL9(O*aTJ>0>!3f$k2Fahy?qLcYqx0V$B+g_qj|1 zL~*gEntev@9vFd8BrLY*z%$)Ft&u>R=y@AjQ`#ArDkdO(()O0VXPyTc{p;@ub?$5B zZW?Pl;DG_8eP#^IGTvDi>xk5QyXs<{nQjGPZ@}$yfFkB857Y;SNF)HX3wQ4x3JpO7 zjbP$~)WwX{$+(L!Fpr%zS>Q@>qCJ+*KV{qFZs4Wz0zLH z6zbLP4#dVtrbJhcJYWbI1HK0g6~>~`UXrvn2yMI{JqQhs#Z16YVwxUkR_^V3N$#;QRx-?i|SrRi=sIUli0ZiSApJFx$lR#iFG(;~YEcR%> ziNPq4jH}Sv(5d&5@+BHgpdw}}$JIjol+axiBu=Hgv5<-_NdCImeC=%l`YO7{ zNWn=RvWDU)4$k^TD?Zl73y~Taol@Z_=;Yv-qrW*dK|_tKALqBO2QcPM@xCKxloe5nUV*Lnbn=P#`}J1+7;ZI+1e-oK_Fc zQwB5`*=NICL)0%Q|I3l|P&xwpXQ-IqO>(R&)aeWA=p{I+_HzgVcy6r*m0?`Xs|^y) z2rlYq5+GOz5V#e#hjs!E&#RBMgUc69^ZjBp$Mf@I9k^#nBsK>hO9_et0C!*zlduM@ zWf3Ci*iwLbd1OChgE(1%6HX^9Bm24dW))7vH>>HMA^da<-9snG(mix?9Nj}FYa$Oo z7WUz61ZSAraW)!x0IMWATZ=R0yW{CTPT!qC_ldI;={|9G65S`x*3ml$aN8KYlTOCz zopiFE-bp7L=$>JmY@~bWWE0&(C!6UWI@u!n)sM5SqF;2jP4tV-P8R*5v#*GL(b;y< zFFHF#^o!0;75$>K(*WbH1Oj)6t5_62oUWWM!08zTbh<6pi>os+*W#4!IZHXEd%h~} zp&#g;v+;0uqSbT6m3ZP@<&>T{PdTOAzlL_Yl_tNgoYD>FE2nhB1-Q2-(cVsRCEELj za!R*PD5rG$g?O?@X>XTuN;h1joYD;!X46x%@XYWB475~xo zkMS+*-xubWuf)pW@)5e;*uSret`At4@&)T3_3f*quO78bFm8xmzTp^gCw`!Rk3^)f zly8zF8&&MJhh} z+0hpI4Yr=G>^Dc7*t&-nU-5v^~u%m)|^yZR(o6R z_1E21_m}$i`bX>I4a*wtY51b?TTPLsvzi`mb~K;e{6&kuWm(Hr zEpN1Tww~X5Z|kdVrncd>@3g(&UeJDBhoz&vYtRH-BX#a3@AzS$9qJxXCSrS-s_L9$*UcAh)Y|rwV<$G83 zulUQ#;gulGRUfau>X^=BrQ@VE&1>Eq**x;v=*U{z+8t{@I{u>*I#2lYB*(hkvD~qb z##_g48h?BI)Acp$cdUPLL-vLp8}8khyRmEI!A;Rkw{Lo9Guzz0`P9vKZE5NQy!g>ex-R+NrR|qKf7xx9J#pD5mq#!E&J~_3wqLR5idS~m z?7r?w*Ofc2eDSK|u8LnBxq9c-?_Se$&B1FoUe|bi_VtHvc=ucW8^><)-}LFtukLwq zuj$s_+dQ|$zJ1c|{kOmQFW>skad(W}@!Fl&e7Eeod%pYj_X6Kr^1Yk>)%34(|Mm8} zq`MmLy7z9^-R*bpzo-759rx_L=k4!DzkkN}Kfbs3-tG6k^Mm#uocn`2fAHabEAHEM z-xog|`QfEM{NnzR`)|Ad`5!rcbk>jVjfG>oVzGT?`_A9@_`cuokL-VN|HluEJ@DFt z(t}q$`1^-U4}I;S4xtYaI-l6{#OF`eKe_SA-A~4z z{Nkzpr?x(I)l-lD%=EKmKil)Or+@az(}AZ~JblK~H#~jd=~sXL_%kOxbJep;p8e># ztt2Yzxc0@{Up(;Q`!61Tso{=r@Rx7D5_#p*Uw!)OqF3+yx88sI=(U>HcDo>2y<6sgj$V~bG^2H3xU?f^?;|9i;z#=GdgR~XWBY&w;mY`qoWsF%Z zxXqlClVdU$*0$ueU}f%PTut z_~iTeqo=RD`pDntt&Q>b8rQzGS+3&`9`>hy_tUU3m%>iIC^}TeEe5_s=3L^JFv-f{ zZDQMCCo~x?rmb+G?3eQaU}o3@nE6-;*4H)BV=B1K#FrSk4AjU5Of`TlfzM#)lF1-#1NJOtn`J9Anevx9 ztdiO6HZLnKHX6#q#i8O*SulWsc=B_y>{f%%=*!7=nhOM2kNoje1=4nNiq4A$2NgAJhfskl2ax;BA3xK#*u%+7&F4&*t~ zAkV2o9%Jdz2g;ChTxKsY*y1(Mh{46DUcGW&$3S_uyg?;M!v;CKd?3C>Pmmux^x$JN zAy6CFv+&lm!b>xsJ^avo?y&GBi(|nRBhhXUB!+Z`lgmaX8+V$y(Zr0Voe(e#PMB@X zK+2b4M>T0UOWhJftR&*LHP?HU*#ew{ej`sGp){0P>$>a{TwyVTI95W*%kF~q9+@8GL z+#JK2!j8JGp@wo}BqL?+hre73dZn@lsq{RLm8ztENKSeX^6F2<>| zcx86&nHh!BUvdlc{6)e3`n-a?PJFyD6Tr23>Iuw8E|`IbqWwlJN^`hbMr;iLe2GPp zxQtoD%!~%JaVwMM{H0ctWH7i5%ghKN%+4~q%r2+HZo@2TVy2uN8zgLAM~gR92_Jl7 zu6+8!^DaDRXfXbPp8YEJ?mhO{!*AR=%>MEu+jADFc&G)>y(~Kr_(I^ zAJpH$q4T#!aEjifK)@sE}yUi^l&K4XCU5I z73q4=J5Gn@05x@?lg;5DSYMdm`81X zZZ_3$%q_^9g_N~ln&0m`BkZd-$4Zs{q zmETj^nv~-%L`6^6(rC0c5GaKBuC5AH1_079^u)r_!cx+BlZAKA^ul{8T={dp$`X>; ztFj~2zA8v;->U4|_^(uoOp+uYLOQEbI=2H-`tfPuDL;!9grdnWjt(buN1J2?Me-Ci z9)zo^@lHpwBA=Jz@H)LvlsH*KEN)?@RdUFv#_nXxJ&rBuHlvm_Q|2=Nk2GR$t81hT3U zNH@9h#iv}oGQM69oVte|JpAfpUob|s4~86YZW#P=`9qarS*8e@2qXn*k`TJI(wP)2 z=~ijJXq|Gq3>{_`_m4)4NQ^*ugiDCnC&h<7llah&^0>5e<8=M7%Ysg?$zpeBIrDQB zwXY`Jlq(k>em5N~~_h(b`PAd021&r9Q2wiikeNyUjfXkwI9QdX&F zG>X8;l488Vm*Xz4%%K%MSq~banB;p}n%kS)ji!*`y$+p4p{TH^*y#=yF6{H?HLJ8u zU3V7cWqIY}jPt7f)lSvapv_@*h$&On%qyeIDg;>(fk1WJX5(oW{F%(F zxTn0LyS%&`H-%T0dcCD|9KK$Mm`Og7e}#D@fSLJQ4~2vgKv@A)>E{c(I$xwS0{Eis zQ|60=nvX@dm>$wSI2sMoQsxts%IoWtH3sx)oXET@7 zw(y`k@n2gA7OWgUN9=F>Hr^h8o^LyHfE(L+P`7qiAhr(np-Hd+Z zL2{L|zGxJ3Tt-i13pZd%wZ+UON$Z2r$k&;&ys9sNxDQ2G<%`Q}ACnIyxS2j$a9 z>%W~)x%oKYRzx6U<0j6)Jj*z%EDx4K_s+IkSskx41I@;=da13o2Mad8p()f@FN?)< zz6adfLzXa?f!}<>6(`i!r>*wAp|K;weSe0<@lTX>3X!6}yy z4PJgqw6c4kyV6%#;Tt-&tLwC(qRPr5@fAR<^jSUxh&Qo;XfHDu%~*6zU{zx@!=`|4 z3fq^_D34Cl$PpME z-5Cd?jpcR7$5wVsbfrv_>=G;f!o+HrU=|F`Vx9o)GNXkVG5wkhIF&4k5xV^9}tJd$y z_XgVPmY%t!az<*+uj*Yt;B&ef=adb`|6yJx%f&8NZeDwzeQ4{>1=Emlcy4cZjj>#6 zuPyB;gUrzI{}yKVIcy0WMk(;%7EDj1y=nkZh#3Sz#7xR-OH*AfLBi{H%yG_96jv1* z2}9;AkbiSkb8}T?OH1YKuwUB}4!6*8_=U_AK;tCY2+acaJumqYj5<*WDYFjLK%&s) zggnl2dR<=JXb+lH5lEuXB|-G%0JntPihf?O_KLOj_4s2x=bVM>8XDF~AI?Ad;(>vS zPo6KG7?1RCpF8&}10Bun^&*q-s3MnzOD{`TvwjGaIC(5Y~k5g z^49ZttJW>v`U9m`KZNAMlp(z!zX3wegJuPLlEV35bctPpi9(hxv2yB^uzL{OqkF=d z(e!g9pEut zj}j(16-+As++$Gea2|G2^mwv-lGdk~tjYA2V)cYU+-kPa3~F2_OrJ2D*w#6?u$quH z&F-;dCIH(f89<%M22g!ZrFl9F=s{h7lkA_mK4YD~SXSWZr#|Z-X-|RVqXkKe>BAcD!TqRo&QRaTPqWAzxN?Bev;P# zX#u1^f+%44bW9pPHD6YG=sOkY`=WDFA2m))`oU&=7REot6gm}s2~&jT3R7)SdYJG- zI%}w&=Jd8u4TUP*U29Nmp$+VrhsY4BdASBKdYute^HsT~7(uPfX1Cg~G_jc12}>xm z+a2q&xWnOgV6{A`v}4e$q5ot<@Omj`(Qre!p}sDX-Y}Zh>y^eZngq1>(^@!G%jl1F zoRHBjs(@CZtHlPzCR)p0dx#98nm4N#l1CUrv(p(v;nfmrR6BDx?T#&QW?9z>izstC zUF&kV%jI?fs4J$da#I@xb|<}CnVW=nOEHfIY6B{0r#Fz!;N40`lsC*|A=OKoY3-v) za8+%iA+{pA6zpo3Y^qHZAh4LsTObY%>oB2UZEUevArpn$wV;6X%YsmWVhIy*4tn#b zvc6Am9!;W_gPK`XvyVQkqmZ=LQH|lWx#w-n-d?fRBZU|;9xlkV6ER$s6xrZF#~ogW zcT%3GSB#V9!o{iz9DhUCy+}gQeJ!CKhWk`{uL z9Hu}pp^ey5v=P0G9-iz+LO83P&S+193+ zZFN&Jcaz=rsmbohs@XOLPr_zf#cotgwQUe%V5u{(NKH1?rl&f)LUm-163@vKU+%%t zXfYXYOR7r9{gZ6Ib!0T(>PH($E8Tvj8~#k@+Z31x+wK1mUWEBp*VB9WV0^8vTdMh1 z=>dY7j}A*aK%=GX+^F5dO-8=NjKSYNR{xh~P8V!;0voKt! z653Srry6RME}$Vv?A(|xej&bFht_oFPYuVku3iWnTUm9qB7vp^Mvcmv5umd&DlS@wxYldF#StFp zTsYp;Jic(niPk6h$mlJ$=qZ;C4PAc9g7SI2^UA|b>!?Hrejdde1 zsI$GX9`R$rf(3;OCS&?kOLj_3U#e9l?db049H^iezpS9Yq|8Mp6$4!vM=V!{T3SOz zFt7)ULai;K!@ryv1lss%-Pp*Ei>_AIjqqtGc0>Sy8LebLv?31BDpo;yJEDEkj!0@^ zWqE0dup~BG8qp#wiH_+liD`$uQCU|m$UIVIwfWlY#z%TU8(SVFyP?HspqP3X2!(+R z(M~3dP-W!tNvkg)pc%@V8XM{(HQ_R0DrBTW$Z=aC2$|D5Jh z$U)fj+FIN}94+pUcxz6uEx3Ngid%W*he<_?!WWvNb0bLfR%NC4+TvbZHF?aDUVN)`*P&9y66q^!t6``L>XtTj= zAoGn88cQC+nq@%a?E)$-fmP8(~DVhT*yWQ?jMfg;dMiue~+ zA)7f7dS(VU%SvDnwO^$~w3zqG6%WD;s05lZ`RGe{#|i4%!AGT<6yq^=!kxuEK` z>Po>^fJkWl3c;#afeMpMBvhj#3vXNcgYho-8LG{7rJ$zWHKbeLKK=BE~_^jP+w10ROy_&Y{MFksMzko zhH}H#_R2FU(1dG(5$_bbvzdJf7|A5(@Lm@m{A9132xlPv2 ztbZbkLE&P-hsnVUd3ZDtw4n0oXcS91G~|V@ORU<0cc#^E_ZAk3Fol9ey`oznFbh(P>NdI;}<3X-SP$7q}GJk`kp$xRyk4E|$nbgVo(X8ubZ%wYYGK zo1UCSdGHRV3A`mpz6Vo8ku%$gpxT;5u-21%<#bA^U?8DC^1A`&YzDbcv`uzduqZ8x z$1zze3ZuhrF|ll(ZNU4zWfkPQBhMXP39ay(wbAC1fmOG3o@cXOXzV?fzZ$Py{*A>1 zFJ)edp?w=GkCs_YFvY|2i?$(z4Kj+axFwRpHfBRCwuT&(i{T+olZy56_zOJx^Z5T@ zVtJO&iGMr(4Zbk`h}!Nj+BLFLM68HV0d#&0x|G_akr{Jx3=p1#?w}iuoeY%Tc`5jh z)+I{>Pb-?ZUbaIWF^s92I9vo)gOws{NfXy3e9-+WOv!&kdkHCzR*5!#Bibds&OvjX z-XCC6`~f8AVY@MosVpsoy~CV{G|ojp4a~ucXNWKwbf2JTXIInv64rhi2d8RX(kNiy z6xlwA<^n{?=0XO4ik(OXh?6(&@FO8eDcSzsIJFF}m4O&1E>BxiDLkaTW=sHoy)qMUf z*V1YzUd7);t1racRNVeh;MPgKMz)DTm!d@EAruOiO?D;1ca}|pibDjRa1D|a!t4vfG6`{k@oaN8S#p%<@?0#wDSt=x z1}5G=Z`V0PL+9*@->7vVfqh*ki0%YE|4_)JGIq(s!D7jhFvkYq-Hti+cea)%KAf^trzVd&ytgsU2{Uy5&7UGMVS zPq>PF)K{Hw;#DU#G@NwRiGyQ}jbnp@>lz!^iI|t6%eF6Auw7Z^UO9MLclW7-gQs?P zpEd~Iz}i;Mm;VcAUc^JfgAG2SMGx6+Wo5BrhN34SLPxB23JZ*eU}-^RVI^W*yhbl2 z$tAM6v?!4D{roiu^zIq@Dt92RB}0cnKU$xk2OSq7!dcCiARz*;p&1&g0l5;1SQg>w zMud(CW-0bjI7_(9S5sVrV3s0F5gJL(m!RoNjq6A|j6;e-J2=xo8bu`WGZeirJ?sq% z>!knMNmF10)^!aM!uS%tJGcabF@2V;1AIg2ps_wQG49D#pmqOBk~_ zxY=UfG>JJo#wKHg0&|gC1$WJjwe6AisTkDdWn>zoUS*#JHV0KrTb>P8GT-!%(H*rz3Z%z{QBhYZ&tx-o=$hePZ@lJ}Ex^mN`&n3O2%Xr}{{6e8Cw zlabmsn`Iofx1yykSp3c7PJqv62H@r^4Hz`H=a+2({?0j_x}em!9Uv88Wag6 z-Sb-e+WKm%11-Upe>Q<66F&GyY3=ADt7LFyG5M#I@M0y&WR`epx zR?A4yK9y*`>E}b5*D+71I#L7cj?M*ZBf6PpNKIQ3Va{5#aCw;YrP}InLwSQsc0Mw;n*N;ol->schWcw2eFo}0IE8^d?NZDA?kG_Kak&lxRw+O~DUA()$gtLlpNyHz?$#!9Z zg;Lwa$<8e=@|0mh&c{jsW@eW(z4q^LT-mm(u`Rk_x+MT_pF7;yW9<0$YVvm_F9dXY zHTn9fqBd$)6e0&1BY?o+MmDK~B03xk7K(mHK*D7Bds)zEsHqAzmLdB(Hyg`?fH9Dh zO{vJVQk}$9#v%O*I_Mo5il!TuSmEYtD4E6BNC#DL)5(@b7wNHM1_@JO#Uz%9ni*CN zVpUdOM|f&&uIsGtOvM)YcqU~{rlF;mI@1j?s%oAI(KEv+bpj|2E)kof;S$_wau>^dKD7M*5*Xb80o9fnqmWY1%?hKcj1@NNIcy=j z^Py%WNo%ok6y#xs$YN$0|L?>KB3W`-twu~C0dg&e%L?0y+LH5!v%sM!nv>QBomlH< zVqE^)H1?m`Q!|2nid`r%3(vsIaN6kXGG-WtpaGJ77H}eAOpzz5_D2HP0Qd?5E-AKJ z%0tDqCD8dSKC2JTNQhsh#)3CV1S^>aQ|6Mdo?L&~sV6sF_Kr5LG!`YY(mXU9Lybl= z-v&a-w)DT#gHoK#Y4xCg@WJoS)bl>LmLJf_J-tCpm7O0$5B&$=Ud|f0JDQi}kPNK8 zE>H?(vPIzaLD4vQilwqr_KXh9?IH_h z?UJ&h>-*=;BNe4~Nw7HRaerD?1xw}YuvvOMpN0yyckEd~YlJLk^;bG>UQU`yma~Rm zS-m{SFK`Z?8vhjxnAOXI@r#ho1IkkDm%o8ril8>oHx&<8O^Ov+ND;CJfN>O|Z-UTQ z z;O;!+E>RdB+z@cesQ+o=6eQqjf!6~xAio;FgEz-t<{OW^vFQd%UAke@?;E$SuBlnQ zwNVYl)6#C&nCdDHRdb$6e4{LT1R+3NGbTQq!n_x#g`Ka);86l^IeZVl+(?ZPf6 zh<{*+Yc#;chSeJ-u}WJ&cnVYy6Of%nVYgX@*@Z}Ec#@$BaThk4D`TsTM9Ps41*L?H4OmoJ&c- zh1f;3K&X;3&^szSyfZi=B6VB77pz*>*;IA<*jGYn{SgC~ww+n-DZDc8{1xYPFP-#C z(D(`eR{;MmK%g_)1{DG79U*`N0b&iMBwRz@iqIGp58iAq;UUKxRPdnEfjN&7{1i$+ zieEpEtorKwMg4dD;G3shcinX@bLUDQ&fVO%@RULjYDxS9Y2DME)irZSzL4+rYT=*D z1(1*d?&Jn2KHw8F(qkq@`ESH~}$(mTzYH)9{kujhszVRdu`dG@lo(V{u_)a)*pN(~m{7V;d(O75Rct$F12 zke{!pz~lG&5#ug0m9*SH3eQtLyp;PVs5%9n|Dn46{xG%Y2`6CMzAN#d=YY=JNy9-@ z8aEoSIw5RJJFy;<6&Dn!RhJ`PT^S0N_*@S7_}h8AN}>j}C?b($WhMD=s9b_yrj&7^ z`(ax0lBh*k=*aM;sI7i}XJ_5Eu8s40 zCv2gP?hXXn1Uo9qs%&}o#$)^XH+9r4=#3PIi#r0ru+?pET@&ry)(xqk@U3)?pqIHK zr@&;E%_eyQ`GaPvC_-X2Nr3}LO3>eg&R{gu)l&X@Znnc_m}{Jylt2Du#&_wCl~><3 z`!Om@=`S--A)hc#qPgzm+;U`Bn_y)(8_fpe1ZhT?K@mEc@LLI_s!knp#!*acxXhiG zhDEuOWTk)<(hVID z*K?6enS^_*MY4!x4!1}<7>9B}+2;vW9+V(_7d|Rl1w;Gv4|CAb3{N8-hqI-N7Y@&x z)7H{Z7w{Jqh*b7fe3crJIyII3OTbu{*4|d!R#27|L2*w{u&rUivCReM%ska|?i!EH z(bO4CN^KvoxA*l_XXLZ_TjtAtsY^|92LSsKo4pr!sAFG^W|w27Uy+jy)mKA=4~Qrw zl?KxUm{8Wjvq6~S2E=tL^I|B?9Z+|apdyntJ?3$kXp0Id{uPoZQJq4IE+WAb3RQMA zR*>@RMl^|{9($VHW>xM?RJGUoI{Hcm7nSjAeO39Zj@uJla^X4%k3ErNJLg?svs_^E zH~4B>k9pl_+%ytj${*<5abkzZyYNGE!>YNR8>5ghsF49da~bAFGYdxjn1vW$hv_~M z=ZSliQn?hL6hs_q5b>wn_JK6^fppTPmmaxO-$oWgelzTp7$tN*5!Y^9)c_s{gkQxgo~p6X;u=kR7$9c zLAnHLx}sT9w5KQ)D8fyWiWw$_o5H3qjGCqf zB&eXA98J}b!?5m{z?eJHFO*8%PQ6jxkC^q`9? zR--m3q!>NHw7*^r*q|B0<3_dwYDH?n8Wet{=61uLIt3IDD}L!=eW#Sz<5$vki*!5z z7p2BqEsI9G^`16b)}v24C!6A8tE>ni?IP8|mKyS}k4jG0S^lBsRQnte%H=qg>-#-5 zUitO3V<6aCr8@-;m5Mcsk=87RO*(2)Pw{M3g8|ZcwsaUzIJIh&d#5_JrrtanEg(~Hq_zt4fXg6GxO9rdp zJ1tK)l_Hb+&HKTC^QS_VoH$9OH`!SY@2cPCT7?hE=wwlHc`ZhZY@EPth}XvLLqw#=I}aw2wJV$g-P$2Bj@v`@p% zNmkCrhDdF7Ww@-=Kh?)FLq6)vH1spQk+afY|35|>72{}dble(p7 zUYg!Rxp!VMH-G6lP;q`t?aM2C@D6=1MDARgE`V2tkM;@uppvX<(6gj+MAShgrr1l# zFrmsbxB=FaIk1pQ@s$PK*t7w~U@Lj05{OKxj2$qoq$rh`Y}|^ta4ZfzJ7vUIX(Vs12!qko0H`=tTBSzHT)TrdYTPvFxp3x zFV)e6)Pl67)LR`M)zGIBQAD%0rn0;&Slm$3pf91RYGu@Y*n||jtiFV1TFvZCtxP7> z(nK9iFOEVzw5KVhd083+Xf!}P9ATFzJX|h#7}Flr&j(MEt3k)R2?s^XB=5pxpz>~# zgJSAahsDg!H@@JN1hJk*%D+@k!c=D z9@;VvNgk9hA(A{qYt&#!mR~!t_o6GleZ{U@fBf{h=b+)=Nrgu~mI{!T@#}ast(8zV zR{p!tPqJB4v_8vbhUI_K?kjSEkm;soNgy*L+mMYnAvMEdE*8rru797@L&dpD^lue9 zh=2Nb(>f`S^+lt!PRe!KC{P5W_^)7SblE%Dz}S=b7`U#FUn+q3i^})pSgK zhxjlplZFQWmei%m9L;2a$SORPHBy*j?n8bb4Bui$n?xOKWZu#O4t4*{WI9k&H^T*C zDtBB`oSeWpQw3~U-&N`C#2QIG`6Q{+Qx{2813P&DVjk7w6xQYhPPO{eDpIx77YIW} zXjM{Q)MQC5CNBxQ{Ff9EeR)!^v=quI{otr9%A{97yLMI)4cP!hL)t7NBrk)YM3Pya zfQJk(hTQbw2anvm=fLlNcVN%W{0x4^Q7~2fn)o%G$LSQAhSCAFZDFBk08Q)i*Csg( zd#>f?3Jz=I#A3)TE{Y%i{yq16KYsWK|6TkMB&`)55ik?&$%i179hA3Xw;(f6#|P9V z<;QCINe+V}7ZgbRCQJs2_V0j6`mfqMLA(7dDc z;TaKg8(<5fJ*CBk*dR*KI7nK_O{TF@s-)mKmBIrB0m>`VN7|;A$A-%si;WRwSMeg)V0tR>p0VVfD?B{j&K074Rmid+SFShmZWZ_5X1 z0Q+Rr32OaFT7o1fVF`twVfiun+Q~%YDYOCKae_9WX+aS4Eedg{dE$bbKpr-$85u2D z+?%z~^!u1EkNf`!DtO}?APOEhB0#@e-S>q-rxNDb0A_Xw)N8lFLiVL&zCZqqwM4=afBsl9G}WBk9pC2e7+C1gXhkM!;s2QnxcRQe#Q#Hb%At`NKB(B2D)-Ey8vjJhLbSDeGY(2bUC(j*tRLUvin zCJjRbtP+T(q2y52zzwUYjB>8p5;WN-=r*~U>@!HweDP5iX@^-@RtI=2iiAtD zhx5t4$cpC+sj3M8X!*C39g-9ni#i8x(k)OeS$3*$0K@@^Z7G0I1+A!k;C2vIpfgJS z12P+OT~z{IZ=zbgv*&f)(G^6P>=Gc*0{iQTR17+=(qbKKm=sZT1EC9ubD#@2yy!xW zaVS)xs8Pll0V!lKmXP}`^hXib#J##OYy3{J4w^F=C9OxJUQ&+B0)TQ{~W^PHz+%;ROJAA&5 zYVlVp4px}^kNm0MToG(ozP_bp{qlyd*A14J57sGvL6b6T!?@SV**K$!@qJJcVm=n zph_TvEPHj~+-OiZz*O%flFvp*qlNwwqd^IP&d;UorVPbKth%%aZ}aS6cL|v0wM_6k zo@^Y!l+!R)Pw3|aGyfy5{6HY8U<{$LwUux ztya@|N+5e!iJectjfH&0KL;*Nj z8U*bSQqWk~n5k6MEWy5{lQ!ZL#iIN{6pNCwD+V@YLc_7irK09!LWx18>1Ti$AF9sx zv$%?p0QQf-rWkgC5nPy+3l$@cM212NYx@$C0obF444a);;Q-2ZyJfvqsZy(AhISc9 z_Z-mR)x{g*S=6?r2CVCWCXtK=_Y)L4#AQrOX(Qv)KvsEsTM4$f?CEOjZ|_IFcvL7W zZY^omZ*!TI@SCOX*f~p-5vQcv0W%ZY+`gHzcgRAELS7xDWr_D#c#h?h(Mpx`IvY3%q!oIHgayYLE3^2Ci(8p-%Luv(+A zBX;4OmLVaMWN2~IZCpA##BvX%+NIffb7KZkD(6hu<}@=)3Z5h#;jhpTTG>)|BELw* z;T-IMI-lE|LZ0Hns$8h@l+vAPHRMW7eKBPzd!dq6=UJMAs?e5kYc?ut04YwVZM|JB znyoCVhGy3cM5C+@q1@%i6^zu4|56cH;6A2l{$Yu(54v`_d^Jx#p%@1nT`nQ#TZyC1vR=!_KtF zYeq^lO|!a8+4gl7koA44~9M4r_sNBv#WEp2Vs5q2JTWC{AigL6$NOl0p1BDSmsf2k7j%ne zP~@E2a;^vQ`J5Wvt(<-#jD5F6gnx41GB+GOCbdnB3YHb@ic5^ASm#q4&)6)HV$0%1 z&0=HQ)hic`E*@PlpEeY3TG+gBy3K9#m3d0UxGGx;&#<}eENStkbouV-_Px!hiUd*? z{V7}F&VmTFbrNkwQpy_GZxsr3k%O&N?if3RKP@JpKOGF}D9oa4t+f`(`A1E#j z1}Ltnx-!rZBtI1u6fCvSPIs_hig0Vie(9ang6dkukYA8t0+Nm)n`dVx4W4q(XkGe! z`3gYI#zL%xODe=^8w?b4FuJ+fCLpeUVN$-(ba^{ft)Yy|NzIy!#Mo1v@-7{yD+MUp zY)V=n+ysDp6CmF?&2s>=SAE!lSqsm?xj+r*qh*0(SXRWYxGAsB^fd4hEDuxpoVwa@ zOL+@oqp(efJ>&>!C3IY>FJG+*nQ-&ER!nD>_#r)A2H!X*>2@cV=A<(6>K7K}3!Sb`wT;LuOnW0vh&^4fSKDM&Bl0*=Eggu= zEVx!70v!%3YQBiAG$1}{mu%A2XVDC%Jg?eT78o8)p%H3Nl6O66Nn@SfO+U+7<+0T0l+sMv-g3>QuYI;; z(#mNK1f5Uyk+rg$6zb!$y)s`pHIh-bB6(W_tFp8;42#VeCXElKCn){L5O?P(QWGyB%UsEMO&r5E7wM^ReN=s9b2PKur43IHzpT zq*UC|`Py`%fz%lC=%jm2aQtMso^&!|j`C=ly`84@*QxRa(jw5&PO;%iDVe%j+T^LE z&EJMGQysF51$<_SmtZ1g|Y?*DlTo8Z1=Wu!q$*AXBMw$Xg0z@_cZ(LkyT}5N9keDTP2-?2FR3 z2-!&bR;BY0IoFkPXrM{6St;`dt;c9o1SvwPq4v}RthHjLtlW=i58RH*1@w$G4q59< zegs=kp!gwd8JlK)imJ3kef4!(6_UzwB&B&=4umvBctlM;p|I_wfCdVJ7GVxa`wH-s zFb8RX_RFf&2RY1BV6X0{75^_?%)HM@TAP^~p(Q|NOEalG^X4=! zXjxEQNwLg@O+`(~0dg}`8Ji^?q_w%=#M_ zDw_{^Aamgk5gr}o0=p4V#1+-2Y9lTJ>?RmC^~7da$h+dmE`maZdLojf#2%F_MWrmn zp^sZ#P&Gx#ndt1E12(m%meO9e+69%RsT>}ndIm!)hgS~tEr_U%X z4MN(?Sb5}o{&>vxQ@8D`X)3MAHC<;ZY*`)cURnw3O!cyPL#Ho@Un@sD1JxeuZd-Bt zn)#h8T8m_3Zr!p^ek=FXH$>xqYwq4rTh(McX4%3ulH@6CcNe+K+uO^7)plETSe&?VZy-6HhTt&69l zQb99B&{3{qB{ncXj!+AMeVXc46CiA(dCwlRF?|G`jp$hVJpRVC5p-O{4fcYLt3~Y? z1a!(0Y@K97L!}k9WU$b*b+u!VzgFUP=VYP05jGM`DQ}c07Nc$?s8R|a8r<4nR@T3D zaOi}NBJ(w7cTK2vPI2*^+E9%fmqi^X*cz5?5K(^B-QCrN!NS_X>gvH-oX|BQ?3BKW zSid^<4JDMX0U0c2qf(E+Nc9L{C8t6TdL&{cENSIoXkVU`C$LZxQB#u$)7_7PPDbnr z5DNMVsl18-d$uK2l+m_1m~PaxpZvXvk)POJWWL($nH;$XmqqO-N(j|E{L#$APmJFH zt4t%i;UNlxrRtG2kSxu}>VkbxGC;Hd9uO@UwAerpxPun84r?;FCm0H+Ub zy3~j0E-D7UMfbGs5hzLIb0a6JznFHCG$-~Sw2L?`v7L#oDBE;UQFpbEW(Ld(_}d;l za#se>KtE}T|6L+F9$+U#M^Q`0gs5DLQo$q@;mA!fSx_nyRd&GH+fXSJvm~m>XarzH z-d?n)y|t-9DUu`#zYOpJH639}#frpU7t;(^SL~%7m;6F7B#b(!rzl0VH^*ETec;DMRuk{!CMdr8H*b0YpX-3F6DJQ68ku& z=5ow}O0@l+YcrF{T5T8TBe%^+Dq5U$@|@s8k!e(PQ04^X0tq@(<^%yNZBCH2!Z#^% zf_f7~XNtU6<^=6$HBdg~p5!?JF4Sy@ST(A8vOqE|J7_;7imskgezaJPq*fOhBD&hF zTVdF=P-@H#yyl37)*8uC)e4NpfF%NeZdJe9lqj@>Y7fUuTzYt>7>PxJXh5~@q**3JOx{G z%6!AeHAcRAgN&D;x^Qq}WU6A-oXV$>y2Axal!T&OFeN-Tj;D+lQ*Td+LGgcakbEs~UXo;O5?7>4HrI z3r~;6Z+ODjE=tE|Os$36U(+!cxR5XZGPkFRKmUNBnTIG9%`v~WJu(LP7vEvZB3 zXl2kT5ot*Y7ucj2ZdsENRiltthfE)eUD77$>4tqa6tz2O=z#?r2Nvx}4BdK_O$=S?jzqep6Ziwr z(=v~rbhL8FmRpjvv^M15s9KsCRkO?{Xlu-9wivgn>Y3H5>1<>VQ#XCajz#zxIZk*Q z^}bljqhOmf4c|KiO;wv7I;!jieNfzu z0w0REAuX2T#@cKYRin7MlSfGeBP2wcq0){{(BCehhiMP2)F;0ze3HV)CO34M20jx? z@tP^n5Q(CuUdP>92vy_05j^dyRDN85p zMgxkYM$%E7) zfnY;JFwg*isPx%4NuQi0?Z?*b1bw0oBKBd^$TN|OFav!;=N9y-gf{eOjp&H{%d`@O z-4p0RvC7#rluYJoPoNT15S=sVvr{R3x)7?2sN(;oi=N6XGG2e^!CjfDU6C0Vs{W}qG4>@mG81n@c>=55VI_48BE;zw!J!Z# z6la=^KqrbKP*4OLvLdJdZyJp*)7n$Ro3!Kx(N(cI-E>_|+e@GiF>sWAK0Mgp*FCSZ zy%j}V!@cFb|73tuX3V~t&MO&TO|!$`(DICu4Z%%blf~}Na^~meOTM=7{F-o6u3RjF zn`V}F3jRA}C9bK54e(o&_M02dp|~ZR$qYn0?GC%skrcm#%mT3)wTs!EE;}icz?vu~EBw&PMA4O5%Pq&S%sFx%FnBdHBz{`~*g zdlN9nuIgO$?4wz#Nu^S$O7kq0N~NJxDpl908hfms>~>FXS66%JZntf41Mv{tZVWcI z!I&_)Z4!cmF(e_u1UDoB?uC$$+?xj>eD=i*863Db6CoiGAmFZk|JwT;=}0A2soUgz z@B7{}bXTi%hCQsk_Zt57FOF)W7x4Of>aa9_e#7ygxCS;xGRYP|YQCj3GMvkzh~9K) z`f7nqo6Ff-Ho`P*dV*F#P2K(7W1+&jrBY3d*FJB4K)e!(%ZyH<}1dIV&uRhiE$LJ zxmtS%MgT93f)a5R0|{4ipIl=LNx1_Sf$+_x2V;>S$SL5ROSPv23xPuJRmr!kdgq#` zy=+P$CkSskNLt_AX0`!zHpR}(OS{WE*IBw|t6JYyYw^-(DefnrPV$L-z2#f)8)<0y zYV|0_t>BGJK~;m=z#7)5PU^f9mWu4+M}=M7VBTR4QBejMJDbjXrD*LY^Ntj233Y30 zZDTZ~iip~Lx=jE>jcz*C*FXPBbb5nHR>y&GP!rOe^bvu(p2G1@fF-q}Y-M9n6%%EH zsoNU}=*YJeDs7AVb?2|{0fz}&BT2I}Q&NCXB9*pK3IxpfOn~yQaE0L_xI#Rca)!y*a{Kj z=*Vz>aKP{FcR72yI;0%Si3+DY+TF}KBqG@gDWmEjyAgn%?rq01>vjjTYjCMx4Y|GT z&d_t;;0~kDP7e46+B){1ovLw|1#^X@$>4Q%x%Af=*?*Gftl}Gy30G2*rr90=0hy4j zW{cTs0f<9JabTWlX_LTFYCehcK?RaPPcsn^4~H$dH&!V{obu`fXlhQ?*s700Y2Yh; z5`~bKHh)qb!lZG#kZFN1JYvkSJ>dT#h>F_FijG&`#D-5I9V|x3lBtO;W5YQr8i*QF zC3mT1WXa~M_pLmUv$Gwns#Qv+J2n;X3aUg=wJyDLV_Ap87Ki>ha7Xk@*I(?w+>&36 zM%{r(U4*jy?# z7mLmV0FA?qk2~iTnUnUjYn73oU^a6SnWTK=@xdZKC?mfG&SfLqp;%;y$|6RkaP`1D z2*v^gOnjW*g){B|L`jGN($WLa(uLqnHG2S&74WA(OWEil+-HgCp$xxleOs_mwek?E zdC~BGxar{QwVH0Ndyuvt9D24ePn1BrcFgZz*grirHkwJtA^~6DT>o5^7D$1f)e)6o zZ%}`81){KXY%$&`Sb}_EumtHk!i=N_Yn$}^pmUzo)&RJj2f4JNV-6bXVh&>hZ0 zDPQPun*Bsb%DDWV>DlnGG} zr6D|3Zu2LU#~R&?_dye~7;mk63I{VNJs^pR6o$z`E5M3F=|l{L(YpZ$&YPjIQvju- z2JLbcBBB6brmjpXc!cGZZ(pS>5Ef)qT4IyR-jPL;td|!!0}>7&OyYwg1HdX8^f;q| zQLLJxq*~CaP0W|j|^#LGQM@XIk zC#~I17)$Z$%AH7ffU;pW)IUPoZa(W{B{Go9)1{@crQv)!h3p&`+zX@aqrylQg($0e zPFJ}*D28+L%I3aeMZfa3tDApr4`ih2O72Ml^WUS+KR%em2W9>Xm2m`xAWl`}J-0IZ zwUbx5^@=A?y|cB+D~EGgGFrw)v(vfh)Ic=s?jv}jK4ZvmL$@8d&Who3<*W6hZ1F=^ zHd(N2Uip(NoFCuz7;g4L63|olD|!_14H3tH^8D_=}P*y9L}$O zgw4dqgTvmnzrd5IxP&AFONz|dQy0M)!s~?7QV*e-dKhGa{S&QJ*bbqsnL%HCO1+mI~*)IK%eg|F0c?bF()x~c80z=XB9D)ZoV_3)DMVt0LM&3a^sL-{>H9R6JJF8s$lZ|4{xbM@ zCD$_QZ-PI1H~vj-86BjR5MDbkh&wR!bPt>6xC)cBAiPI(tBRtaCMu+^o^lV~K`p0P zaOKyB%WWWHHR(-&a1q_BgNj+>C0~8*-go@)0!N_!8-JaA`I~s|0Uo2bN=IwrrXB;u zO}VQDFXuApLIqULTVc1 z#CSnatX%q-=(+APq>tU8g*B)O}XYv_>(nXLi1PHragVY z`DlH|m4Ms?Z zghG068(aF<3o?1@o>ase`_a_pZOqK&J0iWWY;!X`+!dVw((V*Kr_g%Q;-pVtXkNvV z?626EevB&$GB*%KWl+6UQ507ylE}Y?w(jB^GPs8PUYAqdlnLurFNQUFdJ&Q5Qe8a# zZtl42+cD>J)Gs78{f=knK_Dgh^1onAm$O%0)!N}%;NsVWY7+Z+0O|Uf4 zp_8VC1u=zr1^*mt!IiCkn)ns*x%NApt@sW_p-{VoQ>kMx0lIZW63&GSAOD@Ne&dtB z2hD27SO0zOZ@&)>KzXN@XfS>FLSLxHxIm{g};e(5a_4ucX*pk(pK><2kA=^ z+DVP4eHmH|6cGFt`{hCUweVKr*NwMf(sMvOWm>qf9pGbnmF#-?WidvW1@?Bl&ju7a zDruTc7<)=UP-kiLAw&5Or_iQM#gYS);xyUszx{Uh#2eng5<_=p*vi`9=FjHVp5$`^ z{ZO|A-)2&}?o(DkZ25~QnY)+t7-^N4#(}0`GN4orKPKI{PiQ$JXW3W%@^BfYQt+r` zq$ko7kJ_p1D>~~e@bXEn1`2IeEg!#NurEwZZnw+rgR|+sJQe7RMC7KBjmaR~2 z8(dNj(6<~cOlm9*U*zG8|vUaMzZ*TDTIRI;WBn9#$!QJ7a#nZDsD{8TB5@U{AJ z5P)AO8I4AQ_6t51{EW;o+eI(@4K&v@jY%Apw)ID)>R|vgycK-9PE_$0M{8PK&qG#q zM=RVCCK374+(V~qsd~!VlxSCjQ?|kF;Lm7!;Hpoe19$zc`srwNN;jGrkx#IKy`d8pH|h~-mOT#VN;VIr?)SU80``E# z1j@=zrF>m|s(x)+$@ar6bWaKK@ayWl4;v* zdWQj(H>kK#r4(~*F}vEnya}uS*_!@PPegx2CP4E}q48?J$l&dj;?g}emUP@yMIiI# zUCGy^`&CFDhA}o;OnQd1=8EhGbDO|9)k=nqOGYJscaTd4-9?h2-cpf}nyV4iNG61g zWYUS@Qf=M12BJOeEIB$ir)W_LOEvw+o)d0QA>fT%TAOY|OJg0@{P&{ZI)~EVB9Fhv6 zyOqa*1+`u=Q=r`RL;>Yq_rS(oBu#CIh>=NK+fvHSV^*D1CG-b_oaG*E74kyY$Qed+4*<@*;1TL9Ge?se1->5q2rVHv1>&rSW zHyI=DhOa|mH$7z(y$t8DqW=y`Q{{2h9bVOhOv(J=5$o`O2QJ8zI?0Dm%~PlSG=MQQ-N+7F?$6x9+cF&*;V7X z=90r@*eeuD)#}W6kq-U~ab$sca>EWY2P#mGH=8St5NeHyfgTLCu*UyGNrlg!+}emp?NABH)~4;(i*1|$GdwUok!(lgrDobyp(o)}S+Fq`PY* zAu6WY2n*9_AWlR@BFNUP@CJCw;TnmMl*?-_f{{g=Khb?83j>8(2@xR5)+D6S_SkR) zc2FT*L<^NIvgsgbHp&L_^LsV&lOhch6Mf#K**Yr{!AU$Syir1c;I|TYNL23{TN35V zDFRn}eL24VQbmMnE`$hqNrWV+R*3kX3!ol16Dm8*W^&X+m9>;L*I14a=6tF(IRbYY zkAYGJ!f##7fI^bi9c{-&i~$fqXC(yTjkj&pRRc+hSx8uQVABy)IcKY8}W}QdwW~Ud;4Z1@3ggd^|*R!gR_y1 zrlM)s_Su%_M_vQ6oA90E7YBjrr1>G%NMzVyUo>N1izEM{2(Wu#Lr9b+a*?Xj=ufbt)qpQPWCe!hv; zx`E0nP=6b%tk1H=-)Pp}Q%8?mhkAxE@dk?-Ll!%MxAtX$_CknY?=vVo4|LJ_8h<_C`?w$ubIxzP4LFwvyYuCV$ z_}Ma)toHPE@i)xkADtOCoQwaHF17G^IOL0o?_(Diem5SoNlIhPUJbv1jj4VksYYIq zOJIbvP2ET+O}qdtLIF7d!rSt#b&(tO*1FB7$(uKd+^A1#d@Bb43u^x1+`w3BOi=T; z9J|?cVZ6IhUH8{DlRDZUv}V7$KQvu0x-as$HM~I5cgt3gVX-(g8;Ux5Fc*WB&C~?* z$)p3{zS*ostlKOoF#&c@#N)U{pq}yS{;S*q7rlB1@b~IJ>u)ZX@hXeXfONHu?}MjF zeJ4Tm)PHkc)KEUBI%P6xLi4JmP6$LLS&~9%6$@mz*TnM!9a1ES+Z(qLaQ23RFF4Kox^+^g=R?lU#)xFAJb*GaOir{qJ*9D;Y2=FJMQJQ)pw zGZG(w)S(XwdfvzTMCcurFA^Q{ROdCsF>KY23-(~`H9!fv0XP!d5KXQ#8KV=yU~iO> zwvOKK^9=~+*Zxhz?Ds{e=J60R5;@~85d~iwWFxh#eiUEA*~{LIzB`ad%+UyED+83MwxQu3xSbxNR)=CV5AhxMI{Gu{0W04IF{~p`&?U>(rtL3 zarxkPAZLVgdYbzIT+)MO=(?zh$^cWr4o5l=Ae$8z0Id<=E`R_*wy=Vzpg9BL3=&KF z&-yFMe)_Ts9ntG`BI2w(0{UbJkRvX=D@MQ(ggQhVQC(`il}<1>pi^s~ujz_{7}0bH z&6T0sP@f1Z;+CE*^W-dIq;@cP8qhCla|o_GoXSta1x*BK!TQPj@B?>k-Fnvp zFV=Lt0uj-4ok^L?KW0CGObv6+X}tmS2u^e+8<>0!YeE@$E?bHRmPju{0Z%{A(;Q~Q z3g>h+mt!Q6pd?K?3j9RF%h|}mLhf=2rHS>=U03KgJ!W(!!>Pc;w#>f#@UhvceYrl< z!)>v6!XNQ2?m97i+!`$wqaYR!+oNqAmdMO-ZdW>#&xbsofW4>3*wMLjYi{=tMlgpy z{E*AzXqAiFXwsRCy7K@Cn+PLZ70HUi%|#Cujo|PYlIre&4Av^>+Uq$Nbt*~8i#)ky z)G(@bHEB{6Hyw>@;BR_n?qH$M{Ftc+N@rkvduH$O@Ugk6y?K}U5sXMY5cMw5hzQqH zYr~?Bi~O&U=`GURF2eIzLF*xukKf9$-}NSgNpAr8p&ImuK~kZprum0QLGY|)>+gZ| zk}e6FyXwuFI}Vq7&8DHjB!D+OV{eOji@h5b4A9kFZX-aywk|Z+-?eq=t~cE?)8at= z>wyDLU3$93k%DYUxwW6OA7UNsmX4K=fU{b^)Gb#?tLr4gte5p#LGhtCD2` zY4!H4#6grv2Z8b8q}98@=*lBk@q7`KyM&eHnLqhNDWeC0KNUH~uQpyg8v1IzceFp; zIf#U5SFo7L&4Ai}6ZtuQq}SS(9}6w5{oJ%$r}K7nbc4g%GLRecZ@kLpSIhyBSqDmC zEF+8+arwVZ{+U#e-e0!D7pZq4aH#6H0SpUKqMpR=Asm^|Ip_eQn1~06R1pA+G`ftZ zuquZYT}oRnR-s-(!l3%px@)jOIJ;!StZ6iH+b{2mje7ipgTC$O_r(f1_x6RE@om$?TV@MWJ1+$1Zz83y?Zn{SlYK|Ey})CxaHb{7$+b0rMJoNge4uoKdWir4lX|+5%G72fe65k3A{Cn0D$6Z4QZyV#oRQ_Hcf{L}d3EQV zk%4ais5Q2w?D?mA-u&i!{!b`se1nD0mD+}ov{0VyV-~a025jawt87Ar?~^snF`F$X z0Hy=Q7;XV*s?Fi4`NiZO-k|;ad6S~l!tb4#qeji0 z*}|Jet)z?`S)#UmS(n3^>LX?p)aanr86aT>oI!*Nfrs}b<+a6$uvhqA)E5QZ>52(Y=%D$z+J1E@^#F!*+EAzzPSg`5f;oQTTssOXMz zr0DplG@8%Sp!P@Ou?UT-A!5Q|$-Ov=kg8G|Hq!pkq#JpIQ!MC`b}uNVQtcX{@QamO zUGclSCg#1hVuBGL$C~a&9$c?<1J`ygIw<96mj^%f_^Ig94svXvLr^tOfbLirsb!b) zTV4W=zET2O$RuPE&5zB3WS?HvORz;0HHq^q0qP>p7XW#>(fBuG+oMk;6QM-+g$qXe zR%>owft_0WR6L8cN0z_z{hfhPE;I9Jr-z^QZl!AAMeIIEbnxJ(5kJvVQbIKdW`f0| z%}_K?7=hj6$Gy<#ID(lJ%|@405sk{XeCMI3C?Q;IJr4Mk;FstGB3|z{xa$CofYWoC ztM-ze#9{wE>gqsT?W#D`H|{Ut7JbqmD)tT*tmKBHpEFH}4<>w2<|u5&4vOuP>9K}j z@F^_e-OUZ{aR+8a;)og(9~Zd??MW22GLNn8$;T*Qp@`$A?1nI3zr2M%~Mek$V;vdR_> z)IwR3@W-Ao+u&^>(kca$(eOe*pE-#8giZC{#go^FeOuWDlv53I(TBqi8DZhRuy!{f$VQXhKvX^wTjD>|_Ryk$7#r;n|Du5{VjtG- z6(11&Yq(eVH8Dr+P5^<|Y!)zUl{jOgYs$g;uPfVyVq!ob`smEkZW#Q$A~Xlta=ONg z<3ABcd{gZh@C?bPHkeo1BPnOHAbnZ%JEyFSoOWe^4G0ZjXX}H8+9d+-xw1sG;EyJE zRMtQRzf=K#w0H*w$xYagY=)TMB4J;_Ur^$M4Y3~rGft=nle5YAF`WkTfmF@Ot=X92 zY~6r14*)hCWpQ3UD8C7){j*|(QCxwamtNGdsq8%-?863GZ{TD<%DIU%NI5r2Rn~s1 zsJ0b-kgK*HH*)VBWMtwD4X^;IgzHv~0{w_Ys1k|xFg=~TcdVpuaH1k;Uz}K*5XY*r zR^GwS)rchO7G4Z0=V}nvP~}`D+Ikb}2AswYq9nX)mV=q<%OZe+9hE#L5eS%YVbgtMLs22nM(v(Z9jwYaqP=%g z`ks)JEy~`z1w|<bYOBOKrp4M!_Rw_I$^c32JOD*1N zwJMrRg`1O#5#yz1;^y4&t@XEJ>2X@lG1MZ==Q3hzp7c*DTQijtUY9bz;nrL?+-nWC z=-V3&f2;fS`Z?io)G;mF04D4#;# zs5}X0I>G~RnbbY8$s;&GgLoT93i;d+=$uha!rwpU9;=PGZcq_)-N{=wz3g>bz;(j! zZufw|@%cs*ye@O@olO@TP9Vh&;OCYJS)PiP=TTOiN7Z)7A`HMP!jVP$aj&Yhb1Q&i zu3~l3ndQR;xRPAInk9&XO6zEv)|%}tzQG(0J%vc z8<-h@*0|%=%)@JcT^Sl~4buU2k;)R*j)ky_6+Kt}0!G6v-6iCEot{fwd(|53MhYwu zR5b3^+t|`S*7b+e%{J-`X%*1hx5;JLB`N85cyG$Kgk6?oaH8=LUndH)DJYA!_h35T=ZaC5^;c~Bf4$2hnufj<{& z8Q3Zts%7>@G#6Y$(-<`h*+u{MdA}ps$n{afcp(%vRqjQt>0#+zV%D>Y+-Fs}r&*6- zM0v$%oR3f|;I+}7EfpKMJka#IihwI7Ff3b9O&Uu2U?LU{d53+&+Dg(&vOzsIT{yhq zF>E3zETfiyrqa<=HX47jb)wU_QuL+64W&t48^q(AW&L85(#qbSW^i7sd%uzeU5Aa) z?D;r^wR?W!ENJC=etgniUyJA4Gz+@f?Hgff6!2b?TWN0((cV4?F9hG$v+iu|zP>>g zbhCN7yU}+32Xzy**-ozd^uSyMXYd5pyHN_2{gkE8c2R7}wM>y?(0yQ2;8c%#DEb9m z!Qm+AzVSyF-gwuOUzEGoo@6J9$&2<4JV$p7&uNnq<){rLA~*NQ!q&lgB5+}w z)Mf`ch!I#i@icm1M5p`AGZ$7)K6-NH!ZT0ba08zFQFe&_T>3s6UHhNpujbF^XHA!M z-KRUPCb-~K{5uhnDE1I%CE;ud3TuuFZG)*GoviytFd%g4yU-jsMu-oXlA#&zqS7e# zLkeLZscD)L!mewe+CK6XI;1~5L zi>P$=(-FT%XM*V|+;cvy+YN3_SXm&lu_4=x)A^KZzIWGuk7c0HjYazqDGCOB-dfVJ zjS{44x+M>)J~)+q?2|P;TZr?dU|rx zu0Ou;%XdHd-`v}8{i6O}*^`i6v^}ipwja;Z`sMHA>(?dS@M(L88G$8r{c1of1`%pf z8It-Hh9nFvj}+7}B;iX$IShISDm%7W^)7>p04c&B0WGh-o$MymSX+BfeQUV`VrYGn zD{ZDV?Uv4pHQi&kAsd+Dt+b{?2x;I{2ZNJX(*%-%f&+ z1@Nno=N@?M?#_0T+w7+8){ZQ*N)kE_g8Hu9L92V~()o3L;Vb-=e_YoaXs3L>#rhtU ze(BS(uvce*Ni5cPur?)a6HD=413{#zhL$YE1mLww&dD{k=y%jiE) z0cGv-y1Bpx_R7oaMqh0=`DAj^1D{SMLW1=I~C+a7mc;(ycZA56&2FG8qvHGaCDONegme zfS5spF6VdEyDrw<2}pXyYoXi>wM1mI107BeAyRy70Gjtfmkp3En@q%jKXjDsL?N8T@YrP`-%GcM8Cqss;#J-2K%pzsWX)I8zV!~#sS|K_j zqm(qE1wmGZ-&!O~w_O~TMHYhb%tB5WB$p-jskI+7>nT>a>(ttp@4}U`c($$+tD;>3 zZW(r*j&OP^-ZH4Zgad7KBIS}yUw|eQr9q3RY;b`9RJQBeyY0~JDuBvL!&sh_(8sFH zlaIg~@q=n3)ixpr4f@O*1%47mHo>VV@RMYFH-JUzSI|zr%Ntjk!Xg`7q@p6bOk5MS zf=iyb;5Dd7R%!fa1sHPer<&&PdH&R?=P!Mt(gC&oCg?a$>CUpX8)J-0t$d70#_K3F zwPQ$LAWn7*#xqPj-~_}{+~5i{n$u%C+emEDaB(QneyvTt)KyykQ-wwGhnkijuF%SU zq|$nlkwM@hx`1DcNEzvF#cMLyCL81knf$vvWt;MeGzZ0F;>W@T&J&6Qc{w?$69;XP z-o^8gDf_hk0v_HaXcwdo7acBJqCK%lDq@8766*k+CAa!Za1`*8>b9*F>NBj0|9a%(< zO?Pm($K#BRj>g6hXV;!BAMpp~#_XL2sBK_vMBklUuS)>O`WdD7*)G|nPf*1I>b>#_ zjv?wj`2%6`>i`Wko!2ZMoz6i1B3>r1!d#^UvyeopT#w=5$k4`DcGi-htuuFjNs3-%>1O(gO*fr&?u1=za+|DW{1vk1%p4-pteX)3zKmmQW%N2!A%!(M@9;fOaI*3#OM_{{}LqLBh5%Vq#r3W zvo$H(%rgvGLnP_SCo4WF>wFm6zX-$X+u#Ev$g0U?S;dS%&{5iMg@SW9JgEFGkvE9Q z25ozkAl%BG+~K8E@1?tWjH>QNvrT6{SbZ_Q)4GE6C4@z)cVfnT2K~a^^wgFTm5>Sr zdV4s)n&54BpH=jG59aXO%4`#B#UXRo1 z=?!Kx!7G_R3-&2>UG0~C*5b;7f&~g*zOroXq9AsQD{QpIJJ+o=xGL3Gij}tR#Z6aS zWZPCI?cTL@|F->&SDupTt75;eeC26ROLmp3@HlZ8!04$j@6*z3uI;N>Mequ&TbHl2 zxGn(`Zo|n7n}|YnSF|uot=?LiDeOZau`z?R;-lKDFVKvbWA+ zhr|q0MekbT1!E`E+l>0t*kx_2*x|6%qM9y81Y@hEQrW1dV)k%aRWd{uukJxS9;_7J z77wF+dr3S@cm4{SRsS^bh4Jof^dM`y!FsUTOHzm1R!{>G`+Buq6o-hCMSSe&!Rz+# z-MM{fVSFshQTrMSh_217<5wkEj5V^O(^n~K#!!2&$!zWIYhcw$?psqJ93&(z(H6{+r#B#DFMML2x$n*BJY7U#;NBZC9+DB`&}cdpi>&<)$H z3D?}K6UDn4rl4P?QWEU$clg~X1^@~R5YuJ;{Axt7Vn>m$IVBaP?+6L(q%hL3LpB>y z01Z*0I_+;v_>CfoL!2`eTr|e>2%nqv1~XY!kT<>FxXSrtN!JAbnHGYc-~1*HG*=2R z6LuX8WDxiV=|&?%DMRH|)wk5$fcZvXDLX{*PfA{IvkLdNCTw*DTK{VVN#MIS*AQ8yW_ zMw1m*y`sBwHddq2yow1B#}Kmmv}CMXTEE$1!Maz}ZxoUN#6m1l; zsa+}7HfFI}Zr*&wY-#=Gx`p6@IY%pp9-v)0>Z>qC z>Sx((*m;%0+f{Ps?c#a*^H<)u`UjSGsLz4}6aPPlM{=8ncUNlE-D*$k9!6SLm`ZEy z+Of1aJ2O63EWrI1@kM~VPz!Cf%=df+vh+u_Dt;~X^Ltx3w7~-1P^||GbgKsh_Hfmn z{B7v-0kC@SX1}WJGk#_=?o7)aws~gh;4-KAO*?+0$XvOL7^B;aCJ2|!+F`YIoYyMp zom%E-tF>(vk^y<^=&*qNTP2Pr)qdfsp3~kAS)+%+u0tv?%mmti_Sfs;>z_@wu!!Py z;)J6BBImZQwy^%mH5uW0eM&Aj(%EBwLN_CycUr^S`6D_P0GEV-B#Ga}uH5t1jGfn^ou;NWcs4lu)DQp*tby(R# zYBmD4N~Cg7l#E7c6)eAmRfVQRP?w>~#!%fk_@!#-zxE~#WB_StP=IdMyr||r4jDi; z{p2|4LZbnnyRV}SR52x1QVJjtwhS328;)yDRtl?PGGuA{yw4FcH63Pmf4{G-H?;O! z3P9ucc@fs;8@Zaf!uD6;8Wr7GH04tnVd z9-#3?1Nvn}t=aIF`W>Pipdu3snke*@WVc+&HZc^RMy^XdHXV&l$9jXoUSzzSXgdB? zKu41-m=Coi`9lA|e$VlLlhRhUN7*A5Vn7m1sz?ImlLkpp0Ij#pWVW40u%S(sEVT&1 zYH*)N^s7(;GDJ$P-dZ2A6j+ftC^TeZgQ-ky0Rq�kvR5O(W``RGwdFYp4&am@Vo< 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 literal 0 HcmV?d00001 diff --git a/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-Medium.ttf b/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..d6deb87afdd7ddf003f3d0ab1070ab9df1df0724 GIT binary patch literal 135980 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 BE|vfQ literal 0 HcmV?d00001 diff --git a/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-Regular.ttf b/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..dfa34c09fe2bae0626e00aa3265af2119345c27b GIT binary patch literal 132948 zcmd4434B~t**|{Hy|Zu0WKSl`OeT9K>ts)wPL{T5vyiSNZE2HqrEIkSD@>Hqtjd++4VlFZ=y{yv{SdOJ5W=bn4c z^E~G{&wdzZjAh^-1M?29UAY2(aVD8Z@M&2wxNLdJU7kM1e@K5ny<+^dwO!BlWHSCE z3uE;gSFBy%?_II4f=PTkW5%~nTieh)y}HDPE*?Vrscq*>@BP!&CqB-YZ4fUwvF)M@ zyxG&HD#nT(^!v`eyUv-ZKXnCTX(7g3j$PB|@5T4&cpus`%dWF8+4w9Q_J=%NEojzyBS)QAJ&iJz}`2FI&=beASfA0I~yNrMH5sc^d zz590T{rUYHT#OZsp#9eX89d6a&b{oC*SDti{FSB2Z{r*G`hWlMtMvJc-Yrj`+CTRX z!^aGd;ZGBj=v(}U=j6BN{)TtFjNcm!9}_((zlndPH2Mbr?qO!-3s%EUXCK4;11^X7 z#vq->Z(&BXIw(!xn_=aXZ(+^6SF}+7xtFo?aJxzSZtS$NW$YMpmhMo;@JDXNkTU6a zDMNY-A6{nSf5BgGu)6;jmwW>Kex8lXX4W2iNk75&S*%L>HQOjY&t~J&MQr2$>beAd zrr}*Nm-K=dLot`wIL~1ldCsZd<9Y}8qqtV#Vz|8c?$7w_5WoEZV|x%|+KcN_jO)@< zf0jPV?DBg2{W`99@tu0fU3lmJi|cLHE?t9n;gZ{Me>d(Qz-`XmcU`s*XCNBS7+`@g>A7t!yHnC}jJ zo`?SR*NymY7T4Et-G=LQT+?Xtzqt5E0PCyK&#mZ3f33h6&S6>H!j|(awn@28{f=jy z`XlZ~aIM7sDSVdT^F4gFiQh6#{Ya{1Gj?pLto(h^)7*!svtFuW1( zP4!CFUj5y`MZe<$j({1s<#G;M!_~=8ux823IwIHo7~dnzFRts9PpJ*#JPqSabcGiA zEw+7#`Q@GQm(+kYxq|r@yZHO8R)0y|pwZQA;Q#!3m(|I0pn+Rh9pKZ$UjppzXYKqd zjPV9G3YuRoy@+=c?LP#ry$1J}q7Cl(9jrmz<8MC8s@R###@;#=#!gekesF3(dx%wm zzx&x8*cDzs^#JZG`BUh-3*SEup7jjy@&f+;5)1Ly(BIX#|1A2V`{ zpVDh=kmuoVT%hM>xOqBr}M+0!+!MrbHMiwb}wBtCUFt3S%dx#fES+zoco@L{z!lnIA&r%;ITp75ijR)5pQ-7mz z@QtTV@^hJ;UxNAXV}*Pj`uIKCe~poRLXr+Wn}vTN$>7sqcpIN*vk+vK0LIcg?VMLHX%^pI4>WbiJ#t9o(AeE2R3VYfVc23^KbJ<`G4|T`Q7|`{8j#a{vZ4}e^HY8 zxA=*1O;4(jBPqRO;zp;1N|3L2iEB_hiJdbDbY*1z$H-a~2vPZFM z?!lUQj1{maSP^>)>+&dQ_^Zsvo&hC)8!PBrtce|GW$anj${=sCAA-LB18et9Ho$(x z2H6{IDf=;7$^MJ20uLT$zhz@spQo|^X4^qH+rVRYahaVBdE^|d@AJ5weTX~RUhcr! z&R`dEH@kqRv+H>oJHUPHI?(hr+zVPOWjFFFb}O%EpI~?LR`zM$&hFxE>5B^v1^A7eSb`!5*H}FdKFl9A}p0p zf5n2SY}@9a@%VgUHW_9E{=vgoH3L)qbzvR|d#85Rg{6Ss=l9ix<$!nl<8oFu>mLYb z4tS@g`VUE21O11}<$`!1VU8Vf^C5k|d#c?cVUQ@vxy{GB!-E~Y(dJieBF`_DbrT*m&H`4Eo)biN) zgcr~_IPDGF#wVumjhBA2(OnzewM}`ZCMPF7fL7Qsur18iOoZ7mHTL0e&v3Yy?uv(} zpGs%jsKrx8HZwW7eR?v?YbPhw8BBV&V?O@=$-1yH;9ceo8_K6KC-cDgMA+=_4_o~G zzyKbZstcP0xB&_8_Cw~GelPt<)AcCBr+>rp)Us`1V~r0#4tNiG58_>i8jaaN5 zHrLdJtpP8M)`e*q*bJIryrHQ{YBDt_Mr#Wkwz}BBvi=%hgmCPEXhL!*z3^HLVE|K~ z@-92*pC+UVU^5RPB<%HIklHALTL1K*@(SkykB7_f6dsODP5+RK=-=P}xYNPpWk8X~ z@0+Xv5vK(XNz$_L_UXa8aC!h!@Or~(1FL9exB-U3>Ganc{FN^7gP= zb`MN>4^DZ*Zh);WoDmpaH*v_YeQ>fY?A+nMxGtO-7+yUwyjJE5=%CgjkZ>aT@g1mDu$L!j=aBkobr_VgVn?Cac zhh+LJ2plrdrzdd8NS}p)Lnitx3LG-iXK~<=g+5CHhphBj8vryE_=inXfVAIRALeJ! zYN-ncbU)@qe*BQ~W1a5DipY=qlpnnT7EY^OaGsdd6UtO+p8EOuFh4KGRf_r1ryuj9 z&oa!9KFcvb`mDhG=(7^@qt7bLk3Oq0Kl-e}{OGej;O!9$sUhH<3g=CE!SwhP(Zn=9 z!}Ub7je&4OZMXsJu?b6I1t>P*g7i;!_=&ME+5+TQ7jD*2e8^>5MidursySrj*~=!t z6bXw#9e5UM(-QEuijlXXH>ICt@oWOzC*f%6dzN#A(SL)z{*FT}Je%g)26$is37?t5 zVwvu!3%3XAb9?H-9m$%4u(zRkCs4$4%Dwg86~qz%+LZ?nuJEq_3!1>j2d;}9se|+E zEI_ymyelW1i)IF}({j=5kc0Jy?E|$t4%Yj<-kyW#vpc$(w_fQlZ1VSOt-awX;whok z6OS9bMz7~_Lxr(mvY$Au4NM#F#|Zq(r@|&+J9@cdW${{NS27GtZTE+b*i^yn3H+7a?BGS;w|Ve!Amd@V#y{j5g@||g2N6HGZOSTO}iBh zLdL%;C!K#6h^0q^1=NzTu|kD~zXxDi68R}?12ge@m-|=HO9_j;+HYbq3Z%nq-9){& z2OBz#UQ)h9rx8?yP38D&C4P#?E(#LkDR0WJVhfzVF1%EGn}EKGt|=05VyCR3I0XJt zPf%PQ&K;N-_h3);_Dt3vYUEj1w|!B+uJMdV{n{V(tM;tEzt|@R0^zP&eFNr$6sI!THU?o71 ztt@*;Cs^T`_2D+G@>L6bKaB2pR%W;zZAJp&PJE0K6qfiKHH|wbl-E5#XbaQ&(aqz+c+)dyPdpqtX z1CK*XqPvZ_Q@-0o?Kpk6nc9iFGpL=o+d}Qc-Bx<%GBlf_chb!?y_0Tc=$&-4joJ+1 zW;?Z^n;q1KZgx@|y4fYhHHf?2VqA20rWhC9?GfXmyR*c&=@FC^454ZaW=+rFSkH5~xUW;35bAfV8Z7vjT=m%Gi|n zFY(01$}K%{iE>NLFGW8~lrAq*ZmGfL$}Kgx0&ROE{p}ZjiTU$*{dY{x9e498#$u;W)}|1&Hk{zrd*1>ds4LuLH>)zA#C zpP;|D4<4$bzn`)&T#&XFDHu{>9bny3O_Xv~_8(ra$6#xF_8A zxZlWFo^fr)tC{;VAIdUh9n6}`z99RJ97oPr&hDIBa-Pez<@V;Dn|nv@Z}QUfR^`2t z_s{%_{K5Ra`48p)q`+3tT(GC$QBS|;R?oYI6NTR`8Y_CGcu&d3lAm}(-p~80eAoK^ zQo5(~XZ~}`mX|$J?kIn?;oYp<<+s`f8| zp}^&Vuh-e?&aQi^?nM3e`ezy{8t!VeG~Uy6Y14DfhUVSPKMd{*-WGhZ#ndw15^niR zYiaAM*4ftQ+6vk>wtcegz4mL`54XS5(bh5B@owk7u8gkdyJvgamSpq}_P(?9*3jlY z)_1)B-GNUIdIuj^wr1I@%lE7(7)l>Hva(|36T4D z+w9%kwRvXqLz~|{qwS2_&N#NkwWVXr{aY)xUbFSltuJkTcPekHf9ip$_omlOKR0vJ z%xl{M+aB1SxBbx_-W^AG-nA=Z*S=lHcdy!g^vsSkkL?-U^Uhh%o$WpQ;E+{^!Y#q(FZy+3dN#QvwQEVy#&${VkIe%3a-clNcb zDy};Bs-Jvh|3@DA$lF&pUVZ7+?_RV2K*fPKuD$=dsq6bcdg;OXgRkEZzH!UPT5g)T zIsN9(-_m%?Z$7^7)~s8Pf8zKjdq4T!r)EF(_uE$8cFS$g+-|x3g4-XuBmIv3cRYG$ z{hfE;`TSkW?|SLewoeB>eeS3KaChqDVK zI}hD+=#|6X!(E5R58rh7;lnQ;{`KLx$BQ2ye09Q4J=${g(xb?^=rTW{AA?(u@-9mm%kzxen)$A9*n((l~% zoul6^`0hF;!Oq8|$H8CBunY!5)i!Qmd;}Iji5sNdU{6`2Mp=Sf$B| zZ@{L00Iy~TV0kpiCc|zfNm-+2qa@4O@|eNkbQtUgyW5@NPPdpnwY;)D$mie4?^wNI zX0AT!y>bgbaw1p%_WAdJ4R+=d*veOhR+Ms!fse?XOBjG;<;)<#ZD(O7qs6ovPL!-s zn^`g%vyEeM%LqjH z-iDsiG{XvGdU=VrEZw-mkXG6=cT@Cu8;WXbiqJj&Pj@GN5IuH-0z1P^fSrf=VTIig zT3f+wrU>+8cEHHtxjDcLb}pF=61g)iW}9Ug0}lVE)rom@5>MrF@Yq?>xyfcRoe4(iu)7?)na!3p>UK(Yd$xTH z2KjpQ&`0nDm}jGVf)5TorSVic3JozMxQ5X%8`l>oihqEvCMQF6&CQ+y&bqsrJDUaS zT7rR^%5q;xL6fH`Cp**aa#$@U1N`#&8OoZJfIby_#5~(tgIOli;s`wADfi^L+-ae- zD!-?~ljlswZRM^+IE>-3zt1Yj@_9UAtDhg*CI)@bg>Z{4QAtRCd*l)R+D5fWE;lJ2qjEUGrP<#r^9Z;ZfRmBx7!BV z&1?_m`YX-y{0{NGOIBa9VPyF4(aW##6Q9_;`Na35HUe$U?ErFY@Zn{%wV`n<$S0dy zjTn`(kuk(VpuqtfG$tc<1S>O}tmfU!Xv`Y5TO=$P)0o|k?Jd)uVaKMLhA|p!1{-a# z4s(IB!N!eJYNCC%J>gixVD{fmJYK=$H5eAL(?jcGplFMMqSe7IRtshbXxde1W@ZpP z^K&zbGK=D&N*h-^RP|gvA%rzH9tUV0^M=$D5A!XdO@KM%AOUj;q#*HG8`wDG@_xqb z1a>Cxih{dC2X{zPF_5>IO?Uzv+^KSKV;uPTKft!<4kQNu6GyI!gT6NIYr!^4;j67< zdqTU?Fi0Q7xjM)W@(5ljFtzUS8}j=L!J-4T{u+Nx zbyZ~rHl)(RQj#VV2JK41pcBmPckx_#?h6U0`o+YI_>#EkNAO1c*9)I@Eq8@%CERSP z<(P@eggdb=&CFz;O|wX5tP|^gJu`Mm4D4zT9OQPJ)4rQoty!b#4#{TAwrM;#r18zf z9Vrad#TV%(v?7)T$8`lfsjPUhp1^@?eZB&4;F@aa%mOq1(gH66cObJy^I!KO{5Ju9 z5;NVozbPpCTQuW}2_E}BHEKRT6@y-tY}S2WmfZph+aAOSJka(pE4^7j$XO4M3gH8Yzg%Wdz8k6tSS@ID={O)ePRT}mM4xxg9UV|$htOg z0@6}OLMurktPp#q1$SULGK^!YB$#O9B;KMIaTT6I8|Pv|pMI$037E;<(dey9%tcQ8 zXdb@WT;v;ZNBZj0P>%*v0x09VuukXI)ua&3Z1rX9hWQiTI9DD$s==Y56VK>I04RaZ zHb_4Qop})7)f?(A<`71(%S+H&Wj-7k0c}Bux+Wc6)0<)TEF|E$v&$>pq&Jh6-iXaV zD+iKRQ+A_CSsU$9n!CqS1R=LLx4Wm(f)cRG(kcy4yO$L+D2SsG7MS2HbV67WeuGgU%N6mmZ% zqJt;j>+kl{|GVh_`F?*s7Vq3^(GU)lcKge^N=v)S{N1I4XhO~WCvMRLU0X}fVc+l} z=Ii(qWkS(br1mQP{$N-42mLOoY1*oc-k|Ld;yGnIfLbfIm4uIl+Tyl@2=`y?L2!S; z`@!*j1wyxv<@wGdoPUI8Xx zksuvGBsN$~yWrbFFc=Xo)OzDqtIFjWbEo6MG+3DEKbyG-^y|xx{IAsyYm>W zZsg17p5~X#mH*G?k3J{8wc+<0{=Pww=!elSR_{0la~8&ti8WNs20|h5Y8fMuEl^Em z!!9$IBy9{vBj0L*ZwW>&Z%K9*V}4&rwYNGiC#yKS7(Hb8h&u+|K9w5H*~Jo3rC;`E zV_6YtR_JcWdC`g;ZB@-_c}we?&z`hD`DA0a$J1T)?6d9tB_;jRTfz0s?Sp2@n?;0R~ZdLV#*hITEh%{I?i@*2mwmTp;EQi6ZF#v1c1GYDTxZrqZUS((>-U?sCGv7`L$Z1xb6tOvug$#RTR6GgPKo=p{xoq)nqy zp2kEl2a`#f#y%l=q%otRuC}tg$di+m<}?J2L3g^9HUSv_V4zAildqALD#h6dW1%yZ zDMjXV9E`V(tq-k{X(Jc`3B!76g%l0V%V@H|emD!`o5^C{j~N-Jff~%sY?h~OTt;B^ zm<8HQV|`7Px0rBbZnLzZLsN#^E@l|aZ4a7LnB+N^Y#1Kda7lHl^DKYxtg{|G@q-j* z3YwccDQIq(&4jiNxFyMIz@{gSWN-_%of!OC!Q`y4I3h|INZt{JPs=p3SUeVxN1tO-36KDt1Zj+%nmF_)&7p9-Bt2ZW1ylS z=p(+O(f9L^4!TLwiG>Ov1)~lbyrRe(WJ2UkLTqcWp|0FtT$r2f=yrB10<0}YfK7o8 zKBdaA1*sCk%7|3^Vw?_u#x?3Ai-@&{2@(I3N8x~*wt~RQ7iH~i> zxV|J4FUVwG;&6IyfK5e~z!PX-!j?gt07Dekq3kTOXJAwDW_!(<<_w##4`m4Z5E*c> z{&_;U@ls{Y&;2$s-gyAZ$^wVDsEs%ZkOGhY|AZwMdvq;uY-bIj0B|nDh!QIsm;g-p zVlWRogF6WSnMtg5M}&gxasHYn4XAPety98NtJsIq#15+LL*#EVfLj}kyMP~!j<6F0 zNy1lRFc{JdLI8nX%nAWSIG%KG;~yrA*yZ!@)Z9)&0>LmN?=rVS{4VM0RlKbh2uacT2eD zG>fJ%t-+!R!@1RLq4jUvilq5MUM>P9K0>#Upl zu|}CTb>oHp-zqCGEM~9>>Y!+kf#ST|N{?ZfImzLXl}R`*$jdCwDvrmr8$O+RTwgvu%oz|7XN01=>_#-Wq}Vj?fHQHHCyOESlt)QY@Md&7zqY?#_9*V`;`g zK4MqU+*6u?NjDw-6PsDdaWKO4RtDC!A*)Gkp zxl+?y6ZaT#4W-awXgp4*jU6QrH}7pq-X(dP6kDe+;ESLNy015pwR16VQzC?YBB75- zGk1Pb$H^A9b!x0aTW6}R)6d33qgZ8j$)?&k0SSxAybEm1uoasC2-0G)f|psX*;d3I zkerz3&+{w(dWt>Jn>!VqegSi*etkWv%7~i1Gpw6LLStuS{Mvp&a&9hL7V2jt*uwJ& zCu73x6W&M)HE>|yj$B9XJReRh;709Vr>Vo7`;%^b^Wy50kzHNvt;B3u`OwAd1yfK4=Q%o zMWF=8*VJ-2NUs?;$U$o5)EenGOl@)1ZG#1!Ru ztLvhhJDFb#D+|f1qmmrDgV$YXri*ypqp;YJfZ_dKH||BuuTgUmw%0T^80teJwQ6(y zccCfFuR3Tx3H$5$x-qH7SLOYjZG_$R4A52ygS{w+n~Z$Kjx^Ze2$2Db#uyrlNRHTD zOIQiauJj*Cj(Qn2kqK5uWqJdwN@-EH*N99>_Q`VgYsD{Vky2tt3bC>doxrczUzigO$ z2JIkDll|)#u-gU5a*riwk|onDc2lFlBy_}@ayEqT z*hKh_aT>c9EL;|gVcG#mc?@Iu2+9gA?OoE{+0=mGEMMMW{@{GjoN9`W37T8P)Gs@N6jd zKC8)Sodsc7%vKR@r#JZ7=M8=h5Go@kzn~?EK4J26q-64&pZ|r5h8IX_%BsaL=|nZ0 z(;nZ!P75LG(`YdoV8ul2reefGq!R3zB&NtZTP{Mq?wolA-@1 z`VNaRr70SD%#R=`I84 zU_Q!}F_QYwK9g9b_|IuB0l$O|t~HqL!xhZ-i?_Oct-j07ID_83awXo*r!j(oiIwb{J!jS$+tW!Z0N`lgQ+Vdjh_!m>Mw)@y|5 zMK9!kAiEC@)}UAmVdXI!$iQNRD?XFF3xLPtFa{i((abq&f2V$r{_5l(W8Kr-oy$s8PbS`wqgX`>X5_{PVU}0oyqJ9I9lWDT)fGIf zEffUzHA^N{S0MF3m19-Sz>^oF5m>QbWa^9q`Tz)e@$X|4fvg!hq5LlT8E*)zVUDE*A8X%QU{TXBP3;Eg1YC@GzGF16_@!876zwlBgZ+v-1 zjszqS^f>D{wSm0_9ObgQPz^;PQ0zQKArMJKaHZPd(JB`bFs&-s-Q5YL-kE4&_hezT zq>Htxt_PBJi`e~&tXra&buFr_OO_l}@l+*Ss*D*c*+Ov@lC8dVsBU37HbHwxtsWt@ z78S%rRoMjL*C&)#398>qXjiynMouW20*{FCEYsDco%!YN(M>Z~+03U)^2izd4|7=;-KgM{kU(?JPzh`ad;7AV0snp(Jw`Y<^-F64{gN!_pnXg)%b65gJS|n&V2o2{ z#aJNB?+$Y$f;R(!G+42UhlVif%RXC?$F|YiB9?ZV2M5n&(hy+a6xlRT?g3G#yry~Xy$!S;0b|*S{vE77 zRBN}$vapz|7EsU!-eELEaOhx;3_wazT!NX1p76U-;>$gL)z5x<)ztAbzoC@RNuhwA(Q=j^*s?(&%X`e z7rvj{1kTgRMnpJIvq=zPRHwb-rXyJZJF7{FFcer%H>Z}EI&4WtF=HNl^Kz6L8 zT?UI?)snHVN^Ba^2XVaYEY3=bvMRGH5#NF`OC)jV;#-8fxl;4*s$nktGZWWttgnaA zt$i+?>MJdUa;$zLs^$6>E3P}cukUOT-O{(Ian0_I_FZcl8`tb=@7TSD#C+(+SZ9C6 zT`sDWVb{kRA*qt0S|X~E86u#BSRA1n<>wg<(0^<5YZ1qiYs{r2wzxQ!#3OzI1b(wN zTJ1At)N(IU-Q?s$kBt30;C}_IUdv}tJ%46Mum;ldwQv>Txt=DMI^tCdJruE0QRb;H ztVg^`fu(?y*Qk7k%=w_4gp)a;2)x7EG_=py>2!%;;b)Rh$e`fDa2{r6gO1Xy;MQDcLG$F`P+uEwr- zZ0hnTlFe1GVWf!JrJ7GNRSeG^kGL!2ajoWHVE)*Vz!#ANrFknU?Zrg-7ZH2|D!@0v zB7$#*l)-3>M{MsgIT*=6Ayw#Hm&wtt`7=|%I^~`Seq*==Os^CXXRGu%9P{2zvP4n- zh8^BcGczFS3~W+_9$BsCX}CHIMxF3Bn_!$XnLMVkj`lKhZ)u+DfWNO5lsY?=tMWUev}M;_fJKX*bxI@RqvZEwwSTEh_!P zsp=LEhiw|J(t^=+n9i-!X>iSXteTs&U)z)uccoa=ovPlkKu?Gri^;-^>kLdz^^Y#z z7db}TT1!PJaHzL+sBI`v<7+8xN$eO+Aq^xMius#~_k1-sWJKfh+Gi}k|4VEfv63zF zf$%Fo4OyG^e^>42$pK$c-rTQCzEe~Vn4^8pvp=?wj|WWj1JmCOzpHeo{$FWA(`;DjkoHm95C53qE!L(b$w^84<21 zgie~Ki6M%Tge!{Co)@nG7a7vJk_fFc#U>O=_$A=obsAo@&shBQ!se3-Mo0=&_;nWe zbpspF@?ZD}h6`pS8D@zy!!B{={1zQ0Xf-t!L0j(bY#eABs4OpRC~81YaK9$Ye@Qyg zE8-ckPn)my89VRx#0>bvvnfy~>0XMgyjDn?Bq_r%1~W9p!}6`LU*wFEoE)LfLJwCZ z%*>25WD2taUJ&K{3O2XGaEvr;$^&sp3(NQ6%1w1mL#s&CkC{@0Agg(&2?R8;rzWL`YW-Y)`t-b#IAL6)1CaGU4?y6dQ}( z=>muP@bzR9iW2`9o|Mph5x=e|FG*&m&?w203SPR1V@5DzwkeSl5e!)chG4TGt&8MG z&h*mbJf_Ic6X79ACYG3&EIh$DcCz>!6YvxbkL2wE?hYWB0Epy?5D5q%2SE+eCPZqI z;yfw^ixJ))vJGo`Pm)PTvzmn`D=f_4ND-SFzoD29k`CdaOyfo+>ceP&G${Cu*a#7? zU@@Ye0DQC*W|Sk8ponVF(N26vkp~;%h2yZk^I~CDc zHN|w&DMzq9D`68#4+wflrGpTP@@pj*ls+&#{z1Bcj91y#N~IPQ39l|tT~*;PXz{c} zC;6sfO`9%;R9ylJsbd`Drxq8N{CM3k|Am^{HpV@}%No{u8XUsN2HkT(- zG`mD-?>5|#j7oAEBcX;eNI$q#6YXG5EY-lde1|r4?K5@?WAH0Kq~T*>in`{6y8KQbA%RQuZ@GW&NBNz3yW2m#noN#a8NprccdjOBBrBtZ-_UhV zHGj?Fg5h!QL9#-2omD+o<8T3H5qdffURKBY*mo3ORuf}I7OW_u{!_5I2^J8c{;NE! zNz+`#lZv7WVon%qbfa-V+d&{Q0@tc+cGP{Jc5i)W$yF1@wkpUXc zMp%~|5Eqkt3zm(z0)M5*WN)u*&n4K>Caq-SMl08Kb?03ktlqh#YM}6<&_zYX zr3<<%+RhCYc{gQcT-bAA)wb3suZ2brn?Mg~(8f@C4;Dj2v=TBnkRkLiB?%k+T7=ag z`6|QPr->s7v@aL=F=mEn@-!^QnsS)tLHs_-F^4nSKm`y+#|i!JJVLT zZmDbT7;m3@86Bg)ss4Q8_5Gt#suP6x(J*=*P)lq)6C zPb;&KonL#8{gl(u@!L%855c^8@JzmjOIn*VUY5o_Zm|8WjhJtC~ho9O07 zi{?yJGrBB@@U$3rk(WVehbSYc)-lQhF@R9=bA5R}bBq*LrDNNNcuc%oz;kn>y?!j7p6Ipu-netRdO~^ z!9!U|*{!UkglsC&P%KkwxsW)qLFOvC@q8$>si!OhrEW7TLQShS^!3cNOtyy3T|T(6 zt-vy6DTbYLWmj-d=jOKV^KHJ?u2x@RO>N;j`;_f;ZD%ife$cCMQ8sYQ;p1(=&zTp3#}&VQ{wwgOftbHVJ4fzEJV$mI&lCDSpd`kN0@X8@dNpGm5Oni}&zJ{+^C?)*^0L4F_o?f9#PY?E- zR-bE5VI6esTU+EzkI7J9XKe}fRHd{u8Vp@po;qSUu;$MK-0IlAkh7xPoel>Xg%VK_ ztRifRB_z9m?_;gVS|~TDXxe}vPi52dCuo5H4J8UmwJuMFGC4!7zkH%Au!=ffr?3hw zw_hap`&-)^No+P{Bm6{>taF;O%_^flV6$z4}K1JC+5t6hq^!w}^kTVG)P4A+Iyt}x>xgn&{Iutm zqA;DDjbc?2jMS9iA|tUyhM|axb{cl*z&Af(Kl7KBm-~evt4x?c7$JEmNU`M{6!4ZQ zB;BN25pBw-Iu&}26l0hq91z_ez&JB0_6XWPM{t%d2!yjVmmRV96aZor=0_-N2tE?^ zjl>PIBKDlVf7#5YGq!A+S+?IcaM?Qk`dr!Q?%kvGx$ZLLX#v_B@t&K-9-_#~iu|Ee z)Kvn)pvNly2BQHcPp8eQ1agq9tP~_k@MbEMdxP%n3d>J8gvb~uwzz<3K^I%BMrKf0 zG3HMO12rgvLNao)VO~bfMlB$N!j#k;Y*<<2z@gSbJWW3=CI0xugad=jBVeP{X{%+S z&=S3OjZ}P$N#|Ho+-j8-eqV7B`DFA?wb)bukVzyhAkkpsYP7A?4?8|+`5OtRAYfZ0 z-7M&+MCc+$(nSnAbQGnsFmjK1^0P{^OXev{aY4j`QXh8AFVKx_lN#rcfZBc<322C; zJ4AXD$*bNjTKFOoG2-Yl!VW!8dRU|~v?R^R6+b#o_1da+qf*Sui6ErXzldXo&ngsB z;+W$+R`CQ#CgWNZcrEqKJE}yS+@i+f1cOabv8_ly_604#iRMGOd+(!0(n_2rxp6q` zP>e}1`0vHy;alUeE_6C>AQxkzpT)kGM}8cI+Z6Ibbzh5GUaE5n3Ns~RMF?U;<)-GK zJC^rU_jCt(=e7$5B;b$nl8?g6_9`Q;WY}>a!{Tt%y`(BZ6@o6IBMY@ik_X9ZYD*Jz zriB|qGc_6t3vzR^6#nHkdiBC?G*Y7H@*o)5T^c&H`6S?Gx=GQTic}m_v3<&%$|(jN zSkTgQW5BICnLJ2cF%UKuZ-yxB;v&vaNvNv0DjGW!g>8&seo-?@ymYmhjV4^b^OMsr z;%MA`SeYjbi6+uNIB*9?lc*3JjXc%61RjBNk$9wdmvoFm z)j2$&26_MoRU=F*r6+)1;w~-C4fRw)8XTshr~?B@ArpCVNNkPwg9qXOlN!I#0ZXF; z@-MaVA_YiIlp$rd?GxrzTeOpjoJ*9h047gb+Prg#DuXxEgWRS%mr{L_DuQ?5Nr~H} zSpXZ_p%Trcbf9^s7I<@LKAShX3s4zPr3W&iqoOKj&5e}iSnl_h6tsETqAk3OrSmS1 zw{IqlGTIl1x&%(As7*qLH0;5ed_%mK{7lWE2J8=zdCAM9lWtT8lhpzzu-oO-oJ-ay z=TbBe72Cja)uHsbHcm*P#5u`%_mS~~fOomWOU!fYM{Pyl1 zkNZPcDKh+(drf&+qCjc5wY;#xVZY@(n{U(_T8 z6T)soNz6JNtD#{!H_u|Z1c@k@CoV|3O;63qmc6R%agob4K9YFAK~A1yA8KfkDEKyeY8B5oE!sNjF*&99$}ty&{rs zWaoqSYUgEX1Wli#AfHy`~U6*6Hu3x*og zY-V^(^tN%4orP+@YR(EWWzr4lco$M-EapNbRYw0trNGrL{dpBj zPC)u-hSNqR43NA8Jw-kH0Qp6vsi11>ym=zioJnQ3wA>jbOMt5d?e#7EM)!)m?*kv8CiS%#U8i^#0fr;n&HTFCPf+N9GIyJjhZG~mRCjvbczb2%jYZ> z=PB!lDy@hK>U<$;%<;-7l?#s^e>6@J1rWh0!BeTxts+%RL=x$!K&z67q$Z_mv5j0m zKK?ufp}!n8C@obrdKh>ae3#Olpj|tw2>ERQAdWo}MNF{_#0Zq6KO~BSoJ0a=rg)bO zr;ptJ&N~-A^UQ_szKehPKE7}6$Gm#(CY)zfJ@;eu4ID|c=-a~lAuqbt*bfunmY*_)Gy|)Lp5Lj+&`3cAkE6Z0XKJ$CC+XEKZ;rh)GK8o zYx!ahgChgTb*5Y4-gR!7NVif*oCd1F!_ zdD=NpPDmK!(#&a2hs}xykrN{_1hqtOb$X8}gq|CDeVs`9{!!EbgghePCx2PQGkY28 zxR(^><47z);~;4z51eL8>8@hUsTA(bLz#b1f!^RkiV)WsS^T>D%m`c_i=k|HWRT5T zJhJwA;=3^e22G;|lY;Lo;!_&Nafk$+MzsSVq%1^*w*o>e-DS(&^&swZwX%>kV9#jWMU2C$J_`Rg=N+U26qFY_|cNDRR^zX7lu%eP;MVS$) zV2hbcBExEoS6tRdMp?5!r0#2axCxt+LSN5fotCo0Px`<>=MxdVdO^afPefF!QL2F> zl?h}BlnI&7L)4mw>K7It5htQCy1m7|_THVT!8 z_?>gRq>QWT^=Jh5E4)qRKj4%KiQH@+M z8YLAr!wudRNrjEPjh_kTHn(^Foyyi8d9(Ij@*Sqr$#+;3499R8;8sBJ7FKD*=YSV$ zb?A&n6GB<$H+H1N(zdQJx+uQ`pC3uSniBh(ZZN7}Ix?IH9H_2DHpv&L4KF)YH~``z zcPSbSD!&!ACbAu|e+~)pEHym97iuoa7Og*r5_eg~-4;6=Z z9Yq=$eB;o1C~hQQ3022=g*aA$tnkvbx}AAKJ`hN*E|wj?Q(y=h$Xk!r^Bc%B$H)QDUZMS{X?y-EByWyQlYmtbKS{z)@24t8cv%D24_xdSlv0%)HvDMIoa4W z(FxIs^_UlUN0oj_!d%Q|1HxMb_w~agotbQC1Wv4rFCefjdK?5$cr|2yaep?&pEFzmRPHGyKTE zl7c{kf%I33io#m__|eir$!gKCj09^~Ug!`=(pz&zDQXUrw?m@D^sH9XH04?2^ej&Z zb*ZD80^7N2dpm%I=4eRIi2OT`8PM=IH&Jjl{g^bm+IR~LVc2k{6!ZJh>ak%TuHDsO8o!l9PEJ*|UngMk{V zBv;r{)S^GwQq*i!;(uc*j-?1&qQFFFUM@=lU)dag2xdw!E|U5pG&;h*7)_-hJs9)E zQKj}YZnN8jCyV0L1+_X8P%EnZC459st@5060jjO-?^Eb@XnEgA|H#7Bn=ZI;>_Moh zK^SU3gdmtyU{T@!4kb6Ja-#GBDf;A^h)XV>wjYhAZN)+7Q0(*ur*crILfk+DQUqYQ ziYM-Fp*kddN>I1*C{7llWf4c+H41ezwlIBTt;vuH7CQMeH3V=Uq~1h_f(|4l@wih$ zlTtXP;3#|1FTh2DY=muQ$3mcGoE_RBIqYO=BxceH7^A6Vm<59%nG-qzX8q;84>SVW4CKhZKptdz$K{fSO1O^PIzSU-q4zBD=FG`xKl z`a=`)jW+TNj`p-kX)bLCMc@JqVX7@MUCXF(D`%N1rov2AnYK;aGjNO^kd~I_fcL}U z$aYYQ0C_(Kl6Qd(Aa_*hCj%=_;2yY3OLI=6khRCD-qXQ7i*Q>3#S z5-LrJ4pmLM<>kfdS23roCM7aWPDR2Rs^~VbmqK>3#JKH}1;+|2oS}*eUV!m&c@|og zmdj?a!5AsHs1flxLTds|m4K!iW7)d%k&s=S8=EHC#9^4kZ(HCP5GnwScJizprd_1#;^1aU%0c}Is})dnph6O$WwU|p zf@{LS>Oq?J!jA!(x$3hHU<8liA$nem!NjR$NFP>9u_T4zFSSxEDZvr48IE^VXtSHWoq#$U=>Q0oI&CiH$a11mCk2F0 z8ypK!X~zQ332IdycEIS6h+10(wQ2`XXfGwIUF)V zHTdi0xfCfmBEh9Z(Fx?XdD{N6plzP5r|l-3W`P6`!7w7nSrB0ZKSbEh1ccqW&@+On zl?P$8NJ!OH3RN?fh^`~0X3|u#O+ADlHQ@5^ldWGp2{<`%s|9CLqV4yk_3{zOWTiOK zY8WzEns%~AT_uz}%JL&V(E-H{#$Cu|;&=^Ne_*gI=Zr2Q4Tq9Enu17Ny`)E}UbAeV zXL!kQu!)NC`n$`zqbt~?Cy~iM7|4&s3u0Owo1_@{abn?1)Uip5m3dj2WG*l3WGnfH zLw0eFak=DjYCB>#o%dm~n#|T&z5Jvd_+c{TjAn4B6B4ppvRc7HTrNAx0cpp{3&AUS z7hv4EYCmqQ8+3yXs4vGfBxjG1;V2FzB6!7gsrZ|5G`(7dGl#ZR|D zBk7mIw?x*jPej`lD#e*3jKi`?q1IF}3dS#_`o6RVN3|4Ojgw+MW|KAZ;b6ps7uy^nXBK6m6Fpq#omqk0t}mzM-KFNr50?oo$fL1pf)J9(Hvo z-9tNn4aKvKlz?*&D?*x=ih&`CX9sz6pmivUgW1fd)x!B6RC8CG2wGCw0VY^yNC75I zO_~NjLh%Mfl3E#j?tPt;>tS*DxZcI3086VHE?pSy@tT*NHX@=!S&5$}o9?{Fhx}Qj z6`X13nm=ceeJ>c_I+eYTmLZ*T9&5wZhZd@P--4lqDeiwO5*&gqCnLL!2~S1;NK#6i zUytuXHf?78yy<9JvCbP*4=;sw7!l0oa3j(o&kK8k6a!#e!l#60FkgxXL>gl(SA!Fl zXf{9cKPhtN{Hv3966%Y8UL-kDCs-Wf9OooHDN?Ijf)oPT(-rJ*>Bspu2tF%l_Q1b^ z@L`A50`WJW=Py>DCpD(R6AP19B*XZbu|~SlL=ePh8IH2UF)_0?&Joj&-~nX8OOoP1E*eGM7{p6WTV0TeP%UBAO62&Btz5Ti z-HK)XeI0F04KR8l8ykmV0!m0B4e=0pl~B=M2!%m9T%GD9AjiBHspnuYqBKiRcD9d< zpuG|uV=hB%tf=%ix};2>2o{maiQY;nr&&3F4X$fGchl`zt7Y0Te3M&fz%|U-%cXmZn1z*y&ta$C*bAgq$&ZhD} zE#eF)uW?-ZyNEM5i=;;cORK7NkubJ_sR&{#Qlkn)*ny}%;fwiQaB3wsLCs-XB-VhE zVk-T})I`aB0-SOzbEO(%004=Y13uL}SnZ%#14s3ItU((^8MKL51DiLHu}=E?+>24M z1|rHp;lCupQ7lC@Xr)@z8Gsov!I||!po`T1qhy|vqTFnE8p>u=^Xizg8Ii&QYG8zl zY~I&5)93T{&Gd)X1@kSNZG|26?Zf5e!|nAQg|^L>{NOs92#A;-Z>Z?(tnk(N+9#Tu zC)#mCe^X2j$*uJwE}~9EiNmanU11h}0uzn`nZ;~SmckGz)`Vf}YW*1vsJ-R$quGe1 ziNT8lsxS?sk!^)(9i~Yz#z-wVvSQa(SCkbO=H;N+ccZ0|Y}ip}4(qD%EHt%ZB@U=M z`RvwGdh*HplFg5^zPW!$^`NOic4sIJ`TwRK~kQ|*Yt0>P^W`Jfta z8Jqi@j0$u|*>~tNqg#fzH7!Wj=G?%l4xd^f;2)8+>GifX7cC7YAa$hOR~uU*MFCWr zm)?Udpp|_zG4LoMk57ZcV9$#%BtiO6Mnp}oICw`M0YxQz1d=3K0UGOTtNp$r4-UR> zb+#%|T(QxrDbUz*RogE}PGld}^!t-nCLuH}4ou_EC^edZKMTNU{8^^3uxS3Q{tk&D zTNfH8N(AX*!RUZ$a62^?EK)yH$j)?(2E~NM@#laV|D{UGA||XQkVQK$#$Xnbq*&P_j@olc(TRvLV zTDq?1!xg7@pS`^6{L|Zq{AELJ?L%c{Lt;A3Q=@f78SN9^P=6pVca^_vu)n$Xvd?ZG zyLQd$&W#Ne<|cyOgzimy&u_&3N;=Y2M}a{T>Ix}(Q5n_Sl@Ps2#(%Iq6;Y%x;y@j; z)(AvaC2l70sk9|p7x=_!PWj*niVY&aFdsmi$17QA65|yPEi;eZdlTW7m)j=G=PpZv zVX5^y-#h!58(L$GGv1*X0ggB0PWyo-J_&k7NwW`3d!}Q0d&B!HE zfKj_(O8_t%j#O%na5xnnr9P?|r-RKi;|;x+ZZ51FuJe?6NOgB- zR|~6s2_;5DRH?93D58omZmFj-EOCTo#}T0BoW1EJw#KE`YkQ9#ks^1@gnr@Cnb|$B)llMiUIW^m# zS4Usxgpe0Yat@*~)mQ+Hq?nVajLCGMDG}GMM8xHqCoaYkk=H_g{iLwr-_W`AQ=>7} zt)G;{)L6^q^5fW@OIbgn_AUusr1|xw5yVLlFz)rFOHPyFYEue9P-IwbtvBDp!woc z0^Mtg-4DVap9Gu=&+^Lm2wyzxV7x2pJeeUk+2Uc>5?-~4jb$5{t!2 zChhs{sXE1zgXs1cfB4E2fFjLky0Z3p@{>vSa!s!L6W~#c=)%>ZF_5HQGDQtnf`wvD zh!#>I`;j$)0711wAZnKsl4YRQS*utE)Upea41we#Ylk}RoK&WJQDmC4Nv0`eaqQQs z{t#4750B9xEH>>7HoYc6(}%WLU8Nns6RT0gDvAj;3jH3+ds3L3wtFtBWkhY8Vvg@a zCeieb@zAJ{BItlMh@CNLjZrvMT#`>dR^>p)^FI0HD9pl+Se?QQrRgV*q)MN1J8;mA zb9v4PZB(k9#p@lw6NGbAG7|d7F6?p%WN~t<_lK7BfXzY5#F+!#McwE+1-E+giRZ;{ zZ$+uVVPoQL?d07_h+12_Ujc$hzoau#G$b?SR6QKHAk<4LDXUj6Q^B@Uk0yzBaW9I~ zq%y}34Go<#2f`5$XQq9#_hpq8PfmWYzd)l+Q(}Q#w^bTd^>V z&yamb6t$^YsJ-Gn-x+z1lKgTh#0mAh10_YU7i6WoSda&m1GdPA)xHoQ%)ux}xu%=0 z@9rtdSiU(BZ$z-<)burS)lL42W=%NUT3%dbx39f$Nwi6!xW1{vr1{DD+cCBU>3=ZO zPS(dxDIVDFhzMwqa0s5rm(YYjoijCB6r>a;iku$~Qv_hYCb`Uag@fxpSWk2?5wvEB zLFH3LQlwo)cM{{9O*kY7C*|ag4fP^@r@5i72E~sHIz64Kdt;NY+QhQRwp56EcdQI@ z@}?vt4#}L-6SgBxKaXvG!bzMN#7#k*MUc`Z3fstm-4<?Y+x|C$jHi! z`*kveLj344&z)IbMujX2aS{Mo=ah0sD90oU_X<K5y;xud3UY_1302wPjdv;=!)4eMfGJ6h(}Sz&A7V}p!|LSPTJIKhauX*=j55YU=7QGTLgF2YWVl z7Z-PL>er&3_d1|b_q$Xqaob$YoaDV%T z#>Ugz+D>n5+<*!$VxO*;`bC~-KWbvXf3&Asve`B3ZYTCH6Kq7YBJ~q?Z8fJE5xYw2 zZIKAX5Xu|?PEJ9RN%IY~g2_s?WT6hMR)CRu!EUf{C$K%l4uF~P29r{FRam(a_X9SH z0E)o@N|qj3HLzxI&C=e^_J+E0e@RhZe}4ab!n7Jd5tG!5#j1Vt0g#m_>$L6zAuu-g z^u!N75JqVqCVZ0rwh6rWZy`I`Mr-pVt4)jYMn(eIF_Ylngw+?j^sH(h)e>@wM6!)( z2KWS0$puFlBl)lesYqR@1ImVkilXZFlMvm=FT?Q{&GCO0y-sBc4>b0F!riDru-@46Z8YsR~Td%SUO^cN2 z=kppWfkeK=#>3 zDjIy6Zq}9QBw+>~1K~H(T-I%S0H~v*`MH@p&=8Uce0ty$Ed5k{fTS7JA=tNWvHB-^ zM)t?oM~AwGJP2)iwzAWoIySWNA-wlj)ti_S!E?P7(*#&W_>^JWF+sJU znsk~!Su^t30jp0)A?TQwd8r5V&tYxXL@MF%bm{SHhThzH>eTvu_ZfRM+@dU#OY=3w zol@8=9`MH^j-!UH$T$u+dX(ijib4*2Sy?TU`v$w&@C_iY1+Gp_B_*wzSp}~kzrNAh zpknUs)3)Bix{}>amtUvUUH%a|f%!R(*FR9|KuOJ+M!a|L(L#;!o~TO&bs_Z3d3;^J zN1=OovxvR2vQVZ2t&nFEXhfoa8paZ^kE|vu;t~jBsh*HL(8|Z)mig?uh>iMww!O{o zqrHU4&vdZkSzij}0DH{jOUdO7Lr`|3dXolh(?kQfpDrOIhv6&Cjlg(wSRHm7ol;I< zhBd`i9pNqMbW7J*>$*(Fz5KZBjk@_mv>DOzZGfSiNdYC%Xc46JOez%y7Z&C>*r zxzYMZnxk|_38zzEd>;8)hW363XPJrDzXvhdUm+Nt_?s6)i@*MD- zvbjmLuF-8G6BE=!k!TQE=%6VH6fg-I>M49zuW=M--E5zO;MgRe;AXI}Md}FbFY2SLe zoQ%uwm$b9_6O6D!(R7SXaRBFTW|fS5dp^F#B6U z0|9nRrlJKkdHFKx*HQ`tiBRnrILh>%DW(RQ+h2S^mKekZu*L|shHUcoR<}!MZ4|p< z+*8!QJmZVpWa%71XjT?CB`XxNKHaDyaucwCOwsl|#l*L<4@kA#4;vD6FyW>#SWqZC zo=`C;k6zkM070dhJb=x%<@K2?lrPI(T3`(^G>^mIBcS!#S`g)xZ}}h%ms|-HB3%e6 zL6h^BCH*XKUssC4$S=P`uCB8A)y--4j9A+?cIr|spyo(1rB!VemIqBBCKf0x7F1t_ z?y0P&jWrbLD5yc?5sGN#jLtYYny3r#jbb!qd4` zeEw<6K~qn^(tPlp1CU2Wivt5b)sT^~wyr<>N(gLqZy_SD3@!@Y^|mt3=PiO-HM^e={hgG+Cgf`Gx5 zxs-z#3|m3kA%f_+BDZqPN)qU;P@`4yrZz^6zdLoZp;1)8%aC6JA=Xm3e+wtUL~iu19eg4aj6+b z^xQY_St}qOAZnpx3SA^(;^&<7(ntIHkEGK_`umQ; zZXj}2meeM^tB2PP(xSF|X?Nx}CYWNjbeXHcY-;l~ApfVT!c=bteyRW;7PyEazp8u> zZ^ll2Q<+{W*JHzer+inleTC|Tx~XAyS5_;d+Jj*$X=O4NQt0s6>=X*X&el5fWCFxh z@29INl&$ntka3#aWMTBRyTeW4S|nB48l}!gdo~;Exb<*F#gMg_ ztDDugVe}5x0uRX1%%KvojrMG8qp%)m1v}gJP$sw;5!E7?vDHK2jKL01XKQCeT}4Y} ziyq2|!fz7jUsFb9o?O{=R4l}CHB_Xs!~1SFdU4AkiZQk+ONg42>)OxWlDD$m0&`2Q ztgTYUMC7kweC(t`lJ2DD_*tz6*o$cS6Ka& zt{Yy73O2r06pOfIpa;YY$yh{q*tJ+hhD<;igP2bYm^%;y4IiMok{B3r(L+$_8fVGG}sehKLoAafTJyI70|Q z0_a)96=shl%D=Y2Z6UnL14$WLC=+QDoq_&fe|}h@a2af&@@{t?K3l97>rsVer{#Z^ z4!Mw#gj-CN#vhi{$8gS5W=4irNd|hb);AfJr?)$ORMuF^%-a>4(aNU*ViQv* zUZ#~9ibW+JPG3(n9ZUPYO_An^(eSrbWs)oI>E9}_3eJ`pvT|AvebawmerT&>O`PT6 z`I;OQrz#n`$u1`mQGKdxR<9pr204p_d~tu=C>d!XF#-KCg^3sxl6Bo)& z$b1urj)fv941yEY`^uKyB*6(7J_gYV^PobRG?d@mO1>j#-9)(+xE}VuE6+JF>Pk?!S}K`m#d3RWY%GyhBQKsPlHSRpLpkS5njRi!uaF~4ASH-Ya2P$A3Ku^jC`{(_aOsJ9 z5Jt3p#LIf1g_OJ;<|2z~MXG}#OC7o_p`^>@cQrUsht1?M!&a_i@n;wNN+TC3>vEM# zj$SxqzAIOCt0~Cx61lcXc9)!kNOtWs9If0c=YX=rDu=RHYOAcHYUN!A7$oAgdf08@ zs>`rjO-8!H*h}%Fl5u;5a?w>xh?n^VGAoo6ghw_K@^apOJ1*0 zMc&nGnRcQI#tP{|Fu00B26u(&;T>RfU0ad{nB7~iL>l_uAU{YFvAPeT@~Bzl&e7)Y zMeaG`Dm1PKQ!*7Qx7f@mSPr9DDmEgdzNOF8m+#!z3S?*7Yef>JyV_YZ zUK8mc;j*e0Z^LJ{TCN*Onr!usgv9Umk?`dQue?xFhmv+397;Iz5NUuI1Dx(6#vm(8 z5_2~pLQ+d2vD-AhR%3J9-3T-QUzer<_jc?%9<5C2C*^?TtKmc=8xNH%-O9|9rLjN6 zJF-cUY^YZ+&6%XOD_LdBm~J6ieVF&Yqg;>gfUEJ7d)(8Qh?+x!Y_dKt{KACeU$ z_bP{CVx6&84^meeA;qFJ5@aimqU`WQJOJLpPU1#FeINrlT%X{$feywx;(@rgjqLQ1 zx`+)i3T~=uDthjQ>qN>BfsTk(ZD@4Yi?9>7%>0k2jkosqCm}$L^m-Ti`sm$oug}xm z^zpU;tbo6TRcLPdSX<9pG#T!Tz4a0mWy4cF6s?tY(0^4A8(X;VKC+8H_>P26HgtzzQwNWo|W^gY~=e_p!keYDA@Qn10T?Itns1rL_S)vU=bXVBeY*k5u}!I>vxRx>`_FFZ~BB#0KNbA^`(4s=^asD27D`EsQ~jz3CyLkKv#dNe1jk)1X>J%sU+$){)s=EEvPko)G#)Q z6*H1aYlFjA@LoebDbsC2EG_J_5tHkx`>cLC50~+( zw>q{BhT_RB2;q+z7ep#n#%tCx4J{}#D3NMuRb0^!u|%^IST+Fz7=u8EETYbDm%xJD zWQH*LKjCiUs!-LlICm7_o(MlOKLeVpLq3!65&|;tTRpC>tZn=w(LVX`?-F`m zBRidOV7?THv!a?d1^QAcVbb_0;EiY-{!GlTUc3VIFTc+ETumk+3$^K&tYxakyzLJgm*u=EjDaD&4jfDjOVI)E+T=229XOYi$iSkFK#JRhBn->^SD zA9q=uSu%jcH?C=CUUH-NXyVKDznwUA_?e6xv+uVtkX0OdzyD*bj zN`UkfHiU6}S2@%%VbIS*+Z>#<0c>-G+W3^ehaYK#A={KfJP<+xTQ@=#pk7$X3IaeF zh$kX=9pjX+b}!XVAHfgxV&b|g z$@8esD>Fd7Fp&`K6G1vkkS9Rg5#xs)n0q*8Gf?UZ@HTlFWD#72U^HN%bphSg??Z%0 zy_5BG`R^>;%$0o-Q)tT(>*hFILMAEw7vNAqCvoe{E4to!*ZPf4| z&)z}7;NS)U@Qary?|?o6zK1@d#wUiWQaxlYk)E`n*lhMw&wCIp;{{dcm@C0?~#KB(^eLA@OYiL*z^YWnDi?a)2c4n!WBQX$?IeKiy zxj?zj@K&FtNMaMJ?uyt$n@u@o$Z|$iNvi84=8Ej6XiCdz2ai$FnUWJ0L)=`(IBClD z&pvzktwl2@<_^5!^;o+kAxcE*0973ab8wi*ON_pn%=aUpK;aGrmKp@x0L`+Xzjm!M zCc*S|#sH}b6Q6`(6Wbeyn&w(EkYtCYk1+d6_C8>xcmg70t{67+z`#I`2)u1YTlR3x z-myHw=*jKPSV&h=xKzyY*kfAgQ+C*%e_Nl=BC%FxWA1&Zo z#)+z6SPq8`MBB(4DcA;3!6^#*BmxIlDnNx{EH?PFy`GhpvX-LTj(^Dya3%6q7?h zfX3=LU`3_%7M-s;9pj*Q_ZgEcxRO7jN-dnG)Rz2KE`TO~Z{D1@Hz^7kfnl%`bHnC5 zWufxbVsmyBU!dYPn2scpS(dkVxn#xhVP=$gk1QUYT)_ z7q{E6KZDt@R18QsbaOlOWOj#UqUA2rH=ZsK3!fjb|4<)`z-C(e+xqcnV3cgb@mp%N zwql3HT*2VW`Y1|LDXk060_9rer8xHAe91$q8P0-AD8}uSU;>dBHk5V4u>h7Uj|H7C zo4ASm_N6b%Anl^ydrgMRLVJQcK|%sdX=8m|tvpIJc+bM4MDqv-0tIKvYUE&VUuB7d z1#Z9Yp^dmaT{Qlz+$7GI0Q(5yeSj*ijW)`-g(IpFD&1{l!3DY_e7=UG#zwVrh??cN zBl`6g*bcV3oKO&C%O$Q99F5K>4bK^K;WnEfF91!Gv?v@@1yG^+hik!%@OhkAAJ!?t zFfApHtrBz7rradxAda~)QbJgx1RYnYaIx)q6cLguex-|%u_Y*@#C2H;(IA^JD{X$C z9>Y>dDVqN{Z@(1GOKJXRn4gA{Owcez22NN=M^EO_5%SP1ARgFHEw^cSP5Fd$$_DsQ z10hBA8!Y!<;d-PSX>FIfR3{}}SjMCY$8sStWd&xk?+Ag}AgmS4+wA49K0C_3N_^JJ(%g(H6_T~|5Q2iqdOPHL3ZJ=$xGhdM=|DEHTzf@UC?Zo` zFWtth3KIIac^?X@ZsQhFcAD%M8mcYP8wKn@N9^x`3@>>14)CS6-lK@Ct?qUK2iP+0 z?v13z!=0wdQgggLPF`Z(Mef2dzw1(p+XNU1aq|HrX&^e|cKDlhJRzLdT0I!yXQnJT z2qPl&lgg+|N3+C2z`fi-j^>@+OmH~E^x$pXj1tuH@%cDLlW2g{t&3QDoY%0#=m~-Z@DDaArpIn=U!d8$epV|Yyk*oW=c>^lY5k($s3p<}Dhhoc;5DE)|;oQzi zp}xYebr}?1{42_!nm4?}Fn*o)ezsr)lx2Hg$?k*G9YXD}2=szZ_G&3gREIJD2+>qL z5JlBxPo{=Gpg)x5TF4GdYx24lSj1p{Flr51VT`0T%djg z;_dRsR4NZwsPG2G!I+Kfu$0i}^HOq!_@&dJtOM51?z_}&purI4MMdKOd2Cc1`mj78 zV6cQ<7%O{SmL_w$p)C1j6uyn+zBC(5VfZV|f|P{Y&{%jhN}A=-66L+B3YNcgHLuG@ zOHqWTlL(&Oi+r;Lw`B_@X!&LZUVj6hc2s8hG_l&!`DWVn4u{5FwYYvQ+u0Ccpl6Qd z*lM|3jR6MwUHPoE%$*|NjN%JWFjz9sUQh3i;tTSLRTAb+CHdwX3s&VyEK}z&q!R@o zMpcX$s(Cx1@f4|oR+l&T5NBTKZ~|1S)~(M%oaop$=0aBfU5rGhfdye-?3M} z^5pN{aL+wx`4;{V{apSLf6c~SB!`Ojg+5cu1~bVThZSMDu&Cmo1V6bb@}zO5b8cQo zil|7wgtM~NREx(M^FzfDw1@KlMSjW$^B%1^DhoQGKcDPf_3V~RCE2^`svN3C*!coUFGZRj#JGvOI!U4kL7i{*wO@)iozij&_E_LuG0WcLLo3^iGVTl zx-Clth*1R@kRctl@XSIf&`m8oZ%W?`9bn@}c|Fq*Gm84c8I&2t3VN6M=$J@TZtED5 z_K}Sa`V+qVdX>C`Dzf&iu@C>=+1I}A>_?wC``XtaiQVzsa~nV1`1x19Li-W%5<#?E z$AI)v44^LkQDy2E4=ZSsQNT@N+Y8yNf*jJEbq3$EP*DReA046x4 z;U<_(FkjSEI!uk`Mv4QV1m$#+XB|)39`N0?zkaWv9|~=;aB*k!zR(_PX1B{d*5IsA zDW*xnQhIC)H62b+oExc*pWHcB}|uzz7hm z0Bu$vB1$_i;!9CZqN%gF(`e-_1g8$b13}>dEuwAvnp((>XBhZ!+?rAeIl)cNU z+IEB;g0!tx%PETVY|g^nWg)&&-qoBfiN%}%OeF#N96P`rA z>?Mz`(l9ioIN8aPg4f133TL!P^m_hlg>wm!5R$+*z~k+#CzHT_fhUsU#lVlX6eD;g zRezwisNpU|=j=0|y=Yt!6#UI)DB2Vw)_wL=b@k$ca0O3h(s|_IND)Puf z%TOs639U4~EL{0q)!7u%hghU-^Fy&lX1@h5%~b1uR#j1xh67%)2ojFYPp{uvU* zBam0!d zW8f;KARw~JjDQh3??nbuM~MrB!;G%N(hD!OJ=hQm#8D`(H6B7yT6sQzYZMlQl`SECCCi154%v_VdY5#0$ zI%sREZW@dLK$yzWrqLyPh#w%ctsXb!jtJKx+ixb=azNEYeDZb780)zvk@Zice{{L z)Sb=ADu_O?op~XV=5p^gCr6tbfu%go7TD{boE;CTc8CU&5;_@G2Z!&8Qc^VN6pT%J zoe=3uMFO;B3{M8Ubjyo{u|O*;AnaEj&ZaYCqv;ft#cXfQ;TucYA{)4-mAtLr*^I?{ z$2+RkIa5u@?+?|O=G5wr@v&{q;I-baF0ZGW0_muCM*tG zaWLEpqq^BNJCm6l9VQ6)4j)2!2>DbbK@wC1$;$>r+N+yuqRuz>jCEj|&9xz~FIa2F z#CMF1Z)d($E08ySqph>Et;ySj|LNTh=e&(SpQYhL?C}CB#+gm2T13SaRkC>4m8p4f zn>Q6vbzo_cv(=TwLraHBQ@1R6|H7!-cmVLLru0|opdMF8@f#;{l5Z*6|3Crlr@%E) zl?{gq^f~wwi1=^Z2JusD_BQV;qW%sNu1nLu5VWhcWKU}@PWI}yfC{m^tHc(V+};+r z^ob8$L>9yoGVy3iHCxEYBKTE$t_hjqySe`RGnm1Raihwxp1ao0jaZzSKQeBO8)JnCdFhIx0_4qX=h82-1d&irb3*SKfF3(Gswhb-%79{ z_LQ`6r4kLQO<1@{ROu2xRzP;QpiHpIW-{9@;#Pr9s%IonStN?X&4p0*UHK*vucZ}K zq8lMN5`U)~DP%`~l*yz&rQilEFmNOLdMLb$str|DNV}Oata3-9;%lWz?aA$MY7Oi9 z``Z%<#lBWn)wklTaRBdVjD09mNqA_(y-ljas`KVy@*Kd#iAa5>UaT-FsK>1$he0o? zC~&bX7MrX*A}Z<;N*rLZJjf>B(Y+$-LMzFK`(f-inNrD~Zi?n_Z*|s`gIL?qgz_Jd zimm~!ZEsq?lSN}Re_&BNQjxqwJL zKqe%rrUr~utv`Az>h_L6;>k=5&y36zQ@Ih|ZAXh@6d89y@mV2JdUUq8JO>&GGrdG$ETb=KP;;Cx#-#C;_2}D-~ zi@9Aj*^whME)fbIYTa6J4&*7*%35gG4Uv;$|Wvp~0Jd*WhuBx4?Du{d3n9NJk!WEWuaj#69 zQe3M6nSnz>TVC%KFE6rtcnP82Xz?(53%7F5@6fLI+5$)7N4GP9O`99}4k)pDks)f~oD&8%L0VUhLRnd~1<_m3yXr4`-2V=KDxxaSXV zXO8rPetVX6!wFw(V}Eslja}9W0hky3;6QnkX7-%PoDAjU&=+Yd0RDp9RQB2;%Q`CV zAOV_82`z4y1LA@Kqi~0$WyXoUEqjf~cG)HY>HY`u!IDMzVQHNqvv5w=>Om?3gjCA= z*7YCVN|TcmjoMZwxt%)-gi@h68I57+(l+v9b~uan;sbYSJM>q!7Rx_xbEjtMP{gT{ zujiEobO`UTMtqD23^A+aJUsEOuG}^aiulzXohEPLy@_p27Dc;$eY+E*fQKDM+(auI zX1h@v*g4&3g9|K;6I)qM;3Poh8B+O?bE3@;vf)6FXY!^;0{5U?20<{EzYrBQ@_k>( zo0}LL=#QejXln~1T`X*v59@hz2qZu};T7g6FFckTeTDx*wP_r-@>8Zo&2BuRI}U$G zRIJ&8$?0LVe9Y)Z%=&g@EG_NicMJP=&Rdo}G|5m0O$PXSB|Ktz=j?!f&Te^2@mWjY zvc`?PlNj3_Zs%Bu*xUQd&v(v4tCWez{owYfR`*R(O^8Xh#QUF|pNtqhY00emBEbCY z`4Jxmw}MfPk#^AZc5f}vU^H=fLKlZ~%|P@i-+{tbGPxqrx&}sAf}(-^U=qV93K}7a z*UQ<=^u##igx;>WuRTAiWGlq59a2DP0oS}O8Yl`vx%{2&Ek8B{Bmw0)n8do+EuUTl8rMOOIA8jbH35CpB@#tr zRQg#|f35=ZLdojSe)uLRzDbbY7`w{--DxO=Dx~ zr!dAIhl}X5X zs=_>FA5!*mP@S)@mofP4NBc(6>z-~3_0LV8bKBGBNAXAc&+kN#Q#Rq!6UW=zmev zAkt#!g4V1uqpRv>EkdY^(dH}q+GOgIQAAh{T(Xuyd{D3>7O>b`<6I996460~Qr8#g zolvO8xXYyn2c<-8kX>;_{e5FoV~1KgT0OQsR?l#M-$eWVSNUB2#9~W3f6C-;NX8RM zTcxcr>~89acLdVjZ^TkAS5Ks(!qk8T8RPHd4`RGE5P!9jdoKO_C7vQ9oy0gweBMKy zM?98ZYf-CsV;50bWfNPzms*ZQmIeox=(RHeh?1@@{?6W&MAzQl-o0IkmEPSG(dY#I z2zpI#{!F=9`4>d^=hfY5K~Yn^gNU+nHE`x>Zt%Li6?Rt7>t)>?0_vjKlQ@78fx08p zYxw@%H_nAZb2skRf3KW9yMiD4`1#i_?|I#gV`DeIZqM@T&yOE_>zm*F&_i#2)7vmH zh%e{$%J<;Gh-N~_HlSPws4Fn5U!-7LSP=jr08}T2D;!Rdp24xPQS)pN@m{?C#2cDZ zQ>}L8dzV|42|i?VMgtXS5)u?s7O@TiHjwEfpl1>54SEu33t~?qFpf}ZC}!4Wq9+7c zpeY=TiKLBGdIBR2BXy{nFv%uNf?$ByVzv5RtB=o|9f|J>_Kf+emFtwM_Vkg-%&jxK zZkf7)pWFTR$@OD{T@!v^PmeD+G&vMZomtp*^SQC>*3d7?&EJiF^$}W5rzqhEG0lX! z;}_947`u=a2_PkOn1Gao{X_)tF~mZN;5UvkCEj){Or5hvptSVyzAW&6G6%>QFk&L< zH3$Wun$;zKS@`a$mn>Y{vlNZbC-zRAK0UZ6wks0aoj5*s>-faYGh^MUnfVKj{i{m<68EPJuaUO&fpMu2zP|y7u2P?4LgE@DGd(_}k*~w*I~G#A*^jg#3{sU#n_!Exlx_ z_nHNye$&~pB(NHO1p7~l?aE|O)KNjrPVhq58jw2(q2{EWXTdFZZHU&aV^ejZht7CBe*LJ1vyyeJ%)ppEg4UNz3Zm#U?n7eIa;*N!b!G*!} zLNK_H9$cW%NURmn!~oXH$wHY92ZGG7R``5f&;splnRn%6PG_UYyP7biDeSkm+GM?? zCZ^}7TpM@r%hAcXsf6johu?nJ(dHuyT?b$K@P|*L9f1w2Okgk3-0fzFh!;`kCIyqG zRsCxLdZM99L3)s;ZE}2YcY3#+yoNfOxa|G`YeUq&03#%E4EYAt3S=^3b|`u$;e|1y z(9yBc>I?qn<9qMOgp*-k+&A)~`S}-(;0^xF+_ASeIF=YbHZX8(I58HiG1tZC2FFhh^CyEtZQh|kV947x6x?`c=dNI2G8UT* z1b1~}{6)0Wg1FyW>;XzwfR2dZn;gYxSZtVifUK3H9WJ@Lif~$yya2$b2{kRwPjU9R zwSntfgwJPb$2*u$JF7RRhhBWxKs(Bh2G{SJ5yWG&9<$oA#Pj0u$=g9Z^E4$iG4n(` zpl!VD2JKWbFQFbl)+YQyd~rVksosfekYX28EmZv)OgS|HBtqxkHy@u)E%mwgDhHaz z>euR0sla7;g^FoqIx~r;gdUxx8BhuwDQj7RF|pOM^kUHggZ~@ROW}U`|Kl7eEV}s% zeiRIqWl$F2!sCOn9oavRX?U6`(dpKh4U+u&Hz5Dz7_V+EM z2GjK40MR1H(Z|@M`~-eCWn9RT;fwf?Vl+t7aXJ;s(W2yJDdRl4GE?CVJ5Bt=#$!M4 zRc-D%AnI*CvAMxMjd8TF{!CASd_yW61*GOnH<0@q19ef7ZXi7g>4pS1V=e2XYwu7r z-R^5>@b3*zbfosqjaI%z8S3^YLXGYwPg{J*Gd9q(kgyXdB4jtrWeRns(vLPoO?uNspWho_SoSxyw6(N!bvL%Ox*NTV3u*sIFj(K(5t^`O zl8v6``nu-vrdnsS$DN#XC%nFh+2jbf`+Ax&c4hM(%>M`ZHCV$cS^i=*FqZhG;-d+-P`zdz>#ct((L^qWh{Ita3| znhB;>lX>zao%UA3hijCSd759#S|Qm_ zWhN>i&(v`Th5+elZ7*W1$U^Vz!QW8K$`n`h1GaAli6+1fr7 z%J`a^{J!QUpPXytN^&c|4yzf+_&}Q^tAIikU})qdl`2-{Y><+Q0?RTDm)z`JR z*VSXT6c)i6kLK36j;m%0;1D@SlqbndR6s^ZAiDGlsiWK)*Gptq3#1M`V^YAo>LQ0k zCdG(8)IaHs9-PUoaVXi`Hk!ctHe;RnwMC0O95gO6E$&4IA{{2pDjo z_H7fqMW;hC@5br=>w(2cWN|>g1`>%tM_1Q#gU1Kc;{U;8>E)@Bv8l}XXh!bo6nln! z3Vfraz8fTcH)POvZB6ksnX=?r@XUeLs$ind{Hcvs@ale3MN=b!&)A;L6qxL%aiW#g zb;CJbHzagjIOp7S8rWLm?&8yc25Ge>T`N=ZF<;Q#7}?*sD+Ju!JyXu#e0pRs(AU}I zY4!Pf#@pM|;nYfB)gp~;7V~>E`jH^rHnf#)n@#yazjT&`spkwamqyl};0e2&{_?Dy z_JZiIrq+II{P1eDr48Vb$z)TTx5XV=iDW{dOf=l&4aIgnH$CQQ^ENcJPJ8Mb;J}Fv zwYT&JIy!Bty~`IIfb9u%lH#uxYgEA7b8H>_At=;YF`EW;R?KI_z}tWNx!=5XWaRg6 zczTs*e*A;^)ptFN6(&8l8xF!t;O+NclB|8{2|LbCAbtRM+;n>xWft^dm@iMJ>k7(m z{`K1FS#&CB6n@21;kjg{ov&Eiygskj>u$CmYWmlY+ErUP*g4$lpMEYf3Wn0z+#IcS zes^xo-W(kEbxgrMPdsC3GtS?@{s1VdF$O&sIg?7gJQoV|+%PJ^=qejX!rRVKNvl2P zjnRn@7knFUvt@Ur&}+9o8GbUf6l@thFx(uB){pHn+q)8p(YB76fdKw>1Oi_lIM5wh zo{CzQ&C`+UU~t5bF3(4sn`1Eyg!&FW7W8liYgI#fEWm6GdTdbOhlB)vNDZrTx=h%x zZfH&NbWp8#tN!k;n&)_TTO#38o=%_IKjxet^^LBcf_5x)*e-rO=0%1z+xn(Quo)YtL@U%k`ot*i5W5HQk#KtqGj|7p#;vF4xA*SsF*1mvWW`l;XpI4U`h z@Um;3)->`fo)q63UM#cb!H$kV$7c%GeAE}nOQvm&L zKEZEbpT<~PSUNLMPFDc9#1UO%P)#c!PJ_ar79SDX2~k2};5U@i7@#j;PE+VdoV_tm zUm)LhRs$%hFVLJ5<0Gv>VfzI#f9@6tXb9{r)G-R~*+O%maHe05=D<&0QFS2Q0qgNW zDMS`Cwr%9j>EP%eJl|K`@#9U@(OI?CYH`#*&rkk?T zW^QM0eQs>6QScK*(xy?RK)Mn?$F0hrL!ao(M5I0eGG)pG;3SM60i$ERi z6Q$J(e$Jp+K(7F!`~>!qJTtaw$0>VmWZBiPM!VoA-QB)Y>V>bnq7Cc|QK)=)cVScX~EYvT$4f+Kfd`0KQ6})?;^b7v5 zreGXP=P4Mp-`~TYQN88#iv;Nx@IK;I`qS*f)>Nops9uADfoq{-@b~cB)xcIdMt4rf z=*rVE>@BD_QDkx2O2^>uDXnBc$G{xljDGAu$3VYynHkexQpb=>Q2r3up^m{HzLJuG zIaSyc=JbtXt+vuL5<<@qGiu=fm{ShtPtJzKnWkv)_vADU&IU1G*Q%{M)iXdRhB>>k zo{@ijIXy$8^eY}3Pi?7Z@Q2GP8jv~pL;MY@Uza({>lq1yo&mBbsb^$&<_p&|_(KIk zXOW@-J%hiGp8-9T&@&Q3&*(Df8MJZB>KXif`HBW?|NL$Iddy3CJtJYzGcX}#&kMt( z{3`Se{8C_-c3>w}qNzYK@1#4cUXh5_ABk(DN>CsFf zW14>paj@VJ6m6~oLjd(nAmo;WY*^ocW~P{7GgejW5kvv1>kgY8@70x>nu>~=SPhDA zAez?G?5?P;sBUsPD^T+5qZT$}a7xspOXgypt|DbZruM?>|r)HK%+%CW#e(gs^uZm>p!eZc_#O1Iw^jmjX4o8T|mP#+fhPY6OEtMp#(8B$k~=CXqpl8Spyfi9r~>}Zipq<1)lL=Zr4QJ6k-h`7C90j?Si>uk zA9V!3Djg?)NK)z8htSe`w0Qs_rDm(meDMlx7pqcRHr?vlpYR&luPKlRw4sZ_0N{air~Rr|og8tJ`C55caSr zTV#jXDL$R`zqAR`&5tHT;jTg#J17AKHvo)t#XUwO2GWHnVzVHuGe1o6s@tVA>;*Pi zlE5;Fs*J&L<1sq?c1lj**;erEyM>+434|=<60uE@?siGq{5Bc*+VpSFRX8%EXNckFugq+6AAd0!ej1A8f5}MFz}@c0;ypE{ zff4_nT^s)#{QH^Z#>)Pc?$O&<`4b=Yr`oDlhr*f2#vrfg-xqgG9v>V(GbZ&u#6>Dm z=!@-$fbM5cO5z~k7Y>91lwF??9>y3uJ-M(&3 zy+Ub%8j9;_=j}B(j?y_Rd?RYsQwoZ#h%3rJAi9DgSh9h(Rz%udo;(ajmo>)!b$f3&$Z67R9zeL)Gf zv~_fNTjysex=@jQLr!i5Di$Nw1tVnoI_v3e$cQIc1tXLK<#-sA`&FnX`UCaOw`zmn z@771LagF|t=9ZrQ8umBh*)1%U=_eZ;Mx@oZ2VJT6<+{Tp4slCmOGBNUw4y%bkNO?j z{~@tPZsd3LX1WlIMk{4ASrt@Cpw*H{^eChgXbu=BLyHQ} zPC(Ky8Ggfi{t~TA-_m|x-qhYW^m!@#t>hV30Z%n1TX*f7jJrw!A8S#8czrKCz~h+_>>!bkgH%LBiTZJ zj3H$s?vLX>Z=fDEj+)~DdY(|TEJ6!mg&C(>b-9ca^S$WkXf!$*3QonYo4t8#;zd)@ zv9V~kmb>edm(>*G&&B4fPKA17FZPPxRcs*@1qyVE$ykRX|Os zWteOm<#jQTOppe$`xd1ogz!u1;$>T|&$-d~rjje?lbBB+74muZ9sU>SJL2f^ zH{-}clAH=ivPMQ_-1m=a1kC4k%B8mMWIyH~SGqw(gG*0%aIjPozmsf*67!rz?XP7} zOh_iN5iblG*godL!d>0-vDkb!Tx+rUA1CJHiMe=uE`c|q?@>f*e-Znmnzd${wF=?L ziBX``F5rR&wzrYfK^p<1c1M!dG_MBAHH_tI6s=Wr)MGg=%dMn zo_i9nr1eCOsq!j6cfZb7vjEdtjcT|G#!Sgb(9}J zzVZHds9$=%50_YR&u7Fv&P+A_7NO;LI&0}rGY4?_XK?XxT#dQd%Wh>~Rvrd@xQRP5 zRFNQQ5n;NRfi;~EsmkgLIVzNTos0}e1?Y)8`42!oDr?VB)gTUZQv6yc2F=Mat`Cu* zF7D(PxN{$ZAYRaY%E0!Y_gTa9lpk&TKILWE`!=6Y=EX3=!qU6t7|9yw7UO(w9}N@Z zOm7bH*Q+zg3b1_K!FYe%+zqA&@!Cb~qFr(G*B^T2q0Uacj2%ZP=J@fkr>ai8<@b&q z|L|K*R2`|_|I#-tF23od`-uj+(AGs`TlMW|1L4caoJkFIE0M0krqr2oZ8e~+SD`IG zRnlpaIhznL0RacW7=ZT*r}$0=uwPLkg)?6}a#~Rc7>v-*{Z?=SD1s>`V(?PQe*7Lx zBK*9=+BY4IPVZYfe`>lV5^0$}_0+ya&ZqZJbH2Fm;Pl4j&SbTz zdLXuOdHSH3uPFNU_FTU-sh#Q|!rOsR788Y^BhHD6Lg1_`)i1bysPAbN=o zzon>K>dd~yjZO4xW0U&DRnf0hl&f05_Mu;QqhD4Q$oQzIQiTAAq1qSd2|lwjE0_rt zP;xu{t^krD_wo4CPd{aK}x8+BYOMpcqYFJKX6{5!e8~d*ka* zKdsLE*S{ZBPopKu+gL__j&4C$cNY@FC(6-67uvdLZ0pK&b+lYt4QT6CXv@#eT#{rZ z^_NyKg0j@*$@q)Eh70J3PJ(8(0`Z0avWV~#TM?n0+qY-OKqRP*fi^=qO2imSd=1yt z5sh3;YPm676~P|eieQP(Bz;)foa{Ufo?@c)WGEA+P#p$YLLFYqROZoQ9!-BDpQa0+ zRf@3jEW^imsv&@?@L2_Pe>6w;d!T3i@15&edvcmaDLw0c{#My0i)F&dza;gIUQkjD zzDMLL*3>Xo(^k{wc0rP<4wC?nJd~VbWrc{4_a|q$Cw?#YHt*s2dXBRBkNn3xA!MaC zhP*cP1AIaD;{n-^2EMmMPvD5kiKJn`N{6uz6xkjmhWwCe%p%yzFCIAzma3xUpGriD19VWBm z;+5~gDS}(pGJYx_a$t3FZE0;fGc?%SO(k1fVR-?<#{ypg5A0B@32X8&{*ee!dDg4RJ225dQ)fPo`Z?{1&XLm>=hj|xyu16FH(Wb=qF4uM~2(oji3z051Emc zsqj-3pJJ`(h5%+&Ul@v&QxRpuJQ9aSzL#J%#;b(r2?RSx$9mSEkWJDlfNY_VTWvLh zo&%bIOeaJ-B2I>E{Gl6`Q5Xnl=U}GY071#7`l-@@kj58$eD{UfLo>FPL~?&u*Gk{= z>?d4Jt0CWomkq|x9j~zVrc;5?u zPseUwSbu2X)f1s0|8}M7-WBca>g#MD-1v54($~`Z!&Z^=g)#F#hdsKMb!5EI9$_J8 z7qJJh^-ai})KZ$m{#vuu6Za#LO*a7uj3J(x%w!IA#t%<$^W+@g*SEj7@8AHUfzHuB z8r*DB?o$`p9Q49tnIo;-YRaGj7NVO>W~-U1X;GvBMK*C%oV*9J2*Ty>!2)Z*8EC_p zH2{0ZzJ>Yz-gqn&5YQiUd`{Zq9NplO|_itS7n4 zKRbFGzFL_Xp2=JYjU5P01n(L>fb`6N>PU^II=qQ4?_z3ru(GwXYiVd~)nRGdlb*OX zmB>sbJTcgZhJ1Yk)y98o-=T`jWlX zDt~nCfn!}=#~xVIf6w1E9}MC}`+actwLRU}&L($7U6uu#I}u8ZwYQJqm)o{raYc7I z#?RinYv$tF@$s`4XLf-h%=8~TmmWNKpuhjXxxw_ggZ=YUX9fq)Oi>}OmX4OhY&1HX zz#IA-X9Tw5ef-Vf6E(0CCD;JFMabItR~(kWC+cu4Ff8+_)sG;7SKvf+2rvd^H+d9UI>li>{9MPI|qQy*(MPH`BD@kA^$DBW=N8u-eJ*OC9Ly zI*^jDk=S$qhQ-)aFfbkK`n_sw(6+G*mUz3l9*Qs6>fJXN3jf%TB5j z3rk-l@GC&)m#?H}MDfb)Wjw#^e9~8^8He?wgp{_}}Qy z7=HtQpI8?U8y0J_)BLP5)0^d`KkVDr+q`Y*4464zT9u#(Dq6-$XKXh{{V9X-l_OR2y7 z!^`&oA*qApraReBl>wFP{O~5gIAB(eK>q+khTcha4nz%1#Y~np)N2;DODcgG!rbfr zHy*!q>ENYHKjpm(ANat+#+PwvuHAZuY$f;-?M7cBs#3$njjYJCI1laX4sJKu=?V34 zEb0F$U>*$#FJJaagmCDhr-|p`i4QK^x$vNP5i~cp`8!GsLQojl3X_?!cvGbv1L+Jk z0~t%8EMOiv_MQSOhzi#MtRZAP6ebm>QU}5;+!<~mi;6iVIQn23f=j!Jn7I;h5?~Ct zjMas6(+vn2?wnii=~$T<`VJfVvo}BG**et#HD+YAtXT zsBKM+SQbTHt_Hs3qMLIvX9GY`JPg}z;f%>HIRd0JF)ZXRo>h3g?Pcnjh>jAQN zgDtpW@FwMrtllK4MbZV1(-YnMVv)Vwd(!i>>7~SC%-yh@*z1|}&8A-wnGA;~Bauuv zoVn1oZ*XpRYESpxL~N;hX>c}_Y1!Mgr@lVc9-a)#3-JbB1jP<+zDGHv&LY-fgiR5q z!BE801bEDebhHK)z$W{vu|Q0vkX)FZ5rB+vT-0I(e^d#yC&`j1Q`;OilMQAcdt(u2gR?#oz$-zj z)~AxRDZpDqo`#Nl2HF978=$(}_@yZ*E+{A@f<~fiin4a5Vf|eMU zjn8A>O>TU}Id|()aK5*DA$sGx)*HBazOKHmZhrCZCA-pxiTMaTiWz6`%E7%&Wc1PHEZ9!_))1%a(rmz749Uq`*xj5IUGkT9P?9&-NDIfpR;d1WU zz<-?JMclA3=ddsp4!JP2li{EiVWjAJ6GQwwPIUQyB~}AmHbq@mllAp@0j`YT^?}r} zKW_c)SN_q9zWCIO{^*|fw|@TfCqF)4IWW&pZoG!ywei0Bfy()he;h4xMu*v737xy- zc}kdHaAI}vVD4{w)6<`eOMwu$uabYlCA;_vFXMZo6&lwp&MU#p@eKbMNt# zJ6WI7p;Y5OLZ=$d3^4@-(BOnT%glf!Gn>wkm00MUu%tL^$t5qrxhoBhc~WVN(5)0b zWAush!Wp*h|62<{Pf7DaR=s?9Gd?}X-B&P5eA9y@NZ+TiJ|2CL#ZrJ#*&G(D?RF3> z1Q9qQv*_n`W<`AEc?7Uo9BVM8Sx!~K9%liVz#=sSjpib?1h7&RN3vV(6|gU$EnI$a z`;DO%Db_L1;&g&#;do#hk!P5kqi)0;_)T6%)YZ+ftw^r?ep zvWA10ol;=iG7;`VtZ1sT7vl%%?0 zBQ5Klq56gv&&Xn}xzd}CM+cfs6}B%Z-`aS5V5om@jQcn4i-ydr)~*l&Cv~pL#Ch`p})&Q94&ib?qQ~r6c6m)Q!P?>sw@>+3Xk*( z4rcEXIL&mcFjE`vtbnxO68BpWPplOjA3&?P2o+sXo?LryeiJ2bqcG&YUcb-PPz(Es z#E6eYd_Ddi2~u5KUke+9w4hLksSK~mSx+eZTpH`75QQVM(7KXU|G(Ke8}FR!jL&v< z&Mol!ui3bJoWPvDy}kU8@fCfJ3ufc`ho273_VmnzLNh%*v!RU{0Y8zH zx6tmMUi!ATXLo2e5uuMG35*=;VS1xsJt%to2zy?x$p9E<6|ZnuD~zjBEmoygtV%;= zz3OnNC+loZOO073SILzbGu)d$vBvvR%MWWKTd`0ps6rvZo7a_A4RM%xs~9q1-}w#i%de<9qOIs07IouV z!}9Vy+WKyM;3^in%Eq@SX~LIORjsH1Nzt|e8fX1B^p*Jvy# z$DJLEth4>mB~cZ-L!73FsDmJ)AjVKuv9912o8=5iMhz%{F3(*u*g=e87sVZb%ETR# zN#01FK;sSM`a@`e8S|svLjuZHw_sbsyTnUah7VMx|eE~=GQB=ftG3Z%+b#YOcwSyE-q&?hcYxlp|bjt^CBtQITjR_LgtUQ~wBOK@6rqD2W3Nwmi6 zB_YBhY`8qrT}ZOp9XZO)y8#3Xa9++z*V(Mtf?TzU`B6z4)eC19)1|112|8I(uII1Q zYF#FUWEcgdfHkg>AjeK(@n}j)hQmtPmd+#Lm>~f!`<^dp5#9e&`>kBB$LQ<7K|fG# zp~yU~{18f#CYKVSl#SWe{21A%yisrb_FSVd&MT+b=T$eR+ABtca4v9q3T7aaEOB!R z#}I9S#%}{{Qw>6Vh09#H_gQgov$(e~o><40RS`&7#-hvxD`L5OOQ`h5%TDpYBD5dE-z9i~!?@&&+X>z)#EG@@kQx5PY zkPLvtZ=L0r^d+$2Dw0!t3Ftm!}CwB&ps^-lA3ezoX;4vMGl;Mgzf-j*gLFV6nuEm$fO5RF#6oR* zlr{D~wI9)GU7{Zypnef3NNnZ^@dS2K?}Hw}Cc2Ox82Fxavxo)^cUaZ_V@kk7$!Bd- zy$-r}H~%`HS6&K>)?P^ym9Q(gF;r}k+wi>>zIlSK-RH?P(l_uE6?}o*5zGTn%KQO+ z9yle>z_i?-O7Z#LzQRMJv1Jpx zRgp^$ugYC{+*B_3!#@wNBG)QNPtqEBhvV z>#gi1;u#eUP6aXoW{V1U6QF_*_D+ZoWYwaW@AG1=B*DP70E(d~XmJT#qUEkx%Q(qi zj67c8<`cp?W!ll&Vithp)F|QyNOJC@7((p~v#d0AcQ?c#JvMtoW4k)5YI~Bcqrb3> zSUQ}}mio~3yG$eDmZYD?QMozHKDGIC)~NGDM2dj6?1>ey z0^gh>$Ry%7nLu7WI@XGsWm=uhFHm(&x5K3u(IkG@f%E#BU&VRdzP7EO8JoG9GxpBnGqwWy@zO8t zjQ!Am+cUNmXYAqso@eak>=_#k{L0VR|HnrKFTV;tN*J_{lUQYf_YG*|q!rM6jx*^G zwD$_mz;9^p5kt!;zM8Hvu>|kX-kaGhBBSN!7GROSO?z)+VNP}~y57z*{BO1Q6|7s) z@2$l9ecI<$yh8+X()HDBP<@Q;LZ0Gn>@IdYj6r8nPVNHpA;zW`KYe(25TBgE?`iRR z7}uP^6}Lc?xf!4C!(F##f8U7jt{3-Dv6tZYIed3Jy93`Nb{&6Tz;o_EbPMW_;z{Sl z6)$0@SU2vv747xlJ+*!Tuealhm*AQn@#OsH%BRM*?9+abp5Bg;bYny`rcngS zui$4l-qDy!wcN$>@7{Xs`PUv6eZK=ezZJ9O%X_jkHY zgr6i_7~AdH8lH9&{=1dE6s;V@D&B@qd~6nwac1oCCW}ECZcOv5X`@D|VgKp382KE$p@kZ|EO}v@?1<+DF>}lT0 z+kmX;1ub{7pK~AggOkNMp$k95o@IZ@{*wpUmw1SWQI;TzNWN~?!(+UY{VjHBACL0{ zD4-u0AYb8Kyqov%Uf##f@_zPJIGYAg8*31?kcRj$AK|0yE$k!g3GAEyTiUt6M_F9` zf9Bbckj*C9gor?uBGA6rQVapPC?dIRK%@{6P^k(50t7;Wxq*m?{;8#?)Z+EEKvD39 zsGx`#Q2`@fi-<_`dV8(4iVBD~R7Cdup7YFRmx%xB`|f^zb7sz*nK{p#nR)h^%d`8~ zNqnjuV9(&)yfTV@YtQ7(uY+v9Eud%ZGOuxhrqJxRMK)rK?O;2^o@IyHvze#(tsQPh znEvK8TVl_#Bkj424g<``c9ePCj<#d$dG>tHJ6&Mjw_{C_DYw6~7ut*L#de$>Z>HN) zJHbx0lXz)%g`I4s*(toCTzd8lvx0G^*c`FORM~0lw*SaZ=jmpMnQ1FbwVh!rZIzvA zFEKT?+SZs$IEY+LLB@KE!Oooz3*b9e{qWwe67(?;seEIZd;ZtCnjdxf2EujId2 z8O`if{HMx48H>+hSM~2XF*lMvc^UhA|6mu|KiWlRF8>uV$6jl%V@>)`_Imqg-sF1& zcaYo7E#_b5u=%4okAK_!PkWm=-^^px{!057a|Q2$U1pc_ zCfybGc6*0iX;;}h?Opb6p4P_Nd+fb-wY|^$&Rl5r+BK%Z-fthU58AcnBD>CfU>~v% z+x7MlyTLwcH`>Q+gMHj?vQMz{;3>P=Zn0bK)3(t*W4GC7?Q`~d`+|MZEHlg5V3NaW z+P2IFwPPlv15fWK*q7J?wB5dJci30#PW!6e#Z&Qa`*+)9|I5B^->`4mx9r>YzwJBr zANF1QPy3$zmwn%UVE5P$*(>w0-D~&R{q_@kfRlZn+0X44_Dg$^f7kiKd}$8auk6?M z8+(XTXaRfJerJ!^DF3sW$$rz0=05JpD@|wa=uewJaA!|8+2%~TT9`+#hs?u_+w!i< zi_LiM;)CQ(2dp)c&)uu)~s&!g50Y=F$ehPoKEIX<|NjbZ!$NU8_eB|Evrnvm&S3X zZC=Pr_cFXpFUG-br3(uZ!2!`-NF- z-sBg+b*9Pu!@ObMGXG^>H*cGFlJh52m(`c0)K!-EjO0grTF}SQTu1X9?d{Y27%g;R zC!gQXh4USC>GSha@@JGztgfm|$*-DHRarJYSU9PwwshjevdY@zqKT!%K&q=sYm*}y z^3xYMeF|Ovg)YBBr%z$;V5B)8m&B=1ke?E9MMnH0U3_8kU@ajyxJAmm+@v8BN~?Xb zLU#p){Zh_y*_`EPlYCZfdBvo%uMps^27bm@C4PARRfn^93(SDP}zS2KB}ugOSXuX8oz zXW2iJG>QvM9@VU5ZqGh0`-oF2(x=tvxDpYkda=8dNK7fGOeB&%`Y0tvySf_f*Hx?0 z)#a5_l1KYBcAn~zcHYGD>WOtTCRdcrPCai@d0BN?O?gf7c~h!O>&wzEh)4QL7xzxS zK%-pHRa$Ysnuaz{nkBX{^6{AL;?Ppl%E~2o1O1aCh+_mO%-!>I~tEuqyuk=He z371ji)Gl(m6*={bT>TZrF3;6tVR5Ufcta|33PfV5oK%skoJh~~s-qfGm9J)UwXaFF zuUCzRl4_+D*ETChD|XpNoHCKVt?J^1N1V#V?h+y~Wt=44)$8l5f~vFle4YEM&bhh%m;+Uvp-$(% zPUpT(=e|zozE0=9PUpV;Q>k4oXKF}m)%Vw=No_iRNL6wHuW+QOkei#In=`ShqN;M- zgmGo#)ThRE%t_-WB{19hk~QOM$CZw&PKXHl2`84;=x-TvEy|c#T3uFIQ8u}@hp;m> zSgj32!Wk17T*~6n8jMGWB|N>Xwpk#{G2$Xgld3AGBt`0~tF$ijdTJd}cc-Swh4sqq z19~R-a})gi6a2lLKQBRE{4sg)$K=JYwt;HhL;I#`FPm63V?wG98{*T-?Uk#89WuUM zd*$k_o3PpunxCqDEFnej^r=-<(@Q5*)tAM&Jze^q1tEs231t;kv*M9?F0(vs6uG_f z`pH^`jYj!3Elao=?!okW{KvtGfWhq4<3DlCCAc;GGPe9KUW|J^Pb!vQ&>L}|;0e;2 zm(2&bAF|A6`G47T+)SP+El-r}_p;sXZ*fQ15|(v%qQL($d+jYx3a{b*%l-@ZL(k)E zSimDK-vHW#K?i*@mc1EKRuDx`B<*)_WzFS?QdvD4$`hGaR6J~y$*3r;t>j5S4svxi zt@6i4I+@OeBS+z$oj(?LRKEDR@8a@I9@VlW=fL!`>PnL{qqKUu=`dsZjOqNomvZDI zI>~GyQap9EGNE=!zuwz@&~MhmX$^uYN03-SoZ%U<$B+Le)$#;AJgL?@;J$~v=5L^N z{G0O5(ceIJpf}%wz|^GLz~aEhgzwIz+N3#23zC-eUBv^>(&Rfx`#xIoIz*n{@m9jy!2DW**{ta|`nX>-pv}E84)b{3gD=nGJZCC;DU#`}C2y2%hFU zFsIO;`GH@XGkGo_!;I%xp2_ohp1r{o*qeENE#%pB6)UBim=_(Q_CAZ}!MCuNwRc4K z^33)}^jQS10oQ`-z+y0!w%NeH$T2Sql0Ykv3{pUAkP3nz4TL~C$N-rj46;BrXajP< zaiA?Y9u!CKGBwe6O)aPc^q#7a2L26+ym|ftHFI>4Y(gX z03HNu!8))WJOVa=N5Mw$7-#^GgH7NG@FaK&YzAAvR`4`v1kZqN;92k-cpkg}UIZ_J zzk!#*4)6-t30?*7^6u7s(EZ>OZ~%M?J_DbFFTj`JAovP=4ZZ<~z_;Kq_zoP2?zJ8W zfMjq1JddZ;j&@~qJAL9-PAjh=^Z>I^9#5`$@Ryj@wtMtl`y0>$oC0z|Pml+CQRe|< zbI7Ngy&alru7~$$ums!yZUi@ho53xlc?~L34M;U0)qqq3QVmEoAk~0W15&gM+H;T| z97KnM=x`7n4x$5RenC&bdpgnKAV=2z!an%e;O2-pA~1slO*paDD%Hi0L=li(?^8EgSt!PB4-JOj3YXTfvedGG>w5xfNc z23`g`z$;)Ucopme`@tvR0QeMq20jO0fG@#8@D=zPd;<=FZ^2>k9XP_+;(-8220{8) z8VG@OZ~-#X*Y0A(;@)BD|5p0H7vhW_N4}P44CAf2UAb?y_(YqcpYkhXFX?f?L9n)k z&@qD1Z-p6^aaljnwb8dE2X)CqK4_EhkrLoFiB!xNj~2diw>#?cRi3Pbw)m)?uL?8z zZY)o(MYQ*GUmN9K6>St9lq=;rePU&`$nvKK<#l}ihcknCI#RMw{YaTDKgZ&=l=-2s zSV=z}9gUan7(b10SCo%#eflXWStByh?a{licXKPDp0{&%jDN_LJ6wy8SVvM5w*=5y ze(;m?zptsN^ypX{h_%h5+M!=6W0kgxqjX@MtGPYzjkWN&L@dtbpgkZS>$raF`yj@b za`<31^a>vHx)N(nh1E8hjEVy!9q7o~pwaLE?oA~i2jsyq%`L7Q&q9eG*AkD`87;nN zm&8OZ{IRwMJ4#>k`;+TEIWA|_-nAx?(_Y>pA6Kf_olkQg;kxoz@$x?4~*Tdu%RQ_&=?W$7{(SBjRCEBQ|b`Kk?&itE@y_gyCu=rhQ_v@B0&^ zzL$^uN{_YYV~i=^&#WaUUN0?UeoR=$&j#)9OI1FIf3No7=Kha<^817jaR=SF;I|94 zICpk`+}F0}>l2-(J+L9VFs?;(eB6Ju?`R7o){#HvlYg`I{j%cen@3LLbo6_u-fx;y z#aeSbnT%oXp5&PEcIz04_Kzq0alXWBkQCBh{8q>4DkE4E_ceYMG;1g_3D+1a#plKQ zUo$_Zd#q2zB7TS_w80+|X~~aU<>>yBDChKxX_FZB)BHIJ;@v}D(&o%TOv6o~60`t$2la&tD3UoL(v#if2oy?B|L55K>@-)3X^ zY7NEhPB5Oo8xfZ*l)9jZ-3pZaT@zHwz1RZ6~1Ta{)>US!(tGn z4%Qhz-q^;smAu79=l)J2rZc|=TA@iV=K4gZBy@`47V}Le-C%YL4dFM>@$7dgfqxE9 z_a0jFk{0xy!QPKb=7rOkKddIt8lLvsvg7J<(#l+7m^~8k zdm>Mp^6Ruc^H`m5J7X=6b|e3jAvW{WW}e!t3l+blh2cV;n7k&Iw)}jD;_B{0WXu^=T}hpoB%f zvIW1^g^LpS$fZmEQohSSo!`S6j_I}Bg;)7BcrA2E@YWE&wL^D=wgp!c-ULKWsK^Nw zK6ky~1{dDS*75GhGtLKh1z!(7M|f@M?N~Uik#OtaJHhvP=Vb4+(}LU~m44>aG=AGQ zhr`5w-GYC_g_9f&Cd4D3*4BkjaI|wmJmsbJ@WZj|3XLLsKET>avA9yKt`y5F#o}p` z(x#!|xv}(VR}p?P^mf`pM;AMKqoWT&ZzFz%3*Y7FnuK^sztM%YenQ*QUP#*! zu7&PSdpB(l@ozf1KkbXOL%cOL8M}l)HuXts4z+W%qoenP?g>2*S|94_=&u}&@g+Q# z_}mu!J}x{UfiH3eE?0ozj1q-aS1p zy*s#UAiTXO1@Iw zaz|G=N}o!XJ|z9-9kAWeU5>u))9ibU$$kJo{YdFvM?dqa#HZ7D(&;Y}pFw|7N`Fxr zX4#mrB7^>-l>VZW{vtG!_LR{rqen(>=)#P}nO%LF!N}Mgo|LgT7S5pm2tR}VqjZ>~ z^$B5-AK8L`z6;a8G@Zzc+%y-aziD}W65^$txh_oqllbhVT-P1wo=2EImT{v`!~LMQ z0s5cv*FYc2*qHGo*BHvo&Y-_3eZi-)!8{hu*hBn|7W~~V{!K^UjmN9}e#ig9(L?cg z$v2aJtCaf9q_0Z7W)1+qax~Xbu2=a5&>>*BZ@0`*_{V0B&z#J)uFbq7v%=A8M`t^F zbDU<*LvDc!FLLzyc)aFA{VAp0$S0FF6Q(U?ZqD46`4V(}Wbqjum3+E*8MXr+z|I(+~ zOT*nI|8QP-jOO1DDsn|GJk-$=M=x~gCphZ#3(t4qTejeDIfnDL(!`pv=z61aL@ZRue*`e^)S#z_l%32sc;?t}oN2fu9SqrnmF5K49 z6S6vIb;}NA^~f5Tbw2UEp{GFyxp1+g!yLWXrJLlaQ@pjc>&Ng5&RSbho4Qk1YB?)*eUqJ4*i$zNF7i&JJbc z$*#_xo!!n+`bKkj2>e*MtKjjV&C!_LP8V+SX%2RgPxjkB&AH8| z;Gr+$vSpblI$Lt8I>o8nUdCfSl!on&z<#N z6Mx`waqSC%|Hi#3@PfD!Yu82P3FM~06Sym)hj9lff2hjLRsTv!%Q+0m`PZ7y0?lWH z%Ipb@C)LHO$#B(VL|`qP5t`=+)v80_Q#hlOc#8=9`^D9qM+De0#n~3g`Bcq$8Sm<` zcA4h9OmkSK^7L0^c*UBy>aSC`N;y^PUm>p9th#j#$Zzs%L=#-Dj`e@)$BYq8%%4RQ z_-JCTksR>L-Ywa4V{Jq^i!|qv{Kthg&r6=>M$w9Y>49Tq?ZcXtDl=UUcpooU-GDna@Gb7jq;|MFHSIFZZJE~O zRISOCT9eB(w=LesNIo9;9CumZQFXVH{}$D4i|7!LmzP*`rEtuZs?{>yKZm^dy}vVS z!Q0%ml(0%Zq%woG^la6~M=Muf%)Af4m^?KK9xmRe~D>Qbf#{OPouaMgC&e0P2 zyHE02EcHxo(%xojs+mdup&XI?jdCtg$yKU@{5PoOU87PbC#pVIsy=&EpNF+9#y4bs zA9xe@!@v~W63xFv_4!#qYIRJK)MrWH5!`&uvtBjN*BZ;$R?YZCDT7TAnYjV>_}RgM zmFixr?zy<{c(>w?3CzJQ4$KxlZ#=-&va$pG?+Wjc8=(Abad|(C$hs0^SX)bp_Viv}7JQk5v-Tq1-(c;GKnS;=_Z99d-YvL;1DE3V4a`#hTk3u!uIdm7 zbi_X}aJuqeS6A}v=gAJnewy*EZnqgi|a#*b8 z4i5~$|5V^0Wu;2FX1+)7Gg05KlFtjr4pGkY!m*RI+*7nxPtn>wMfr0AGQPd-{U`3I zz-72aT6+?iTd~$pvDRvqT6UOPwpiQGG?knfXoq~n zleRZVbIbQmz@IOrd%e`w5$^*y#mX;Mem~)J{z_`n?}M3AqIq5P*PVverun6-CRuu2 z-L%gpOAh>pG&^GLzp>HfTOs znrgJB>ZJTmD*v&{|5D{UY27}r@|{$^IB+~Ubko+-O;dH#Hs4Jp@ANq;8CCaTeD?SwfYrrsSsCM z4!?BB`9v-8c=aDp_hQZeWNq^&Yac&3AbtGgfb{XLT5dn>S`-BFycY0uWaU!vn=iKH@5 zNr`5xDAp7?SOwaEh?6$>?)SoL*7_ucKEZ0R`xct%a~oxd&y|O-On87 z0l2N0OEqRb7~>%RISIHB{{%1!W2y7#Jkzq(Io>`L{o_D&*gt@J2L)r&bDK8?Dn(ferX%L!^L-LA#i9RW}~zXbFZ`wvsW#3omy&~TB=4YrEhs-bd|Pb?p1r; zul9N(Py%PA%CGhqx6K@BOXi|LUUQu@N9*jE&CEI$U>CWiCS)Xy=yfFFE2q_q9hvU4 z9Xu?Ou(v#y*E91p3Cyu`B==|jw*XRz84W@bFL^L&u*JRh$+&*$sT^A)=Dd~-s2 ze@FSz=_U8{qjFCn!LFgMEaAG2IextUe!u2y%gx*OEpB&S@=)~f1BmFBsc^2*6(yPT{tyK3qt z)R@<6>SorMcc=$Upw{5cP8#Abn4q#BpMPJ{II&ki>pok8kJ-g(@Qr-8aY8rt?dSFLioJSols5r)fwz_K>w(UJqQK0+ z4yUJ_3b>F{_Hu$e})}s&$NT&47@Eo*2#DC2GZl>XV~pz|NJ^Jta{aQHoZOP#&0!UI32#ybY+EOE$e17Uis@5S96^w z&{Df|m4jG28bwc?%4*(R)@AzHa~OBV+iB!84__%~;imK>?QUx&cY{g62F==^H<|5w>9>1`v-LPSYuj> zO6RhE#(VbAj?)zAeM}iY0_MrVbp{!(D zqS$120&fTQh-l zZ00j}plNfoCdS)x7xrs{UHi(Hb|IskU(a1Nb$89#Pc3&N86%fA=X-+7r>&+JJBg&M zG46wkU)p*GmXNj%;D}|;A&*3f9M)X#Y|)B6ZJ|cnrle}ExmjJq44Pk}Lu;`V&&&Z< z9Cx!a>zVCxj)+>8xVEN~y@@r#Am>q*vTAlKCr@Oh@(Fx5;$v-@9w6FoSL$mk?QAJL Nxyp51fq&)Ze*nc98`%H= literal 0 HcmV?d00001 diff --git a/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-SemiBold.ttf b/apps/mobile-flutter/assets/google_fonts/BeVietnamPro-SemiBold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..b25bad31fe8984d5db6cbce61a23323bbb68bc97 GIT binary patch literal 136736 zcmd4434B~-xj+8C=ghuj&rFg@CbMME%w%7ht&^=u+cYh0fusmc(j7{v1uAQ)KxwUr zzy(A>Ktx1D?{s4mWKj?i5dpc%ML^_o(W_ouE`pZy|9#$b&g9Hwk_p`V`+WX|lXGU~ zocFx%^E~hKKKsi!V=M=M49qvMX6X<+HH{QkPhlf_JT+?Ez zI*IW&?_#X(;Lw_r`+P&YDwvd2&zSMum22u6rwU8mXyP8+Ke6HL$(?_`;`nup*#_`} z;~RFL=X0HDiZSMO(C?dfZaI4@a^h0PGNX)TIJQilyA$7M;eBu7Y}vB?!p;BP>^;O- z;A+Od_3qY9lN(F6?7klDhwyvrR(xSG$}i#bMtm;YdiHr2+@7`RT*f2|9`D+I&W6cX zLxUILecSPV-`SHF?3B;TcoFv>!2P}*lV@+b@E^BLG5*|n`2FRb=bU@qZ+HFbJ;r}N zjQ)IS=dMjV|NX90G8iiv!TpZ{GI*3-@#6kR_O8$D`X81lzl(3!Yyb7)_vrlVo--ag zv1|H6!##$_@X^F1`WFB2oc!+e-|>#uPwe{WPlkI$OUiHJuarsO;P2VYtbD<0*s1Ib zxV}4sV1-AePf26Uh`SEr%-C|}%+Fwryhq%j{<(v(b8vZ@_TA{p(Lwe&a|Sl4efT4{ zqD#5-yOblnjgya=_}}p9eHQ$GaTsi9_hB|H+gLdHkp2_j=ddE_k8D(&FJi;eUN-u_ zJci{wyesLDet~y6nTr>&VP3{YdD)5Aar_e3y*P$&{db&;asDgLJvje?zs0Jg$Iz!s zaa@6ZU2)>K(zQ&M&&Fr;UHU7&QxCZd@BDvo7z`{d-GX=FkQd?lAzVL(_tJ4K3nd)i z!*6%8>V)Ih`0jVO_kVl5#$1L>=9B-2SZ(xi5uMhcg z)+pVC@$N+X=d$*M<0gFf1sreU_y&#*IJV%P|HZ+tW0lf1Xy+!hqd&&bhYOg4mw=8c z*h$K9;tgJL;#autQ;riK;9P?5{)RK|JMl-*+%KeI&}TjlKWKCl&R=6L`7+icjpA6( zmc|c);k9`0LXR6#-2Zvq^J;tYKg4*31snl0eh2z?Kl*Y%s|Am$lU%^Jc07vy9cHEC zxK}w#gXrfe=x3^9`8>ZR-#@}U@;S2)X(i_5Dpor0Az@zVk3RH!CC2rCe8?tNX{Z7X z9ATCGUcl-ZyyGYf^V?YsXuAjVFe*KVci)NYW2{$Ni|+|vxaQwu4I1wFRjiDi%WUjt zCvIRr!1c2ycCp7;8J}W7c90p^izjZvwV(eKZ7*dt{7K;Cc^2irV0HWlxPBe&U5)P_ zWbNYmi!A(!+Ixd7LciKX8#m9iCv`Kg9AGxN6h{Lqlgn^!#krA{Nq@oWL~GJJ$|3K- zn0K*uc`;kZ7lZak*lO*JXHVi$b}}8(>8zO!?qh4DW_CV5&Z^{Iz$y!E%FIU3fY#4P z+viIUvh(DP>^$jZ-1h`v@C4xfJS)Ia#eWAH{2d#^caeneKg4%*gxG4S0DZXY#6JO_ zW}X4qybT)q0jr>c#{lOT&gZZ$_5$mY-v^KVEBY2-vh+9$$+dW2jQQo&teXE4IKG$N zN{94A^otJSHJ5|$901Lo4cva8`EcDM_@R*%VC^^`eCK>=BaVl0ysIAVY(%1CKs|26 z_rn<9Z&{!8MYc*>jnC)e{p(q);dRyu=qCTm!58{t6rWe&FyY9?QHDeN9hagF$TO@H zM-h%n9B1Gt#L>psi4Sph^DFTEQNY6h_!?Ly^@HCGUUnnogwJ67S743Y&fM%n-2d;4 zBngsqWE^?;OHxuEGZ;uV+RmboSwhUo>ewQ-hnsmW_i#V2#?`ab8o z&mZug5y%PT2ZjR^flVh);H^$bH1({5-N9|##l5_ONBAP%&3pL}AK|O`7(avW;CJwU z}Tv{_6mE8{T6fdUG_WnM{eLIP>zjff_ln< z%~~Gf&HOw3C;YqoxBPm3EB_gPnZLk)!Jp$#OEUivKZ!re-{#-s&qxmbEdLGP%Reib z_&fY5e!Jx4m+{s7`}`pP9REDO4f8}|R_0=vEFUyo%p%~p<*b(Vf*W+R9#)DObsAgE z*09ZNod1e#VwbQB*oEvOb{*RWUUDD%D!YpvV)wEbJIo$nKW5)&kF%%Quh}ozuh^^X zVfF|1clIat-&|tAM*d^n z*1FrlzrF@d=uT!~UkCrV1N{0KaFAzNBl{_;txzy~j>re}NA5S2o7}#!hB`2A}%}n_$P;B>RX>v1zuR{gYkH-Rx3c$S&g^ z_F2ekmvb+>f|s(ryqMj{8`urJp54S7*_U_|{~mvYKfoX25Az54Px6IUJKhL4VL6j>#e4#v^iGKI`j`<@Wm~Ci)IZdHsEd%H{r;)W7xu zU(6B2MgQc+m|@ighb2iuGco_BVn2O*I3u6;75i`*>^q#xbMad+##W7O8b9pfbZ0nb z2*u>uSbqN)y)WkK?^kc|_%`}tkFSauD%KsY;?DlT4TCY$;Fv!qmyfSLZ45Vi4vzU^ zt5)Hw=(xuhYp1LB@p0cFEn4^C~jIADvvE_8LAD=zTV_v%QE}wibi*2Ag9yGG4@$rq5<1t=4 zKCX^n+_w?q3HFVLW5$qg&=)h5Phw2w{#9c!bFeRF3HAX4cw{0RGYN15626Ux%u{_n z`jLj~QMylmWAen{hM2L&k01Me2YmdlqG=T0}M8xZP9_V*^1T&d_9mhz9(ElNPn~1DA zPzV!NZV*83kFlP^SO5e)4o&d<(g)!#WhGXu~Ax`H4z?;s6p+hpAi$aGCboPV}8R=XcI%J}A zN$8N7&fd@=3!O_thpcq24FMVo{9~pGKsxA)#Q5nnTf(uB?nhVr$4@IihIKzy#DCnS z{OAj@SZ3{<c{8D_QQ%=a> zWP6Yp`@DC69K*3j4aJ8tOoK#mv4)yMMxH-72Bt_@Y|?>eu6vq8z82B*7PO|cGdP<~ zfcvEEEq%{iM;ZMa=n1wTYUcSg)>gm+14#MI1SZR5dpOn>inzPNvG#Pgg0MH>_C-Ju zbCvrdz9C`>0PWI)2Zw?~U_oP8_`r3sBDHg#p9ctcfOolKZro-7J1rNt9dfX~n7zMt z)4@p4=j%F%Hain<^F@^QVy0l9cDFA!K|CdT(%2D$&*<|UF;p0f#`}oV+Q78&e)J%? zWFlq)wiBl-W)`niRwYCK#KvIEh(#65&d@*U!S%#A_(SqDljs`we{jiUyC;Y@EWtQ& z5^q6!DPDq(5KA_Ji2xZ!5FA#Jq@JL~Nm{L_6Egm)oOI@0AeJr-7EnuK#tIb{!7hNQ zJN{G524>>(EeQ_MO9_iT+HYbo3Z!Ff?O4Rug$12@FDYN5(Ksq%rgD5*il5@Li-N@2 zls6Gnu?5Z_jxE;SCZMmPYk~xv(THzucH_L zxZ2MlFyM|_4JyO<)LH8%oDp2q(ZoTp6d=e~rahz+%<$YutQE6-*&N?5M{_(cH`a!G zMnbVgIISQk4g%c1C0K+tXpM%5pkpfl=9Qttj4i>%DqL{7I4N|Pi*HurLVUA^?is>Q zYv~@kIGOIDi&N+xx;Qm-1iY{xS7W%s+K#L7&=F`!bhQpw%6F&HeVo4g6x}DTPN)0C z)fsf3xLQx|9K>xC^iH~%q<7NA6upx!Hqbr8xY$Vd(8VUYhb}hLJ#?`}^lJcDTSdR< zYMba6U7acVMOSBue$mx-(J#6>Tl9;rc8Gq_)j5E1R~&&m#iv*lKYUub?8W6S0y^Cm z>%*sWvDV^}?m17nq29dGqB;*)sd0_BpPxKO#I+b=>p-Aa=eE0=V` zCCVk;a4GKXiMO{$d=l+lrd-nPdzDMN{j+$oM``bJ<&ti=Lb;?HuEf2ILb8pSJckXEJP4%;YuR{TZOpPMCYPM_ zvX^l~^uUI-;!gZP{~icQWm29=&Zv2ipV${Od;#nAz!Bp{6B`&H`5@iDtJqcWEuKR@ zL+Jx42lm2l=}#x#mzh+2;{6lt^c%G5^{`<#vX@TOXrJXa{$;t7UniQchaKoySa0r; z@}%eF`{efxs|<&Y5#uYSyUYgji1`{zp5+wFBi0J*Gd729v+YTHjr}%9z2k1j+s=sd zD(4H%>5PtyD>I(Ytjau?Wy`uh`{<{NG;Lc!9@TRgr*}dg^DlVvawX(JHgR1+h4b^?sSJs$n zmet%^^N-r8+E+rSgp0yAgzpc(8!3xi6!~uCPjwx2x7NK|AF4lG|9-=ghC3Sm(s*^# zK-1Mt&o{R=-_a6n+1PSJ%ZshWt(UYu-ukCDSKCP2HEr*-SF}&Gzq)AqqOUA^rNi5? zwd3v1on6IUzwExFXJYa0=+WNoeMNnL>3@9SnZXa1+%ps%dVcu6r5#KEvFzyb`$sNc z!B(8I;=$3X(ZeePEALp9wd&53T2H$Ar1w@&tbT9JjW~zwU+8uKv{C(>I^7WPQQTOyo?AP24f@@}zHa+2rBL*QSQ2 zp52hQp=ZO`hCLhZ-tfZ4){U2Md~?&P&BdEHZhm-6^On1|c5Hoi+rcxJo%!ThfwL~! z?%IC&+3f5CJ8U~%Jtwv^W9R0b&wqORu8v*zo@+Yy%5$GN&wJjL=e>LW*!eH)-n{$m z&y;;;=VzY0VC@C>TIb(~-FD~gmfP!ZzweH!JI=i0hC5#VTKm_we(m); z+wYvZ^Tn@+zP{t@H-G)LyO!K_!CmitWAq!BeB)1dkKBFj-QWMF^v$i`yzQQ%doH-= z?r#-+>%4E>|E;&~t+@Bvd!PJv$G30(_S5&Z-*?k}hrhGxJHP+#_y6_o*lUNS!_O=K z9SI$I;mGfgW*i+pdezZ4k42769lQH}cK>8>dmeuMdwJhm_Pr~=_u3guPSdFt(_+0&y>U-a~gPrv*0^p8S6TKA*tpJ{*Qrf2Sd=CvP3e*FHk@BgIr zCl~zWAJ5(N+|xg`{q&OOEzb`>zw7zypN~EN%Jav67Wmm2KfC*dq8B<|xZ#CYexCF5 zuAlGu`P(mgUp(W*125k5;?pnw;ia6HMqj$=W!uX&FF*Y9-ZWg z;C_Jj$iO6SkhX$<;`*wc20Jd#q6o&m2FLY=6(FTn^s>oZPmY3uIERN7pHvp6Rei!kjFp z-E4%X#%(vdYIz_cH#F6IrKZLn2^Sf%zIPWl_myYL>kOFzuQ!loSSM$d_f4Od(Ayes zB9Pu81Mb~qK0yD!6fSCu^73|dL+6rzn@ew1Jfg0I>r3SDi@EPn}G8v?8 zz@EiyvutH1Q{D=PRWh6N&7;M|Mnid7aj-ZT@cS?jS6+6e-D>a{J=s}KbAbS>Jnv8l z(6^@_&YS$m^iA^&37rm}$dX=?HC#m4y6CBi*sv#I!{+2xn{|`GibKUpS&;BjQxycW zPmP_7blAxbRJ1g=HZ{8P^GrNFru;Q9Mb_5J^C0Z_10io|C{*eVA!cY6?oN-ci{lP% z6~Y~}bKsE!d5#pwbLx=CSZefvGUV(Gvl|#}a+~MH;IgwWTir?+G@Paoq__YaXxak? z!{CJ3#!MO9U@;lC!S`UZI&52+#gexo(=J)9`PNZSv`2Yf-uuZK7>`!8w0KJx>*;Rk zYU%3iSk&IuS{JUVDhre}dz;Z|Oo5s#a4inxx3#slwlr4+113{`u%cBZ2AVPRLUN7G zndx%n<>uxXR`G(?nvVWZg`qkTIePA3)JjQYze+Rw3Nvg(ol4m8R0iJf~1q%B4YR$O^uI7!;Oue zBF;KH8W%MZ_F9^oLN%4;{?eibPlGEzH#@^&1p%=_UYMiIaR~^_4>lA2h!MB8H07C0 z^P}(wo{Ew}XSQ=BtES3RSyGUZNtfrOXK??WTbS$h_W zRvwBD7@_25bF(ZVvXQfW7D?hVRv|Mp8qCJ6OqTOjSWS|_kZ%|@n-PHJT`Xx-*l_W3~yQw~h(Q@rD7b(m2te#4z(W=d;ze|Uc`g<=wI{j?wKCA170>={Sa}p$NNszQUxW#JO zBp_;6A)1>*FfDZFl;oDohAgdxvmvWrDN;gNTN`EpTfZWtg8MB|8{iI^ir_9hZqYh$ zN`wLPy>@^fPHK4VLnPHw;KNtmf$onv)4Zx%?kHTXiT z2|oDaYk5Z0R?5w$S`L{(T^JUDSD2Z}yf@P#nL#+#J^BSALtj-wnbn%N zBFiD!Z22~AU5ILYAaz3uQ_tY@v=bdlUKVCGHM2a-*AiBRTED*tt3pk+zsgU@DGLUQ zC@2XsdE%OoJ$JjI501nYEQz2nwB9odQpC=U`e>DXxE#d#pvp_o=w-fK!AohJFa)Iv!dL-cf;0&e zL!pE^r%Ir|*eCqHJ)bH~br(L&K! zs`TrSU0sJ#87)-(1OkzwpNQv_Wd|k#v3#X`EZRD2*@-J5^F4@FMyxSdS^P!9Op?5| zIOkbg^ngsQT}&@Z0Og|O#pL*pbWjxbIQgfb@k~hQebL1^pl?{qBqm!Vc?+4Uphj;2 zJ;Ufv1P=3valKVtNf3dYg$FZXp`(8`b4hIz_vgp|YYDiO49dZLQrwy=;upib ze`sS%c~gE-Z++wT@xpI?tE0KO{>YKOt}f~A#|nC z?HAG7nkeQ?_{96tucbdBZm~1k&LkO@O9;hAQc?Fn9pICITM>bbO`15v#2e+Tx-#g8 zUXW$CBCNm83{)Ef5virQ2P&o4&=9PT$iYAcx+{!*WcAB1@SE1{KP?_we21c{xl8N?eKP6@7eo@h7Oz^t>_x)swl3lDXv}isrvfUmW4`c zYDy})`@1WMuZe!kzXjBr*kH7e8H{Eqg?piO8O^YvpsT{xXEe%_7zRdQGD(vbAiyJy z8V%vv%JLGAD=*V&XfifsXIW<~d+EmUV>0h8a4d2iEUdW;7?3ue7r|#+*~!t>GA-C6 zAX$m+vvNeP?t^K_0-Nhz*ep#J2>B+HVG?M;$joMW%En~`qK{gjvDHUvs(fC;kGa*- ziUv(N*>*9$CU;wt8OF86o3ms)3wryMN_{$3zC+KbZpMu^-*i>}=K#n9? z4OnQTkz8)!%agHa1w*sKP>ZN)V006d=^1QVCSecrSU`?LgZ+J79j(own)0BxIMWGs zKEg*7n{!1&OTCNMAcc)LRJKLr`3co*ChkvDjbB;Rk+E*`nUA8bid;TwT%{h|~6qkQzGK zNOWm3Q~)U=?rjO+kS0MUMBF48w=~s-2^Q{rN2jw>5sGa|Lh%CV;HF?W91KPx!G#h+ zOT_1oz{(ss{=@=^0X$O@Jo91jOb2uT_)?5I776aN9>hT`GQ$Zzo#}LExN)Q1Z&Fz! zvBV4sEHN7xCKOi;bKAQ8>%w9DFcp7Xe@ zpXsO$W;{sNrR|qZKRz3Ha!}0eU(W%=|3AVI0V{xEH}Gm_bYT#gmX^!E`Ax=`8LeFQS;3S8Dr+XL>D=1G`d_*6`1&-3fLb_ zW+UVrgFzz>v)QuV#)a93NW+|CRt=Aufiz)Z0CB{{9`06_fa&LSU5b-}(kTrVWUm!{ zA{!&59e`w?RkbgEf<9%7#Y#0T%DO(?tQZ>Z*YzlFMO4A0&YznNioMpw&WN5y)>@JV z70H-Pg-MoLI0dX`3zTD%aXqFx$vc9$LN0j7O|(bk!0wAJ@i3hAu2 z`u--EY;}Ex9ibn7K7yl{`mBSbJqeOCE%amD%i88&npHdA+dOGP~WeK9f5f`3`8|OO$pDn&tKr zHU!U~V!|z}E31oyL#d6pX+3|bfcDFj))Uo=dsH`b)0%J<&?@U;zd^CzhIqv>GTv$) zy*|tsVYtmoWw?cBPiVe&=5X2_Tj0>Mt{2u@=5%JP&*m8!`5Br8cd5o7Qn#q6((v#t zpuy;>SvqxcFF`Idp1$-=lBZ9x>H0%{4O??sb?5W+r9#=i%xl&?ro-9XHr=Fo(68Eb zgKSlF1=xUHvZ=OQK*C}&ZvmSHtDx8skh>R=Bha3P^S>2o@;TI}t;oH|r`kh16!< zgsJ;Ll3iC%cqxs#@&0RT+DniM+(NG;VH;})9K0zPczA*-YQ%w#JKPTU%sP=;=uTK; zM^y28`VAeBTj~@My6*T-;!=lL|7nd7GL4%oiE*jMY~?1iX;YGQ1BPdkZjkQ86bQzp z99xo*;r3bJh!~s>NC90cWGI zHE#orma+??b{97pp@kzQTD3A)#c5V=iBo0d6<`UbNyuP@CM#P= zGhS8-n=}0*!CEiCr?NlmNL({N5A=FbU`UK1!SW2%0NqgJeX`G%v$qtpY=hn`>rhu2 z3$xlR)-71IP3tiXa5kIGzTV08@gtCf6e(0^p9SL(E5Q8EM;FgpRN`yMf?B{D5+tu+ zQF#$L1XJ2<)i|(}F(L_!>xPseA=Ce&BiNKCTa8XgKO{9Y<{TF#qTD7xxC+wIeSu*X#YHl=`gjU8U zXAGHUC~d-!N#4!M@>rc7Z(Y74>#LzbaiPd^A_=BKVced)HLT9@U3*;U=w z(0OV{ar2guu`T9j`RLe}Y*EFK*<0Sx+fnYRs`7+j$n3pfWMb9!wsUV;N&Qyre!r7{ zhSjE@jYm&W<19^Po7rUB%aE+jj1&uIgDc-AT=~XHOchxDEEdC*1Ca67ZRHd-3dCd{CYv}Vx6@t>&}Miwn($clFSQu={vI42pSjjfC#a@c4w8erMwh^`fe zJw&O&#!F(7tg~JN2AYAdsj;Cx5>ickZO%4D+E7iN@mS4;;aHVIDi$)x8R_JSv;)<~ zN&e3|vRTB($*gLW&b;F^LS7)%q@ql<)Ylp7(3A}O0D?F%5f?N(#utBmAtR%u?4h({ zQfy0K5OP>$w2bsInH#o%t;4TmQvIQr*NHYI@UCq5knnf-0B<>PU>c&`^b5THqhhS5 zPbz%C1!nMfw3`b~08b1BkRuQa!AY%;llSx1{lmlir=R7m)6YtgZ7c9^+ea@_ANbLWFc$D-_5%7K zv%)C)gCW6@M&?&s6X0cGc5T8a$o;1D8^7^-VGlF2VAO|JXp!3@jHd|QRYKDZNScB9 z#0aS<7h&l#pz+k^HGAGVpqaU-HR;6bXlqFR73QjqHAk_`#_dT))e1rpebAgo-pA5bEP3skGQKHU#wW}nf||4* zkC>Q3qMz@!@o1iq-+d)Bk!u#B(ByDuB_06lK2$yG>sgApo|l_#TvgCgOEJyH?FZ(S z(G~jsE5_3x`-R0 zn1_i09l3Zk9>}52)A1dWTl0vD8G89uyiu)k}dxQjB zQZy?Pj(lUe@QbF3jwZ;z>ZzpAHwt{NET(X15&CxIfSgjy1+qD-I`KaL3hEP%kLkiwWhW;Abx3ThQz-ofqLGc%ZX&Yn|+sT)RYulJDCnt_fln3HLPK!{{z0;5J z(b)7GSco3u(dn;F@8zqfzl(lTyHT`jWTlAu5#bW(`~+k%wMip0W@j6~Y>7Pl`Dkn> zNZ|V2s;;y|@Bl@!*RKU|j2I?V$sX@Gyb0it9FBIgFhk0b)b$(;H53P8*)ZD_kK-wa2}X=7stZoHB20rQPv6QnfApW)JEYt34l65*`YoV{ROCVWRh&FH zx=z$_317SalIi=J;xHJs^v0U4-vUn*@TSozVTiA$VAj-G6_eM6$$9&wf2G zvnjmH%!xhOt!kjPk~0FU$1H9Jp^ms&(*1~MpOc@Zd;%P;RB*Ilk%+!dj=@p_+EQ~h zAVb5)V=0;2^i~Z#DS4a@R6@>|GogFuB4#}tt-)01lS11T-uy{OG{o)l^EfN<H;i36V`g zvnnhw8vLaNm4%gv3vwIXlt>xR_|t-BQup)cAnN)0Dt90f$TEM2L33K4e*)SsLftqu z2Zk6AysBnM!3N~N#6y2XB)}wvCm{Bx*hA4jWdTo3aSdXBiY!H>^d{uM&>E*kzoZ<- z?-ikWSOd0p)&X5^5`o-5n13j!1x>I=?i%IIc#+4T=FQM5sLTf|d16f{YPmO#6^lif z0EQhw1+qk-?N< z29%NJA^3O|>R>FHKH@&!qy(LG0F>&<^>Fe6Cc;t85xf8zp}BA^^qC+mK11xm1n)5A zaq7IoFk*>xPzM$^3o}|~qU3Eh5%X5GA_JZa^Q05t^Oym+#YzJP%`NgiqFKD214E?m}D-nNjW9=&R$Iv3LdxF&`AG<}XTE0<{;M?32|&o}2y`$>ZZD5NS+ zxLww1jxSWHBJQUtp@Lo-==2-16>nX}SKVfsa7UWrUw zPUe@)bnA#F?`Sd_jRWPoDP&70q)~vQG)cojd@BiQ4AJs4Vj7?!L)KR^`*lX)lp+q_ zLCO6Z0cvMGA$Gt9^}PkapLlv9czQjHvi*v6DgsW7uJ3|GCU`hjG|W^z1=<7!S``ZP zK;#z>$a@#~1boho4V1JGS5LGtT2)a}@2y84C{;gYnv13y(~kjK&Y5Suub4=IVFdKwTdRtzSJa7KfHuZI#r_Fm}6lbFb)M0lb_%Fl}B zeOtnh99G*6^}Fkv`Ugn+ z&<#3H+YKE9EnSATubqsz?j+Si%umui$d*o3-Y+;-$W0<1j7C^ViH+RAW+Y#eNsZCuC-$Y}%G^-+0s=@EZ*^RsQ-weSTh!)8I4uQYAH{9sN%sA%8`~^jr`S zI(v?W;rYNI;6U=Rg6DR?L67M&1(hbKF_b15$2ci84D(&ZI6~ryAXJPq_4b6D#-k-D z4g=gbH-_6I?THjX*1Z`PmktK0ncZBl7*Yl31yF^K?aiW(%M$3LisV6H9`Y|@(giP! z69}n%@kAC6+~>1yRVgIlM(_=y5QaK1dKy7LWW0f5K+OL^sEVLT-RW9D z)i0~rwk=^8C|ejUZ*QY&EYY5}CGAUU5rS;~PxSub=W@dFxi}|-wTI|kBi~HG@ zyhj&+bX9K>b~H2AU`W_xg9pBpT$#9ZjYvdD*CebVCZ(jmcqg3!SnpcUG&WgIqx}YE9^i<_mkssFGR)JVw$|-eG?vf&}!P{PG zmh)V0UO}~}aus`VC3meK?Cc^*EwnOFyl+6c4vz*(YB49xRdp~J{|CVD@l1bMRd80@ zmsgS9I4d)BX6wzXNPf%84D-*|jR*NHS!AF@1_B6=MJl`|~}B)Cr9(sm~HeTgamTYp^at z45_Up(p}eG9xMub!inr?z9j8*^v&N%$%M|)Xk9z&X}&*M#Xo)|%?yB^0HZjvy|M3% zpS8e72&*FeK}e;>UY?#@Mi9)*O${RC&6`k%&gWXep2 zw+l(i@{`kFm8gNCTB_dk7wX1`Yxw`v z4z0sVMZVJA=o{rO*25hQQA{%MEkY+zQq9Q$0#9s0`l6%4Pq8ZSL8__UNEFni4Pw*B z@!g%ByT@<6<(6AQ9VI0l(%VCy?(Y8d@Q2cl6Xl(yz7C3+)ZU!|szj^|L!7Mv?p0{b zl*BD<0pTi;!L-iGq%iQz!mL7+p~$H4!x<{P`$!~L${Zy1;J~f3dT{B`&hDO1!#DWn z6BQl4(#~@7kCBbP6gCKCZn8bgV{UMT3@S5|1Def;Vk?b!^j{#}WMo{qt$>96N$`at zRlSoC<*4X)#WO#F>*Ob@f6(wK*s-FlAXFGaO(rWVeVV?>CTbid%tws z`8VBk(;`af@7%O_=$t|XaaByeD((1HZzvQczY!>3+AH+U9KaD(5S-iq!3^_-xDMiy z5vgW2Y@sz;O;FSdz~to`4DS3~PoBq+W5~&N2WX+zytb+P#aC`#a~a}&lY6ImbLZ_N z`E~Q*6Iv5pMV{R(ZnS8QT{V*tW^aSVxP=63RV}uuHObtl3P=fVF0b2*ybF<)r{%d) zaHs0wr#x3d*-7wxM|J%kpWXAg6F6nxISZjq!Zy%)8%aNiWaLKD&j{Pn9$lQK&f-Qz zH_HN)kd7FSHr}R^sey{1%1TChWhMFJL^VE{$|i^a6fACTrPc#qGdR^-k!w0=%n5de z28LQ&rWS4J>fgO``BZ1Ib)TiUy{^Bzy>46QR7dm@TcEYOHBeAqR@fb`2;1`=^{b=( zQ!SxrG*ntu+7&FTvb&tkCohg}?WVaa#v|yZlTrNylUX*KpihvK+Qk$XE*qLBz_&DRcWb^9FZul5uR4fl4W1!kNiROCP+UQ9{aeM@H zhA$-Ogs3BEHW>Gk)P!{yA-8cKq(G{!tU^OwZFPB&N=BG_EIoL9R#}CQGxA&|vbgr+ z3|@^e{usjt8bheJKywLMgHZbK}Qg9sB%N9rxB2aoRN`bOR0E6eFSAI3qOD;b{!F!yL;d144FzUxPPMeoIj6dJpHg6V*0=fk^tBw8+FSa1s?yK1p3OaSncO8x zIsgR2;2#HphcG)onpF-9Mn!fOWMh~;B|?M;h$tn|2FnB{p{zyugD@uzhyzyE#$bv& z5OJdfm8s=f#-q6$=JBFJB2{pnFb^x7M~f~Kr7(gmZS@r-LgyozND;DK4f$r3?|izV zEmGVy>>FKG^5v3R_o=7v^Dn<-U35#=9gU~7bX;OHpJVj3cpEyFzhyFQJAHbHKh&{f zyv5~;{=wY1roDaBVn`ntb2S*vg;*QS%pdh)6=JGISB*EsJxXb83O4j3ZrP7`W$t`i zTJ^THWB2ZluGhDL9etxAL(4T12ZN5}G?7eU#dM zN_mQKql99k z3E@VvHX4tz;#@>8mi?lZ!W-(~03Xavf=@Z?jrNqGhOq%jQz-KYLDFn8m{9>3Ifuqg zI>=#msii3JPTE!1!H00)F zI&D@ZP=_RHiZhb~Dc<`HNGo5}y<1sEQguLkKgFOI5iRIq(ADT03O`3r5WX=*CTO+j zjw>GtH>l&P1>I2SnOegD2IX0x_`c#0U8e7p5~u#tRNW%^PQc}H)yEl)b^`}-4<|)E zqE9;KrsCSHtO)wOCCQG>&(RX`xaQn|v4ng2HOS(nS zP^r*ajHI&|HtDEIC zq)TZYNFC9s&PO9m=rd#ow8+q>8n94@zL>UHfI4}J~o0u@#acisRXW5 z9g;SfZ<2&uGu6G+ir(^D2BXESgVHv#eRJ{;<$_0Zq zDYGCutH3a70*W;p8=|oFS`EdKz-HiS+8YwhDVcuGK6pVVywEvcnow8U%G?HmE!!t2slNNbu z$l6phc#t)eWyr#tkTzs77b|H)`u9l@Dy!D2f2)v>isybe>2bNNKN=-HF2`x3vJgrx zg}Rpr@~lX)L^a?UwLaIJOQqhlJR&87f-9-Tvc5xnh}5{@rN2$+(oDW_B0yvn9*PcE zM!6k9uLnkXvAb5h-aWF&Nx{UPA!e#9q^2R46_ioYj*{ZUWaRlO-6wQa+MHAq={E^| zo~^`Dsr$tKC*l)U@HjpxN(pAwEh<8_lq_&pMkrNMvS1s~$7%8eKt4x7_qQi>NlOJz z>;!lsN5x+zeIImdXBE+)4FJTJWTIFurhw>!lKhAyd=Qh!WX%VS)=3;tf zbIE-~nNqOE{-kihW$N}}y zTodMuc>6cNRQ|U14r~W<Ks29$^g>FUGa(U$*q*#sNt**4 zjq7RQ&{F3z&6!Sz&5CD{G9;2EwOo32gx^sj^rd(oDn(xXuM&DeD?i{Te_z-sd}P-u zEiS}nb%Mr0(n_8^jhE7X#hgyENt=BNl4DjL_x8w8yDNf8Og+SDr2VzEnL=b?4%qbWj%$o5* z>jiJve%YE!;t+^H$@#hd-N3s_Ck7{U>vFdHfik~WAV}F_iFZ)Av%W|3xDK&m}26uI`5h8^+YmCcb6a{RT_ zV@8fZAq%j>5BR}&XOcO6df~&WPv%ssr>aqEl_}&7lx&&F<-7P5eop>8f}*`NXiR$t+v7CD*JHas7~%4eW_R4ik5_9o2~mm+p(Qf{ z0n&;|98kDpf}Nkw^u?}L%za$f9o4Bk(;-QLv8eZvPjU(NDv+Hj8~||uVw(W+3?Nh{ zEoy+|JK*sq*+i)WLMBnJtInb8O;oEb_>!(Wy22ncT>=DJVOt)Ridg}6NK)fCY*