From 9fc0a948f50338a4dc18e53a7f14ad1d2de39af5 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 19 May 2026 14:48:06 +0300 Subject: [PATCH 01/33] fix: update namespace and applicationId to match new package structure; restore MainActivity --- android/app/build.gradle.kts | 4 ++-- .../org/nehemiah_osc/{bibleflutter => bible}/MainActivity.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename android/app/src/main/kotlin/org/nehemiah_osc/{bibleflutter => bible}/MainActivity.kt (70%) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5dface7..60a7dce 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } android { - namespace = "org.nehemiah_osc.bibleflutter" + namespace = "org.nehemiah_osc.bible" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -21,7 +21,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "org.nehemiah_osc.bibleflutter" + applicationId = "org.nehemiah_osc.bible" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion diff --git a/android/app/src/main/kotlin/org/nehemiah_osc/bibleflutter/MainActivity.kt b/android/app/src/main/kotlin/org/nehemiah_osc/bible/MainActivity.kt similarity index 70% rename from android/app/src/main/kotlin/org/nehemiah_osc/bibleflutter/MainActivity.kt rename to android/app/src/main/kotlin/org/nehemiah_osc/bible/MainActivity.kt index b52e7b9..3ea1448 100644 --- a/android/app/src/main/kotlin/org/nehemiah_osc/bibleflutter/MainActivity.kt +++ b/android/app/src/main/kotlin/org/nehemiah_osc/bible/MainActivity.kt @@ -1,4 +1,4 @@ -package org.nehemiah_osc.bibleflutter +package org.nehemiah_osc.bible import io.flutter.embedding.android.FlutterActivity From 548d2637b8ad547380517df8b1592b22afe171af Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 19 May 2026 14:59:43 +0300 Subject: [PATCH 02/33] =?UTF-8?q?feat:=20add=20auth=20foundation=20?= =?UTF-8?q?=E2=80=94=20ApiClient,=20AuthStorage,=20UserProfile,=20and=20de?= =?UTF-8?q?ps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds http, flutter_secure_storage, and google_sign_in packages. Creates core/api/api_client.dart (typed HTTP wrapper with Bearer token support), core/auth/auth_storage.dart (secure JWT persistence), and core/auth/user_profile.dart (UserProfile model). Co-Authored-By: Claude Sonnet 4.6 --- lib/core/api/api_client.dart | 84 ++++++++++++++++ lib/core/auth/auth_storage.dart | 16 +++ lib/core/auth/user_profile.dart | 31 ++++++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 98 ++++++++++++++++++- pubspec.yaml | 3 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 10 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 lib/core/api/api_client.dart create mode 100644 lib/core/auth/auth_storage.dart create mode 100644 lib/core/auth/user_profile.dart diff --git a/lib/core/api/api_client.dart b/lib/core/api/api_client.dart new file mode 100644 index 0000000..c161fdf --- /dev/null +++ b/lib/core/api/api_client.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +const _baseUrl = 'https://eotcbiblebe.onrender.com/api/v1'; + +class ApiException implements Exception { + const ApiException(this.message, {this.statusCode}); + final String message; + final int? statusCode; + + bool get isUnauthorized => statusCode == 401; + bool get isAccountLocked => statusCode == 423 || statusCode == 429; + + @override + String toString() => message; +} + +class ApiClient { + const ApiClient(); + + Map _headers({String? token}) => { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }; + + Future> get(String path, {String? token}) async { + final res = await http.get( + Uri.parse('$_baseUrl$path'), + headers: _headers(token: token), + ); + return _parse(res); + } + + Future> post( + String path, { + Map? body, + String? token, + }) async { + final res = await http.post( + Uri.parse('$_baseUrl$path'), + headers: _headers(token: token), + body: body != null ? jsonEncode(body) : null, + ); + return _parse(res); + } + + Future> put( + String path, { + Map? body, + String? token, + }) async { + final res = await http.put( + Uri.parse('$_baseUrl$path'), + headers: _headers(token: token), + body: body != null ? jsonEncode(body) : null, + ); + return _parse(res); + } + + Future> delete(String path, {String? token}) async { + final res = await http.delete( + Uri.parse('$_baseUrl$path'), + headers: _headers(token: token), + ); + return _parse(res); + } + + Map _parse(http.Response res) { + late Map body; + try { + body = jsonDecode(res.body) as Map; + } catch (_) { + body = {}; + } + + if (res.statusCode >= 200 && res.statusCode < 300) return body; + + final message = body['message'] as String? ?? + body['error'] as String? ?? + 'Something went wrong'; + + throw ApiException(message, statusCode: res.statusCode); + } +} diff --git a/lib/core/auth/auth_storage.dart b/lib/core/auth/auth_storage.dart new file mode 100644 index 0000000..ac413bd --- /dev/null +++ b/lib/core/auth/auth_storage.dart @@ -0,0 +1,16 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class AuthStorage { + const AuthStorage(this._storage); + + final FlutterSecureStorage _storage; + + static const _tokenKey = 'auth_token'; + + Future saveToken(String token) => + _storage.write(key: _tokenKey, value: token); + + Future readToken() => _storage.read(key: _tokenKey); + + Future clearToken() => _storage.delete(key: _tokenKey); +} diff --git a/lib/core/auth/user_profile.dart b/lib/core/auth/user_profile.dart new file mode 100644 index 0000000..cfa869d --- /dev/null +++ b/lib/core/auth/user_profile.dart @@ -0,0 +1,31 @@ +class UserProfile { + const UserProfile({ + required this.id, + required this.name, + required this.email, + this.avatar, + }); + + final String id; + final String name; + final String email; + final String? avatar; + + factory UserProfile.fromJson(Map json) { + return UserProfile( + id: json['_id'] as String? ?? json['id'] as String, + name: json['name'] as String, + email: json['email'] as String, + avatar: json['avatar'] as String?, + ); + } + + UserProfile copyWith({String? name, String? avatar}) { + return UserProfile( + id: id, + name: name ?? this.name, + email: email, + avatar: avatar ?? this.avatar, + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 3792af4..5a27a5d 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 21d8f8b..015388d 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux gtk url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 00aa41f..744d5d9 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,11 +6,15 @@ import FlutterMacOS import Foundation import app_links +import flutter_secure_storage_darwin +import google_sign_in_ios import share_plus import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/pubspec.lock b/pubspec.lock index ad494d2..879b164 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -174,6 +174,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "6848263f9744072d0977347c383fb8b57d9780319a6bf5238b5a2866a029de62" + url: "https://pub.dev" + source: hosted + version: "10.2.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "67cd1ff671add31dc13e45194398187a04bb63804b37fa47866afae296d73fcb" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "8f42f359f187a94dce7a3ab2ec5903d013dddfc7127078ebab19fa244c3840e8" + url: "https://pub.dev" + source: hosted + version: "4.2.1" flutter_test: dependency: "direct dev" description: flutter @@ -200,6 +248,54 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96" + url: "https://pub.dev" + source: hosted + version: "5.9.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded" + url: "https://pub.dev" + source: hosted + version: "0.12.4+4" gtk: dependency: transitive description: @@ -217,7 +313,7 @@ packages: source: hosted version: "1.0.3" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/pubspec.yaml b/pubspec.yaml index eb392a5..321deae 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,9 @@ dependencies: path: ^1.9.0 app_links: ^6.0.0 share_plus: ^13.1.0 + http: ^1.2.0 + flutter_secure_storage: ^10.2.0 + google_sign_in: ^6.0.0 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index c71aa3c..8e4d43e 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,12 +7,15 @@ #include "generated_plugin_registrant.h" #include +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 003474d..3fa8ff7 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + flutter_secure_storage_windows share_plus url_launcher_windows ) From f65ce58dd68e20676d4dc40504d3b0b02ad0d6ba Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 19 May 2026 15:01:19 +0300 Subject: [PATCH 03/33] feat: add AuthRepository, AuthState, and Riverpod auth providers AuthRepository wraps all /auth/* endpoints including Google Sign-In (idToken flow). AuthNotifier auto-inits on first read: reads stored token, validates via GET /profile, and resolves to authenticated or unauthenticated. Logout is best-effort on the server and always clears the token locally. Co-Authored-By: Claude Sonnet 4.6 --- lib/core/auth/auth_repository.dart | 84 ++++++++++++++++++++ lib/core/auth/auth_state.dart | 119 +++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 lib/core/auth/auth_repository.dart create mode 100644 lib/core/auth/auth_state.dart diff --git a/lib/core/auth/auth_repository.dart b/lib/core/auth/auth_repository.dart new file mode 100644 index 0000000..ba39ed7 --- /dev/null +++ b/lib/core/auth/auth_repository.dart @@ -0,0 +1,84 @@ +import 'package:google_sign_in/google_sign_in.dart'; +import '../api/api_client.dart'; +import 'user_profile.dart'; + +const _webClientId = + '633243120991-53mhcrmdns9ngi06hj2f1gj5ja7t6n1p.apps.googleusercontent.com'; + +class AuthRepository { + const AuthRepository(this._api); + + final ApiClient _api; + + // ── Auth endpoints ──────────────────────────────────────────────────────── + + Future register(String name, String email, String password) async { + await _api.post('/auth/register', body: { + 'name': name, + 'email': email, + 'password': password, + }); + } + + Future verifyOtp(String email, String otp) async { + final res = await _api.post('/auth/verify-otp', body: { + 'email': email, + 'otp': otp, + }); + return res['data']['token'] as String; + } + + Future resendOtp(String email) async { + await _api.post('/auth/resend-otp', body: {'email': email}); + } + + Future login(String email, String password) async { + final res = await _api.post('/auth/login', body: { + 'email': email, + 'password': password, + }); + return res['data']['token'] as String; + } + + Future forgotPassword(String email) async { + await _api.post('/auth/forgot-password', body: {'email': email}); + } + + Future resetPassword(String token, String newPassword) async { + await _api.post('/auth/reset-password', body: { + 'token': token, + 'newPassword': newPassword, + 'confirmPassword': newPassword, + }); + } + + Future fetchProfile(String token) async { + final res = await _api.get('/auth/profile', token: token); + return UserProfile.fromJson(res['data']['user'] as Map); + } + + Future logout(String token) async { + await _api.post('/auth/logout', token: token); + } + + Future deleteAccount(String token) async { + await _api.delete('/auth/account', token: token); + } + + // ── Social auth ─────────────────────────────────────────────────────────── + + Future signInWithGoogle() async { + final googleSignIn = GoogleSignIn(serverClientId: _webClientId); + final account = await googleSignIn.signIn(); + if (account == null) throw const ApiException('Google sign-in cancelled'); + + final auth = await account.authentication; + final idToken = auth.idToken; + if (idToken == null) { + throw const ApiException('Failed to get Google ID token'); + } + + final res = await _api.post('/auth/social/google', body: {'idToken': idToken}); + return res['data']['token'] as String; + } +} diff --git a/lib/core/auth/auth_state.dart b/lib/core/auth/auth_state.dart new file mode 100644 index 0000000..de5cd63 --- /dev/null +++ b/lib/core/auth/auth_state.dart @@ -0,0 +1,119 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../api/api_client.dart'; +import 'auth_repository.dart'; +import 'auth_storage.dart'; +import 'user_profile.dart'; + +// ── Providers ───────────────────────────────────────────────────────────────── + +final apiClientProvider = Provider((_) => const ApiClient()); + +final authStorageProvider = Provider( + (_) => const AuthStorage(FlutterSecureStorage()), +); + +final authRepositoryProvider = Provider( + (ref) => AuthRepository(ref.read(apiClientProvider)), +); + +final authStateProvider = StateNotifierProvider( + (ref) => AuthNotifier( + ref.read(authRepositoryProvider), + ref.read(authStorageProvider), + ), +); + +// ── State ───────────────────────────────────────────────────────────────────── + +enum AuthStatus { unknown, authenticated, unauthenticated } + +class AuthState { + const AuthState._({required this.status, this.user, this.token}); + + const AuthState.unknown() : this._(status: AuthStatus.unknown); + const AuthState.unauthenticated() : this._(status: AuthStatus.unauthenticated); + const AuthState.authenticated(UserProfile user, String token) + : this._(status: AuthStatus.authenticated, user: user, token: token); + + final AuthStatus status; + final UserProfile? user; + final String? token; + + bool get isAuthenticated => status == AuthStatus.authenticated; + bool get isUnknown => status == AuthStatus.unknown; +} + +// ── Notifier ────────────────────────────────────────────────────────────────── + +class AuthNotifier extends StateNotifier { + AuthNotifier(this._repo, this._storage) : super(const AuthState.unknown()) { + _init(); + } + + final AuthRepository _repo; + final AuthStorage _storage; + + Future _init() async { + final token = await _storage.readToken(); + if (token == null) { + state = const AuthState.unauthenticated(); + return; + } + try { + final profile = await _repo.fetchProfile(token); + state = AuthState.authenticated(profile, token); + } on ApiException catch (e) { + if (e.isUnauthorized) await _storage.clearToken(); + state = const AuthState.unauthenticated(); + } catch (_) { + // Network error — keep token, go unauthenticated until next launch + state = const AuthState.unauthenticated(); + } + } + + Future login(String email, String password) async { + final token = await _repo.login(email, password); + await _storage.saveToken(token); + final profile = await _repo.fetchProfile(token); + state = AuthState.authenticated(profile, token); + } + + Future register(String name, String email, String password) async { + await _repo.register(name, email, password); + } + + Future verifyOtp(String email, String otp) async { + final token = await _repo.verifyOtp(email, otp); + await _storage.saveToken(token); + final profile = await _repo.fetchProfile(token); + state = AuthState.authenticated(profile, token); + } + + Future signInWithGoogle() async { + final token = await _repo.signInWithGoogle(); + await _storage.saveToken(token); + final profile = await _repo.fetchProfile(token); + state = AuthState.authenticated(profile, token); + } + + Future logout() async { + final token = state.token; + if (token != null) { + try { + await _repo.logout(token); + } catch (_) { + // Best-effort server logout — always clear locally + } + } + await _storage.clearToken(); + state = const AuthState.unauthenticated(); + } + + Future deleteAccount() async { + final token = state.token; + if (token != null) await _repo.deleteAccount(token); + await _storage.clearToken(); + state = const AuthState.unauthenticated(); + } +} From ebd31a0525e1ace8e07fed4242fcad37f4058133 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 19 May 2026 15:04:00 +0300 Subject: [PATCH 04/33] feat: add login screen with email/password and Google sign-in Amharic-first UI matching app design language. Includes email/password form with validation, show/hide password toggle, locked-account error handling, Google sign-in button, forgot password and register links. Register and ForgotPassword screens are stubs pending Steps 4 and 5. Co-Authored-By: Claude Sonnet 4.6 --- .../pages/forgot_password_screen.dart | 30 + .../auth/presentation/pages/login_screen.dart | 560 ++++++++++++++++++ .../presentation/pages/register_screen.dart | 30 + 3 files changed, 620 insertions(+) create mode 100644 lib/features/auth/presentation/pages/forgot_password_screen.dart create mode 100644 lib/features/auth/presentation/pages/login_screen.dart create mode 100644 lib/features/auth/presentation/pages/register_screen.dart diff --git a/lib/features/auth/presentation/pages/forgot_password_screen.dart b/lib/features/auth/presentation/pages/forgot_password_screen.dart new file mode 100644 index 0000000..9754932 --- /dev/null +++ b/lib/features/auth/presentation/pages/forgot_password_screen.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_color_scheme.dart'; +import '../../../../core/theme/app_typography.dart'; + +// Stub — full implementation in Step 5 +class ForgotPasswordScreen extends StatelessWidget { + const ForgotPasswordScreen({super.key}); + + @override + Widget build(BuildContext context) { + final c = context.colors; + return Scaffold( + backgroundColor: c.parchment, + appBar: AppBar( + backgroundColor: c.parchment, + elevation: 0, + leading: IconButton( + icon: Icon(Icons.arrow_back_ios_new_rounded, color: c.textOnParchment, size: 20), + onPressed: () => Navigator.pop(context), + ), + ), + body: Center( + child: Text( + 'የይለፍ ቃል ዳግም ማስጀመር — በቅርብ ይመጣል', + style: AppTypography.amharicBody.copyWith(color: c.textMuted), + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/pages/login_screen.dart b/lib/features/auth/presentation/pages/login_screen.dart new file mode 100644 index 0000000..052ba25 --- /dev/null +++ b/lib/features/auth/presentation/pages/login_screen.dart @@ -0,0 +1,560 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/api/api_client.dart'; +import '../../../../core/auth/auth_state.dart'; +import '../../../../core/constants/app_icons.dart'; +import '../../../../core/theme/app_color_scheme.dart'; +import '../../../../core/theme/app_typography.dart'; +import 'register_screen.dart'; +import 'forgot_password_screen.dart'; + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _emailCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + + bool _obscurePassword = true; + bool _isLoading = false; + bool _isGoogleLoading = false; + String? _errorMessage; + + @override + void dispose() { + _emailCtrl.dispose(); + _passwordCtrl.dispose(); + super.dispose(); + } + + Future _login() async { + if (!_formKey.currentState!.validate()) return; + setState(() { _isLoading = true; _errorMessage = null; }); + try { + await ref.read(authStateProvider.notifier).login( + _emailCtrl.text.trim(), + _passwordCtrl.text, + ); + if (mounted) Navigator.of(context).pop(); + } on ApiException catch (e) { + setState(() { + _errorMessage = e.isAccountLocked + ? 'መለያዎ ተቆልፏል። ከ2 ሰዓት በኋላ ይሞክሩ።' + : e.message; + }); + } catch (_) { + setState(() => _errorMessage = 'ግንኙነት አልተሳካም። ድጋሚ ይሞክሩ።'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _googleSignIn() async { + setState(() { _isGoogleLoading = true; _errorMessage = null; }); + try { + await ref.read(authStateProvider.notifier).signInWithGoogle(); + if (mounted) Navigator.of(context).pop(); + } on ApiException catch (e) { + setState(() => _errorMessage = e.message); + } catch (_) { + setState(() => _errorMessage = 'Google ግባ አልተሳካም። ድጋሚ ይሞክሩ።'); + } finally { + if (mounted) setState(() => _isGoogleLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final c = context.colors; + final isLoading = _isLoading || _isGoogleLoading; + + return Scaffold( + backgroundColor: c.parchment, + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 48), + _Header(colors: c), + const SizedBox(height: 32), + Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _AuthField( + controller: _emailCtrl, + label: 'ኢሜል', + hint: 'example@email.com', + keyboardType: TextInputType.emailAddress, + enabled: !isLoading, + validator: (v) { + if (v == null || v.trim().isEmpty) return 'ኢሜል ያስፈልጋል'; + if (!v.contains('@')) return 'ትክክለኛ ኢሜል ያስፈልጋል'; + return null; + }, + ), + const SizedBox(height: 14), + _AuthField( + controller: _passwordCtrl, + label: 'የይለፍ ቃል', + hint: '••••••••', + obscureText: _obscurePassword, + enabled: !isLoading, + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: c.textCaption, + size: 20, + ), + onPressed: () => + setState(() => _obscurePassword = !_obscurePassword), + ), + validator: (v) { + if (v == null || v.isEmpty) return 'የይለፍ ቃል ያስፈልጋል'; + return null; + }, + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: isLoading + ? null + : () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + const ForgotPasswordScreen()), + ), + child: Text( + 'የይለፍ ቃል ረሳሁ?', + style: AppTypography.amharicCaption.copyWith( + color: c.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 4), + _ErrorBanner(message: _errorMessage!), + const SizedBox(height: 4), + ] else + const SizedBox(height: 8), + _PrimaryButton( + label: 'ግባ', + isLoading: _isLoading, + onTap: isLoading ? null : _login, + ), + const SizedBox(height: 20), + _Divider(colors: c), + const SizedBox(height: 20), + _GoogleButton( + isLoading: _isGoogleLoading, + onTap: isLoading ? null : _googleSignIn, + ), + const SizedBox(height: 32), + _RegisterPrompt(enabled: !isLoading), + const SizedBox(height: 24), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── Header ──────────────────────────────────────────────────────────────────── + +class _Header extends StatelessWidget { + const _Header({required this.colors}); + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + return Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: c.primary, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text( + AppIcons.ethiopianCross, + style: TextStyle(fontSize: 30, color: c.accent, height: 1), + ), + ), + const SizedBox(height: 16), + Text( + 'ወደ መጽሐፍ ቅዱስ ግባ', + style: AppTypography.amharicHeading.copyWith( + color: c.textOnParchment, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + 'Sign in to sync your reading across devices', + style: AppTypography.englishCaption.copyWith(color: c.textMuted), + textAlign: TextAlign.center, + ), + ], + ); + } +} + +// ── Input field ─────────────────────────────────────────────────────────────── + +class _AuthField extends StatelessWidget { + const _AuthField({ + required this.controller, + required this.label, + required this.hint, + this.obscureText = false, + this.keyboardType, + this.suffixIcon, + this.enabled = true, + this.validator, + }); + + final TextEditingController controller; + final String label; + final String hint; + final bool obscureText; + final TextInputType? keyboardType; + final Widget? suffixIcon; + final bool enabled; + final FormFieldValidator? validator; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + TextFormField( + controller: controller, + obscureText: obscureText, + keyboardType: keyboardType, + enabled: enabled, + validator: validator, + style: AppTypography.amharicBody.copyWith( + color: c.textOnParchment, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: hint, + hintStyle: AppTypography.englishCaption.copyWith( + color: c.textCaption, + ), + suffixIcon: suffixIcon, + filled: true, + fillColor: c.surface, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.borderSubtle), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.borderSubtle), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.primary, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFB71C1C)), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFB71C1C), width: 1.5), + ), + ), + ), + ], + ); + } +} + +// ── Error banner ────────────────────────────────────────────────────────────── + +class _ErrorBanner extends StatelessWidget { + const _ErrorBanner({required this.message}); + final String message; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFB71C1C).withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: const Color(0xFFB71C1C).withValues(alpha: 0.25), + ), + ), + child: Row( + children: [ + const Icon(Icons.error_outline_rounded, + color: Color(0xFFB71C1C), size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: AppTypography.amharicCaption.copyWith( + color: const Color(0xFFB71C1C), + ), + ), + ), + ], + ), + ); + } +} + +// ── Primary button ──────────────────────────────────────────────────────────── + +class _PrimaryButton extends StatelessWidget { + const _PrimaryButton({ + required this.label, + required this.isLoading, + required this.onTap, + }); + + final String label; + final bool isLoading; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: 52, + decoration: BoxDecoration( + color: onTap == null ? c.primary.withValues(alpha: 0.5) : c.primary, + borderRadius: BorderRadius.circular(14), + boxShadow: onTap == null + ? null + : [ + BoxShadow( + color: c.primary.withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + alignment: Alignment.center, + child: isLoading + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: c.accent, + ), + ) + : Text( + label, + style: AppTypography.amharicLabel.copyWith( + color: c.accent, + fontSize: 16, + ), + ), + ), + ); + } +} + +// ── Divider with cross ──────────────────────────────────────────────────────── + +class _Divider extends StatelessWidget { + const _Divider({required this.colors}); + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + return Row( + children: [ + Expanded(child: Divider(color: c.borderSubtle, thickness: 1)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + AppIcons.ethiopianCross, + style: TextStyle(color: c.accentDeep, fontSize: 13, height: 1), + ), + ), + Expanded(child: Divider(color: c.borderSubtle, thickness: 1)), + ], + ); + } +} + +// ── Google button ───────────────────────────────────────────────────────────── + +class _GoogleButton extends StatelessWidget { + const _GoogleButton({required this.isLoading, required this.onTap}); + + final bool isLoading; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return GestureDetector( + onTap: onTap, + child: Container( + height: 52, + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: c.borderSubtle, width: 1.2), + ), + alignment: Alignment.center, + child: isLoading + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: c.primary, + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + _GoogleLogo(), + const SizedBox(width: 10), + Text( + 'Google በኩል ግባ', + style: AppTypography.amharicLabel.copyWith( + color: c.textBody, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +} + +class _GoogleLogo extends StatelessWidget { + @override + Widget build(BuildContext context) { + return SizedBox( + width: 20, + height: 20, + child: CustomPaint(painter: _GoogleLogoPainter()), + ); + } +} + +class _GoogleLogoPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final rect = Offset.zero & size; + final center = rect.center; + + // Blue arc (top-right) + canvas.drawArc(rect, -1.57, 3.14, false, + Paint()..color = const Color(0xFF4285F4) + ..style = PaintingStyle.stroke + ..strokeWidth = size.width * 0.18); + + // Red arc (top-left) + canvas.drawArc(rect, 1.57, 1.57, false, + Paint()..color = const Color(0xFFEA4335) + ..style = PaintingStyle.stroke + ..strokeWidth = size.width * 0.18); + + // Yellow arc (bottom-left) + canvas.drawArc(rect, 3.14, 0.78, false, + Paint()..color = const Color(0xFFFBBC05) + ..style = PaintingStyle.stroke + ..strokeWidth = size.width * 0.18); + + // Green arc (bottom-right) + canvas.drawArc(rect, 3.93, 0.79, false, + Paint()..color = const Color(0xFF34A853) + ..style = PaintingStyle.stroke + ..strokeWidth = size.width * 0.18); + + // White horizontal bar for the "G" + canvas.drawRect( + Rect.fromLTRB(center.dx, center.dy - size.height * 0.09, + size.width * 0.92, center.dy + size.height * 0.09), + Paint()..color = const Color(0xFF4285F4), + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +// ── Register prompt ─────────────────────────────────────────────────────────── + +class _RegisterPrompt extends StatelessWidget { + const _RegisterPrompt({required this.enabled}); + final bool enabled; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'መለያ የለዎትም? ', + style: AppTypography.amharicCaption.copyWith(color: c.textMuted), + ), + GestureDetector( + onTap: enabled + ? () => Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const RegisterScreen()), + ) + : null, + child: Text( + 'ይመዝገቡ', + style: AppTypography.amharicCaption.copyWith( + color: c.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/auth/presentation/pages/register_screen.dart b/lib/features/auth/presentation/pages/register_screen.dart new file mode 100644 index 0000000..3c1b65b --- /dev/null +++ b/lib/features/auth/presentation/pages/register_screen.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_color_scheme.dart'; +import '../../../../core/theme/app_typography.dart'; + +// Stub — full implementation in Step 4 +class RegisterScreen extends StatelessWidget { + const RegisterScreen({super.key}); + + @override + Widget build(BuildContext context) { + final c = context.colors; + return Scaffold( + backgroundColor: c.parchment, + appBar: AppBar( + backgroundColor: c.parchment, + elevation: 0, + leading: IconButton( + icon: Icon(Icons.arrow_back_ios_new_rounded, color: c.textOnParchment, size: 20), + onPressed: () => Navigator.pop(context), + ), + ), + body: Center( + child: Text( + 'ምዝገባ — በቅርብ ይመጣል', + style: AppTypography.amharicBody.copyWith(color: c.textMuted), + ), + ), + ); + } +} From 2094788f5d759ee163ccbedc70904a972ea4ab61 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 19 May 2026 16:18:17 +0300 Subject: [PATCH 05/33] =?UTF-8?q?redesign:=20login=20screen=20matches=20Fi?= =?UTF-8?q?gma=20=E2=80=94=20app=20header,=20social=20buttons,=20verse=20q?= =?UTF-8?q?uote?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds app logo header with language switcher, remember-me checkbox, Google + Apple side-by-side social buttons, and bible verse footer quote. Apple sign-in shows coming-soon snackbar pending backend support. Co-Authored-By: Claude Sonnet 4.6 --- .../auth/presentation/pages/login_screen.dart | 636 ++++++++++++------ 1 file changed, 447 insertions(+), 189 deletions(-) diff --git a/lib/features/auth/presentation/pages/login_screen.dart b/lib/features/auth/presentation/pages/login_screen.dart index 052ba25..ff06a73 100644 --- a/lib/features/auth/presentation/pages/login_screen.dart +++ b/lib/features/auth/presentation/pages/login_screen.dart @@ -3,10 +3,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/api/api_client.dart'; import '../../../../core/auth/auth_state.dart'; import '../../../../core/constants/app_icons.dart'; +import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/app_color_scheme.dart'; import '../../../../core/theme/app_typography.dart'; -import 'register_screen.dart'; import 'forgot_password_screen.dart'; +import 'register_screen.dart'; class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @@ -21,6 +22,7 @@ class _LoginScreenState extends ConsumerState { final _passwordCtrl = TextEditingController(); bool _obscurePassword = true; + bool _rememberMe = true; bool _isLoading = false; bool _isGoogleLoading = false; String? _errorMessage; @@ -68,104 +70,125 @@ class _LoginScreenState extends ConsumerState { } } + void _appleSignIn() { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Apple Sign In — በቅርብ ይመጣል', + style: AppTypography.amharicCaption.copyWith(color: Colors.white), + ), + backgroundColor: context.colors.primary, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + } + @override Widget build(BuildContext context) { final c = context.colors; + final s = L10n.of(context); final isLoading = _isLoading || _isGoogleLoading; return Scaffold( backgroundColor: c.parchment, body: SafeArea( child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: 48), - _Header(colors: c), - const SizedBox(height: 32), - Form( - key: _formKey, + _AppHeader(s: s), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _AuthField( - controller: _emailCtrl, - label: 'ኢሜል', - hint: 'example@email.com', - keyboardType: TextInputType.emailAddress, - enabled: !isLoading, - validator: (v) { - if (v == null || v.trim().isEmpty) return 'ኢሜል ያስፈልጋል'; - if (!v.contains('@')) return 'ትክክለኛ ኢሜል ያስፈልጋል'; - return null; - }, - ), - const SizedBox(height: 14), - _AuthField( - controller: _passwordCtrl, - label: 'የይለፍ ቃል', - hint: '••••••••', - obscureText: _obscurePassword, - enabled: !isLoading, - suffixIcon: IconButton( - icon: Icon( - _obscurePassword - ? Icons.visibility_outlined - : Icons.visibility_off_outlined, - color: c.textCaption, - size: 20, - ), - onPressed: () => - setState(() => _obscurePassword = !_obscurePassword), - ), - validator: (v) { - if (v == null || v.isEmpty) return 'የይለፍ ቃል ያስፈልጋል'; - return null; - }, - ), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: isLoading - ? null - : () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => - const ForgotPasswordScreen()), - ), - child: Text( - 'የይለፍ ቃል ረሳሁ?', - style: AppTypography.amharicCaption.copyWith( - color: c.primary, - fontWeight: FontWeight.w700, + const SizedBox(height: 28), + _TitleSection(colors: c), + const SizedBox(height: 28), + Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _InputField( + controller: _emailCtrl, + label: 'ኢሜል', + hint: 'nehmia@bible.app', + prefixIcon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + enabled: !isLoading, + validator: (v) { + if (v == null || v.trim().isEmpty) return 'ኢሜል ያስፈልጋል'; + if (!v.contains('@')) return 'ትክክለኛ ኢሜል ያስፈልጋል'; + return null; + }, + ), + const SizedBox(height: 16), + _InputField( + controller: _passwordCtrl, + label: 'የይለፍ ቃል', + hint: '••••••••', + prefixIcon: Icons.lock_outline_rounded, + obscureText: _obscurePassword, + enabled: !isLoading, + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: c.textCaption, + size: 20, + ), + onPressed: () => setState( + () => _obscurePassword = !_obscurePassword), + ), + validator: (v) { + if (v == null || v.isEmpty) { + return 'የይለፍ ቃል ያስፈልጋል'; + } + return null; + }, ), - ), + const SizedBox(height: 12), + _RememberForgotRow( + rememberMe: _rememberMe, + enabled: !isLoading, + onRememberChanged: (v) => + setState(() => _rememberMe = v), + onForgotTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const ForgotPasswordScreen()), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _ErrorBanner(message: _errorMessage!), + ], + const SizedBox(height: 20), + _PrimaryButton( + label: 'ግባ', + isLoading: _isLoading, + onTap: isLoading ? null : _login, + ), + const SizedBox(height: 24), + _OrDivider(colors: c), + const SizedBox(height: 20), + _SocialRow( + isGoogleLoading: _isGoogleLoading, + allDisabled: isLoading, + onGoogle: _googleSignIn, + onApple: _appleSignIn, + ), + const SizedBox(height: 28), + _RegisterPrompt(enabled: !isLoading), + const SizedBox(height: 24), + _VerseQuote(colors: c), + const SizedBox(height: 24), + ], ), ), - if (_errorMessage != null) ...[ - const SizedBox(height: 4), - _ErrorBanner(message: _errorMessage!), - const SizedBox(height: 4), - ] else - const SizedBox(height: 8), - _PrimaryButton( - label: 'ግባ', - isLoading: _isLoading, - onTap: isLoading ? null : _login, - ), - const SizedBox(height: 20), - _Divider(colors: c), - const SizedBox(height: 20), - _GoogleButton( - isLoading: _isGoogleLoading, - onTap: isLoading ? null : _googleSignIn, - ), - const SizedBox(height: 32), - _RegisterPrompt(enabled: !isLoading), - const SizedBox(height: 24), ], ), ), @@ -177,43 +200,108 @@ class _LoginScreenState extends ConsumerState { } } -// ── Header ──────────────────────────────────────────────────────────────────── +// ── App header ──────────────────────────────────────────────────────────────── + +class _AppHeader extends StatelessWidget { + const _AppHeader({required this.s}); + final AppStrings s; + + @override + Widget build(BuildContext context) { + final c = context.colors; + final isAmharic = s is AmStrings; -class _Header extends StatelessWidget { - const _Header({required this.colors}); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: c.primary, + borderRadius: BorderRadius.circular(10), + ), + alignment: Alignment.center, + child: Text( + AppIcons.ethiopianCross, + style: TextStyle(fontSize: 18, color: c.accent, height: 1), + ), + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'ቅዱስ መጽሐፍ', + style: AppTypography.amharicLabel.copyWith( + color: c.textOnParchment, + fontSize: 13, + ), + ), + Text( + 'AMHARIC BIBLE', + style: AppTypography.englishCaption.copyWith( + color: c.textMuted, + fontSize: 9, + letterSpacing: 1.2, + ), + ), + ], + ), + const Spacer(), + GestureDetector( + onTap: () => L10n.switchLanguage( + context, + isAmharic ? AppLanguage.english : AppLanguage.amharic, + ), + child: Row( + children: [ + Text( + isAmharic ? 'አማርኛ' : 'English', + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, + fontSize: 12, + ), + ), + Icon(Icons.keyboard_arrow_down_rounded, + color: c.textMuted, size: 16), + ], + ), + ), + ], + ), + ); + } +} + +// ── Title section ───────────────────────────────────────────────────────────── + +class _TitleSection extends StatelessWidget { + const _TitleSection({required this.colors}); final AppColorScheme colors; @override Widget build(BuildContext context) { final c = colors; return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: c.primary, - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Text( - AppIcons.ethiopianCross, - style: TextStyle(fontSize: 30, color: c.accent, height: 1), - ), - ), - const SizedBox(height: 16), Text( - 'ወደ መጽሐፍ ቅዱስ ግባ', - style: AppTypography.amharicHeading.copyWith( + 'እንኳን ደህና መጡ', + style: AppTypography.amharicDisplay.copyWith( color: c.textOnParchment, + fontSize: 32, ), - textAlign: TextAlign.center, ), const SizedBox(height: 6), Text( - 'Sign in to sync your reading across devices', - style: AppTypography.englishCaption.copyWith(color: c.textMuted), - textAlign: TextAlign.center, + 'ቅዱስ ቃልን ንዝምን ለመቀጠል ይግቡ።', + style: AppTypography.amharicBody.copyWith( + color: c.textMuted, + fontSize: 14, + height: 1.6, + ), ), ], ); @@ -222,11 +310,12 @@ class _Header extends StatelessWidget { // ── Input field ─────────────────────────────────────────────────────────────── -class _AuthField extends StatelessWidget { - const _AuthField({ +class _InputField extends StatelessWidget { + const _InputField({ required this.controller, required this.label, required this.hint, + required this.prefixIcon, this.obscureText = false, this.keyboardType, this.suffixIcon, @@ -237,6 +326,7 @@ class _AuthField extends StatelessWidget { final TextEditingController controller; final String label; final String hint; + final IconData prefixIcon; final bool obscureText; final TextInputType? keyboardType; final Widget? suffixIcon; @@ -254,6 +344,7 @@ class _AuthField extends StatelessWidget { style: AppTypography.amharicCaption.copyWith( color: c.textMuted, fontWeight: FontWeight.w700, + fontSize: 12, ), ), const SizedBox(height: 6), @@ -271,12 +362,14 @@ class _AuthField extends StatelessWidget { hintText: hint, hintStyle: AppTypography.englishCaption.copyWith( color: c.textCaption, + fontSize: 14, ), + prefixIcon: Icon(prefixIcon, color: c.textCaption, size: 18), suffixIcon: suffixIcon, filled: true, fillColor: c.surface, contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + const EdgeInsets.symmetric(horizontal: 16, vertical: 15), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: c.borderSubtle), @@ -291,11 +384,80 @@ class _AuthField extends StatelessWidget { ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Color(0xFFB71C1C)), + borderSide: BorderSide(color: c.primary.withValues(alpha: 0.6)), ), focusedErrorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Color(0xFFB71C1C), width: 1.5), + borderSide: BorderSide(color: c.primary, width: 1.5), + ), + ), + ), + ], + ); + } +} + +// ── Remember me + Forgot password row ──────────────────────────────────────── + +class _RememberForgotRow extends StatelessWidget { + const _RememberForgotRow({ + required this.rememberMe, + required this.enabled, + required this.onRememberChanged, + required this.onForgotTap, + }); + + final bool rememberMe; + final bool enabled; + final ValueChanged onRememberChanged; + final VoidCallback onForgotTap; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return Row( + children: [ + GestureDetector( + onTap: enabled ? () => onRememberChanged(!rememberMe) : null, + child: Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 20, + height: 20, + decoration: BoxDecoration( + color: rememberMe ? c.primary : Colors.transparent, + borderRadius: BorderRadius.circular(5), + border: Border.all( + color: rememberMe ? c.primary : c.borderSubtle, + width: 1.5, + ), + ), + child: rememberMe + ? Icon(Icons.check_rounded, + color: c.accent, size: 13) + : null, + ), + const SizedBox(width: 8), + Text( + 'አስታወስኝ', + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + const Spacer(), + GestureDetector( + onTap: enabled ? onForgotTap : null, + child: Text( + 'የይለፍ ቃል ረሳህ?', + style: AppTypography.amharicCaption.copyWith( + color: c.primary, + fontWeight: FontWeight.w700, + fontSize: 12, ), ), ), @@ -312,25 +474,24 @@ class _ErrorBanner extends StatelessWidget { @override Widget build(BuildContext context) { + final c = context.colors; return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( - color: const Color(0xFFB71C1C).withValues(alpha: 0.08), + color: c.primary.withValues(alpha: 0.07), borderRadius: BorderRadius.circular(10), - border: Border.all( - color: const Color(0xFFB71C1C).withValues(alpha: 0.25), - ), + border: Border.all(color: c.primary.withValues(alpha: 0.2)), ), child: Row( children: [ - const Icon(Icons.error_outline_rounded, - color: Color(0xFFB71C1C), size: 18), + Icon(Icons.error_outline_rounded, color: c.primary, size: 18), const SizedBox(width: 8), Expanded( child: Text( message, style: AppTypography.amharicCaption.copyWith( - color: const Color(0xFFB71C1C), + color: c.primary, + fontSize: 12, ), ), ), @@ -360,7 +521,7 @@ class _PrimaryButton extends StatelessWidget { onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 150), - height: 52, + height: 54, decoration: BoxDecoration( color: onTap == null ? c.primary.withValues(alpha: 0.5) : c.primary, borderRadius: BorderRadius.circular(14), @@ -368,9 +529,9 @@ class _PrimaryButton extends StatelessWidget { ? null : [ BoxShadow( - color: c.primary.withValues(alpha: 0.3), - blurRadius: 12, - offset: const Offset(0, 4), + color: c.primary.withValues(alpha: 0.35), + blurRadius: 16, + offset: const Offset(0, 5), ), ], ), @@ -380,26 +541,31 @@ class _PrimaryButton extends StatelessWidget { width: 22, height: 22, child: CircularProgressIndicator( - strokeWidth: 2.5, - color: c.accent, - ), + strokeWidth: 2.5, color: c.accent), ) - : Text( - label, - style: AppTypography.amharicLabel.copyWith( - color: c.accent, - fontSize: 16, - ), + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: AppTypography.amharicLabel.copyWith( + color: c.accent, + fontSize: 16, + ), + ), + const SizedBox(width: 6), + Icon(Icons.chevron_right_rounded, color: c.accent, size: 20), + ], ), ), ); } } -// ── Divider with cross ──────────────────────────────────────────────────────── +// ── Or divider ──────────────────────────────────────────────────────────────── -class _Divider extends StatelessWidget { - const _Divider({required this.colors}); +class _OrDivider extends StatelessWidget { + const _OrDivider({required this.colors}); final AppColorScheme colors; @override @@ -409,10 +575,13 @@ class _Divider extends StatelessWidget { children: [ Expanded(child: Divider(color: c.borderSubtle, thickness: 1)), Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), + padding: const EdgeInsets.symmetric(horizontal: 14), child: Text( - AppIcons.ethiopianCross, - style: TextStyle(color: c.accentDeep, fontSize: 13, height: 1), + 'ወይም ቤዝህ ይግቡ', + style: AppTypography.amharicCaption.copyWith( + color: c.textCaption, + fontSize: 11, + ), ), ), Expanded(child: Divider(color: c.borderSubtle, thickness: 1)), @@ -421,13 +590,63 @@ class _Divider extends StatelessWidget { } } -// ── Google button ───────────────────────────────────────────────────────────── +// ── Social buttons row ──────────────────────────────────────────────────────── -class _GoogleButton extends StatelessWidget { - const _GoogleButton({required this.isLoading, required this.onTap}); +class _SocialRow extends StatelessWidget { + const _SocialRow({ + required this.isGoogleLoading, + required this.allDisabled, + required this.onGoogle, + required this.onApple, + }); + + final bool isGoogleLoading; + final bool allDisabled; + final VoidCallback onGoogle; + final VoidCallback onApple; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _SocialButton( + onTap: allDisabled ? null : onGoogle, + isLoading: isGoogleLoading, + label: 'Google', + dark: false, + icon: _GoogleLogo(), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _SocialButton( + onTap: allDisabled ? null : onApple, + isLoading: false, + label: 'Apple', + dark: true, + icon: const Icon(Icons.apple_rounded, color: Colors.white, size: 20), + ), + ), + ], + ); + } +} + +class _SocialButton extends StatelessWidget { + const _SocialButton({ + required this.onTap, + required this.isLoading, + required this.label, + required this.dark, + required this.icon, + }); - final bool isLoading; final VoidCallback? onTap; + final bool isLoading; + final String label; + final bool dark; + final Widget icon; @override Widget build(BuildContext context) { @@ -435,32 +654,32 @@ class _GoogleButton extends StatelessWidget { return GestureDetector( onTap: onTap, child: Container( - height: 52, + height: 50, decoration: BoxDecoration( - color: c.surface, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: c.borderSubtle, width: 1.2), + color: dark ? const Color(0xFF1A1A1A) : c.surface, + borderRadius: BorderRadius.circular(12), + border: dark ? null : Border.all(color: c.borderSubtle, width: 1.2), ), alignment: Alignment.center, child: isLoading ? SizedBox( - width: 22, - height: 22, + width: 20, + height: 20, child: CircularProgressIndicator( - strokeWidth: 2.5, - color: c.primary, + strokeWidth: 2, + color: dark ? Colors.white : c.primary, ), ) : Row( mainAxisSize: MainAxisSize.min, children: [ - _GoogleLogo(), - const SizedBox(width: 10), + icon, + const SizedBox(width: 8), Text( - 'Google በኩል ግባ', - style: AppTypography.amharicLabel.copyWith( - color: c.textBody, - fontWeight: FontWeight.w600, + label, + style: AppTypography.englishLabel.copyWith( + color: dark ? Colors.white : c.textBody, + fontSize: 13, ), ), ], @@ -471,11 +690,20 @@ class _GoogleButton extends StatelessWidget { } class _GoogleLogo extends StatelessWidget { + @override + Widget build(BuildContext context) { + return const _GooglePainter(); + } +} + +class _GooglePainter extends StatelessWidget { + const _GooglePainter(); + @override Widget build(BuildContext context) { return SizedBox( - width: 20, - height: 20, + width: 18, + height: 18, child: CustomPaint(painter: _GoogleLogoPainter()), ); } @@ -484,43 +712,40 @@ class _GoogleLogo extends StatelessWidget { class _GoogleLogoPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - final rect = Offset.zero & size; - final center = rect.center; - - // Blue arc (top-right) - canvas.drawArc(rect, -1.57, 3.14, false, - Paint()..color = const Color(0xFF4285F4) - ..style = PaintingStyle.stroke - ..strokeWidth = size.width * 0.18); - - // Red arc (top-left) - canvas.drawArc(rect, 1.57, 1.57, false, - Paint()..color = const Color(0xFFEA4335) - ..style = PaintingStyle.stroke - ..strokeWidth = size.width * 0.18); - - // Yellow arc (bottom-left) - canvas.drawArc(rect, 3.14, 0.78, false, - Paint()..color = const Color(0xFFFBBC05) - ..style = PaintingStyle.stroke - ..strokeWidth = size.width * 0.18); - - // Green arc (bottom-right) - canvas.drawArc(rect, 3.93, 0.79, false, - Paint()..color = const Color(0xFF34A853) - ..style = PaintingStyle.stroke - ..strokeWidth = size.width * 0.18); - - // White horizontal bar for the "G" + final w = size.width; + final h = size.height; + final stroke = w * 0.17; + + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round; + + final rect = Rect.fromLTWH(stroke / 2, stroke / 2, + w - stroke, h - stroke); + + paint.color = const Color(0xFF4285F4); + canvas.drawArc(rect, -1.57, 2.8, false, paint); + + paint.color = const Color(0xFFEA4335); + canvas.drawArc(rect, 1.57 + 0.37, 1.2, false, paint); + + paint.color = const Color(0xFFFBBC05); + canvas.drawArc(rect, 3.14, 0.74, false, paint); + + paint.color = const Color(0xFF34A853); + canvas.drawArc(rect, 3.93, 0.83, false, paint); + + final center = Offset(w / 2, h / 2); canvas.drawRect( - Rect.fromLTRB(center.dx, center.dy - size.height * 0.09, - size.width * 0.92, center.dy + size.height * 0.09), + Rect.fromLTRB( + center.dx, center.dy - h * 0.1, w - stroke / 2, center.dy + h * 0.1), Paint()..color = const Color(0xFF4285F4), ); } @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; + bool shouldRepaint(covariant CustomPainter old) => false; } // ── Register prompt ─────────────────────────────────────────────────────────── @@ -536,8 +761,9 @@ class _RegisterPrompt extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - 'መለያ የለዎትም? ', - style: AppTypography.amharicCaption.copyWith(color: c.textMuted), + 'አካውንት የለዎትም? ', + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, fontSize: 12), ), GestureDetector( onTap: enabled @@ -547,10 +773,11 @@ class _RegisterPrompt extends StatelessWidget { ) : null, child: Text( - 'ይመዝገቡ', + 'ይ​መዝ​ጋቡ', style: AppTypography.amharicCaption.copyWith( color: c.primary, fontWeight: FontWeight.w700, + fontSize: 12, ), ), ), @@ -558,3 +785,34 @@ class _RegisterPrompt extends StatelessWidget { ); } } + +// ── Bible verse quote ───────────────────────────────────────────────────────── + +class _VerseQuote extends StatelessWidget { + const _VerseQuote({required this.colors}); + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(AppIcons.malteseCross, + style: TextStyle(color: c.accentDeep, fontSize: 12)), + const SizedBox(width: 8), + Text( + 'ቃልህ ለምንጊዜም ብርሃን ነው', + style: AppTypography.amharicCaption.copyWith( + color: c.textCaption, + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(width: 8), + Text(AppIcons.malteseCross, + style: TextStyle(color: c.accentDeep, fontSize: 12)), + ], + ); + } +} From 26ea139568ddd0f75b10bccf477f0e5263a749c0 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 19 May 2026 16:24:52 +0300 Subject: [PATCH 06/33] feat: add register and OTP screens matching Figma design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register: name/email/phone/password form with 2-step progress bar, live password strength indicator (4 bars), and terms checkbox. OTP: 6 individual digit boxes with auto-advance/back focus, 60s countdown timer, resend, and masked contact display. Both match app design language — parchment bg, burgundy primary, Geez labels. Co-Authored-By: Claude Sonnet 4.6 --- .../auth/presentation/pages/otp_screen.dart | 528 +++++++++++++++ .../presentation/pages/register_screen.dart | 623 +++++++++++++++++- 2 files changed, 1139 insertions(+), 12 deletions(-) create mode 100644 lib/features/auth/presentation/pages/otp_screen.dart diff --git a/lib/features/auth/presentation/pages/otp_screen.dart b/lib/features/auth/presentation/pages/otp_screen.dart new file mode 100644 index 0000000..0dfa3b0 --- /dev/null +++ b/lib/features/auth/presentation/pages/otp_screen.dart @@ -0,0 +1,528 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/api/api_client.dart'; +import '../../../../core/auth/auth_state.dart'; +import '../../../../core/constants/app_icons.dart'; +import '../../../../core/theme/app_color_scheme.dart'; +import '../../../../core/theme/app_typography.dart'; + +class OtpScreen extends ConsumerStatefulWidget { + const OtpScreen({super.key, required this.email, this.phone}); + + final String email; + final String? phone; + + @override + ConsumerState createState() => _OtpScreenState(); +} + +class _OtpScreenState extends ConsumerState { + static const _otpLength = 6; + static const _resendSeconds = 60; + + final _controllers = + List.generate(_otpLength, (_) => TextEditingController()); + final _focusNodes = List.generate(_otpLength, (_) => FocusNode()); + + bool _isLoading = false; + bool _isResending = false; + String? _errorMessage; + int _countdown = _resendSeconds; + Timer? _timer; + + @override + void initState() { + super.initState(); + _startCountdown(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNodes[0].requestFocus(); + }); + } + + @override + void dispose() { + _timer?.cancel(); + for (final c in _controllers) { c.dispose(); } + for (final f in _focusNodes) { f.dispose(); } + super.dispose(); + } + + void _startCountdown() { + _timer?.cancel(); + setState(() => _countdown = _resendSeconds); + _timer = Timer.periodic(const Duration(seconds: 1), (t) { + if (!mounted) { + t.cancel(); + return; + } + setState(() { + if (_countdown > 0) { + _countdown--; + } else { + t.cancel(); + } + }); + }); + } + + String get _maskedContact { + final phone = widget.phone; + if (phone != null && phone.isNotEmpty) { + if (phone.length >= 7) { + final visible = phone.substring(phone.length - 3); + return '${phone.substring(0, phone.length > 4 ? 4 : 1)} ••• ••• $visible'; + } + return phone; + } + final parts = widget.email.split('@'); + final name = parts[0]; + final masked = name.length > 2 + ? '${name[0]}${'•' * (name.length - 2)}${name[name.length - 1]}' + : name; + return '$masked@${parts.length > 1 ? parts[1] : ''}'; + } + + String get _otp => + _controllers.map((c) => c.text).join(); + + void _onDigitChanged(String value, int index) { + if (value.length == 1) { + if (index < _otpLength - 1) { + FocusScope.of(context).requestFocus(_focusNodes[index + 1]); + } else { + _focusNodes[index].unfocus(); + } + } else if (value.isEmpty && index > 0) { + FocusScope.of(context).requestFocus(_focusNodes[index - 1]); + } + setState(() {}); + } + + Future _verify() async { + final otp = _otp; + if (otp.length < _otpLength) { + setState(() => _errorMessage = 'የ$_otpLength ቁጥር ኮድ ያስፈልጋል'); + return; + } + setState(() { _isLoading = true; _errorMessage = null; }); + try { + await ref.read(authStateProvider.notifier).verifyOtp(widget.email, otp); + if (mounted) Navigator.of(context).popUntil((r) => r.isFirst); + } on ApiException catch (e) { + setState(() => _errorMessage = e.message); + _clearOtp(); + } catch (_) { + setState(() => _errorMessage = 'ግንኙነት አልተሳካም። ድጋሚ ይሞክሩ።'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _resend() async { + if (_countdown > 0 || _isResending) return; + setState(() { _isResending = true; _errorMessage = null; }); + try { + await ref.read(authRepositoryProvider).resendOtp(widget.email); + _clearOtp(); + _startCountdown(); + } on ApiException catch (e) { + setState(() => _errorMessage = e.message); + } catch (_) { + setState(() => _errorMessage = 'ኮድ መላክ አልተሳካም። ድጋሚ ይሞክሩ።'); + } finally { + if (mounted) setState(() => _isResending = false); + } + } + + void _clearOtp() { + for (final c in _controllers) { c.clear(); } + FocusScope.of(context).requestFocus(_focusNodes[0]); + setState(() {}); + } + + String _countdownLabel() { + final m = (_countdown ~/ 60).toString().padLeft(2, '0'); + final s = (_countdown % 60).toString().padLeft(2, '0'); + return '$m:$s'; + } + + @override + Widget build(BuildContext context) { + final c = context.colors; + final otpFilled = _otp.length == _otpLength; + + return Scaffold( + backgroundColor: c.parchment, + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), + _BackButton(), + const SizedBox(height: 24), + Text( + 'ኮድዎን ያረጋግጡ', + style: AppTypography.amharicDisplay.copyWith( + color: c.textOnParchment, + fontSize: 30, + ), + ), + const SizedBox(height: 8), + RichText( + text: TextSpan( + style: AppTypography.amharicBody.copyWith( + color: c.textMuted, + fontSize: 13, + height: 1.6, + ), + children: [ + const TextSpan(text: 'ወደ '), + TextSpan( + text: _maskedContact, + style: TextStyle( + color: c.textOnParchment, + fontWeight: FontWeight.w700), + ), + const TextSpan( + text: ' 6 አሃዝ ኮድ ልከናል። አባክዎ ከታች ያስገቡ።'), + ], + ), + ), + const SizedBox(height: 36), + _ShieldIcon(colors: c), + const SizedBox(height: 36), + _OtpBoxes( + controllers: _controllers, + focusNodes: _focusNodes, + onChanged: _onDigitChanged, + enabled: !_isLoading, + colors: c, + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + _ErrorBanner(message: _errorMessage!), + ], + const SizedBox(height: 24), + _ResendRow( + countdown: _countdown, + isResending: _isResending, + countdownLabel: _countdownLabel(), + onResend: _resend, + colors: c, + ), + const SizedBox(height: 28), + _PrimaryButton( + label: 'አረጋጥ', + isLoading: _isLoading, + onTap: (!otpFilled || _isLoading) ? null : _verify, + ), + const SizedBox(height: 16), + Center( + child: GestureDetector( + onTap: _isLoading ? null : () => Navigator.pop(context), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.edit_outlined, + color: c.textMuted, size: 14), + const SizedBox(width: 6), + Text( + widget.phone != null && widget.phone!.isNotEmpty + ? 'ስልክ ቁጥር ለውጥ' + : 'ኢሜል ለውጥ', + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ); + } +} + +// ── Shield icon ─────────────────────────────────────────────────────────────── + +class _ShieldIcon extends StatelessWidget { + const _ShieldIcon({required this.colors}); + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + return Center( + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: c.primary, + borderRadius: BorderRadius.circular(22), + ), + alignment: Alignment.center, + child: Text( + AppIcons.ethiopianCross, + style: TextStyle(fontSize: 34, color: c.accent, height: 1), + ), + ), + ); + } +} + +// ── OTP boxes ───────────────────────────────────────────────────────────────── + +class _OtpBoxes extends StatelessWidget { + const _OtpBoxes({ + required this.controllers, + required this.focusNodes, + required this.onChanged, + required this.enabled, + required this.colors, + }); + + final List controllers; + final List focusNodes; + final void Function(String, int) onChanged; + final bool enabled; + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(controllers.length, (i) { + final filled = controllers[i].text.isNotEmpty; + return SizedBox( + width: 46, + height: 56, + child: TextFormField( + controller: controllers[i], + focusNode: focusNodes[i], + enabled: enabled, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(1), + ], + style: AppTypography.amharicHeading.copyWith( + color: c.textOnParchment, + fontSize: 20, + ), + onChanged: (v) => onChanged(v, i), + decoration: InputDecoration( + counterText: '', + filled: true, + fillColor: filled ? c.primary.withValues(alpha: 0.06) : c.surface, + contentPadding: EdgeInsets.zero, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.borderSubtle), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: filled ? c.primary : c.borderSubtle, + width: filled ? 1.5 : 1, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.primary, width: 1.5), + ), + ), + ), + ); + }), + ); + } +} + +// ── Resend row ──────────────────────────────────────────────────────────────── + +class _ResendRow extends StatelessWidget { + const _ResendRow({ + required this.countdown, + required this.isResending, + required this.countdownLabel, + required this.onResend, + required this.colors, + }); + + final int countdown; + final bool isResending; + final String countdownLabel; + final VoidCallback onResend; + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + final canResend = countdown == 0 && !isResending; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'ኮድ አልደረሰዎትም? ', + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, fontSize: 12), + ), + if (countdown > 0) + Text( + 'ደጎሚ ሊክ ቤ $countdownLabel', + style: AppTypography.amharicCaption.copyWith( + color: c.textCaption, + fontSize: 12, + ), + ) + else + GestureDetector( + onTap: canResend ? onResend : null, + child: isResending + ? SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 1.5, color: c.primary), + ) + : Text( + 'ደጎሚ ሊክ', + style: AppTypography.amharicCaption.copyWith( + color: c.primary, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ), + ], + ); + } +} + +// ── Back button ─────────────────────────────────────────────────────────────── + +class _BackButton extends StatelessWidget { + @override + Widget build(BuildContext context) { + final c = context.colors; + return GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: c.borderSubtle), + ), + alignment: Alignment.center, + child: Icon(Icons.chevron_left_rounded, + color: c.textOnParchment, size: 22), + ), + ); + } +} + +// ── Error banner ────────────────────────────────────────────────────────────── + +class _ErrorBanner extends StatelessWidget { + const _ErrorBanner({required this.message}); + final String message; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: c.primary.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: c.primary.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + Icon(Icons.error_outline_rounded, color: c.primary, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: AppTypography.amharicCaption + .copyWith(color: c.primary, fontSize: 12), + ), + ), + ], + ), + ); + } +} + +// ── Primary button ──────────────────────────────────────────────────────────── + +class _PrimaryButton extends StatelessWidget { + const _PrimaryButton({ + required this.label, + required this.isLoading, + required this.onTap, + }); + + final String label; + final bool isLoading; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: 54, + decoration: BoxDecoration( + color: onTap == null ? c.primary.withValues(alpha: 0.5) : c.primary, + borderRadius: BorderRadius.circular(14), + boxShadow: onTap == null + ? null + : [ + BoxShadow( + color: c.primary.withValues(alpha: 0.35), + blurRadius: 16, + offset: const Offset(0, 5), + ), + ], + ), + alignment: Alignment.center, + child: isLoading + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, color: c.accent), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: AppTypography.amharicLabel + .copyWith(color: c.accent, fontSize: 16), + ), + const SizedBox(width: 6), + Icon(Icons.chevron_right_rounded, color: c.accent, size: 20), + ], + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/pages/register_screen.dart b/lib/features/auth/presentation/pages/register_screen.dart index 3c1b65b..e379f15 100644 --- a/lib/features/auth/presentation/pages/register_screen.dart +++ b/lib/features/auth/presentation/pages/register_screen.dart @@ -1,29 +1,628 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/api/api_client.dart'; +import '../../../../core/auth/auth_state.dart'; import '../../../../core/theme/app_color_scheme.dart'; import '../../../../core/theme/app_typography.dart'; +import 'login_screen.dart'; +import 'otp_screen.dart'; -// Stub — full implementation in Step 4 -class RegisterScreen extends StatelessWidget { +class RegisterScreen extends ConsumerStatefulWidget { const RegisterScreen({super.key}); + @override + ConsumerState createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _nameCtrl = TextEditingController(); + final _emailCtrl = TextEditingController(); + final _phoneCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + + bool _obscurePassword = true; + bool _acceptedTerms = false; + bool _isLoading = false; + String? _errorMessage; + int _passwordStrength = 0; + + @override + void dispose() { + _nameCtrl.dispose(); + _emailCtrl.dispose(); + _phoneCtrl.dispose(); + _passwordCtrl.dispose(); + super.dispose(); + } + + int _evalStrength(String p) { + if (p.isEmpty) return 0; + int score = 0; + if (p.length >= 6) score++; + if (p.length >= 8) score++; + if (p.contains(RegExp(r'[A-Z]')) && p.contains(RegExp(r'[0-9]'))) score++; + if (p.contains(RegExp(r'[!@#\$%^&*(),.?":{}|<>]'))) score++; + return score; + } + + String _strengthLabel(int s) { + switch (s) { + case 1: return 'ደካማ'; + case 2: return 'መካከለኛ'; + case 3: return 'ጥሩ'; + case 4: return 'ጠንካራ'; + default: return ''; + } + } + + Color _strengthColor(int s) { + switch (s) { + case 1: return const Color(0xFFE53935); + case 2: return const Color(0xFFFB8C00); + case 3: return const Color(0xFF7CB342); + case 4: return const Color(0xFF2E7D32); + default: return Colors.transparent; + } + } + + Future _register() async { + if (!_formKey.currentState!.validate()) return; + if (!_acceptedTerms) { + setState(() => _errorMessage = 'ውሎቹን እና ሁኔታዎቹን ይቀበሉ'); + return; + } + setState(() { _isLoading = true; _errorMessage = null; }); + try { + await ref.read(authStateProvider.notifier).register( + _nameCtrl.text.trim(), + _emailCtrl.text.trim(), + _passwordCtrl.text, + ); + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => OtpScreen( + email: _emailCtrl.text.trim(), + phone: _phoneCtrl.text.trim(), + ), + ), + ); + } + } on ApiException catch (e) { + setState(() => _errorMessage = e.message); + } catch (_) { + setState(() => _errorMessage = 'ግንኙነት አልተሳካም። ድጋሚ ይሞክሩ።'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + @override Widget build(BuildContext context) { final c = context.colors; + return Scaffold( backgroundColor: c.parchment, - appBar: AppBar( - backgroundColor: c.parchment, - elevation: 0, - leading: IconButton( - icon: Icon(Icons.arrow_back_ios_new_rounded, color: c.textOnParchment, size: 20), - onPressed: () => Navigator.pop(context), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), + _BackButton(), + const SizedBox(height: 24), + Text( + 'አካውንት ይፍጠሩ', + style: AppTypography.amharicDisplay.copyWith( + color: c.textOnParchment, + fontSize: 30, + ), + ), + const SizedBox(height: 6), + Text( + 'የጎልብ ሂደትዎን፣ ምልክቶቾዎን እና ማስታወሻዎቾዎን ያስቀምጡ።', + style: AppTypography.amharicBody.copyWith( + color: c.textMuted, + fontSize: 13, + height: 1.6, + ), + ), + const SizedBox(height: 20), + _StepProgress(currentStep: 1, totalSteps: 2, colors: c), + const SizedBox(height: 28), + Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _InputField( + controller: _nameCtrl, + label: 'ሙሉ ስም', + hint: 'ነህምያ ተስፋዬ', + prefixIcon: Icons.person_outline_rounded, + enabled: !_isLoading, + validator: (v) { + if (v == null || v.trim().isEmpty) return 'ሙሉ ስም ያስፈልጋል'; + if (v.trim().length < 2) return 'ስም ቢያንስ 2 ፊደላት ያስፈልጋሉ'; + return null; + }, + ), + const SizedBox(height: 16), + _InputField( + controller: _emailCtrl, + label: 'ኢሜል', + hint: 'nehmia@bible.app', + prefixIcon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + enabled: !_isLoading, + validator: (v) { + if (v == null || v.trim().isEmpty) return 'ኢሜል ያስፈልጋል'; + if (!v.contains('@')) return 'ትክክለኛ ኢሜል ያስፈልጋል'; + return null; + }, + ), + const SizedBox(height: 16), + _InputField( + controller: _phoneCtrl, + label: 'ስልክ ቁጥር', + hint: '+251 911 234 567', + prefixIcon: Icons.phone_outlined, + keyboardType: TextInputType.phone, + enabled: !_isLoading, + ), + const SizedBox(height: 16), + _InputField( + controller: _passwordCtrl, + label: 'የይለፍ ቃል', + hint: '••••••••', + prefixIcon: Icons.lock_outline_rounded, + obscureText: _obscurePassword, + enabled: !_isLoading, + onChanged: (v) => + setState(() => _passwordStrength = _evalStrength(v)), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: c.textCaption, + size: 20, + ), + onPressed: () => setState( + () => _obscurePassword = !_obscurePassword), + ), + validator: (v) { + if (v == null || v.isEmpty) return 'የይለፍ ቃል ያስፈልጋል'; + if (v.length < 8) return 'ቢያንስ 8 ቁምፊዎች ያስፈልጋሉ'; + return null; + }, + ), + if (_passwordStrength > 0) ...[ + const SizedBox(height: 8), + _PasswordStrengthBar( + strength: _passwordStrength, + label: _strengthLabel(_passwordStrength), + color: _strengthColor(_passwordStrength), + ), + ], + const SizedBox(height: 20), + _TermsRow( + accepted: _acceptedTerms, + enabled: !_isLoading, + onChanged: (v) { + setState(() { + _acceptedTerms = v; + if (v) _errorMessage = null; + }); + }, + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _ErrorBanner(message: _errorMessage!), + ], + const SizedBox(height: 24), + _PrimaryButton( + label: 'ይ​መዝ​ጋቡ', + isLoading: _isLoading, + onTap: _isLoading ? null : _register, + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'መለያ አለዎት? ', + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, fontSize: 12), + ), + GestureDetector( + onTap: _isLoading + ? null + : () => Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const LoginScreen()), + ), + child: Text( + 'ይ​ግቡ', + style: AppTypography.amharicCaption.copyWith( + color: c.primary, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── Back button ─────────────────────────────────────────────────────────────── + +class _BackButton extends StatelessWidget { + @override + Widget build(BuildContext context) { + final c = context.colors; + return GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: c.borderSubtle), ), + alignment: Alignment.center, + child: Icon(Icons.chevron_left_rounded, + color: c.textOnParchment, size: 22), ), - body: Center( - child: Text( - 'ምዝገባ — በቅርብ ይመጣል', - style: AppTypography.amharicBody.copyWith(color: c.textMuted), + ); + } +} + +// ── Step progress bar ───────────────────────────────────────────────────────── + +class _StepProgress extends StatelessWidget { + const _StepProgress({ + required this.currentStep, + required this.totalSteps, + required this.colors, + }); + + final int currentStep; + final int totalSteps; + final AppColorScheme colors; + + static const _geez = ['፩', '፪', '፫', '፬', '፭', '፮']; + + @override + Widget build(BuildContext context) { + final c = colors; + return Row( + children: [ + Expanded( + child: Row( + children: List.generate(totalSteps, (i) { + final active = i < currentStep; + return Expanded( + child: Container( + margin: EdgeInsets.only(right: i < totalSteps - 1 ? 4 : 0), + height: 4, + decoration: BoxDecoration( + color: active ? c.primary : c.borderSubtle, + borderRadius: BorderRadius.circular(4), + ), + ), + ); + }), + ), + ), + const SizedBox(width: 10), + Text( + '${_geez[currentStep - 1]}/${_geez[totalSteps - 1]}', + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, + fontSize: 12, + ), + ), + ], + ); + } +} + +// ── Input field ─────────────────────────────────────────────────────────────── + +class _InputField extends StatelessWidget { + const _InputField({ + required this.controller, + required this.label, + required this.hint, + required this.prefixIcon, + this.obscureText = false, + this.keyboardType, + this.suffixIcon, + this.enabled = true, + this.onChanged, + this.validator, + }); + + final TextEditingController controller; + final String label; + final String hint; + final IconData prefixIcon; + final bool obscureText; + final TextInputType? keyboardType; + final Widget? suffixIcon; + final bool enabled; + final ValueChanged? onChanged; + final FormFieldValidator? validator; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + const SizedBox(height: 6), + TextFormField( + controller: controller, + obscureText: obscureText, + keyboardType: keyboardType, + enabled: enabled, + onChanged: onChanged, + validator: validator, + style: AppTypography.amharicBody.copyWith( + color: c.textOnParchment, + fontSize: 15, + ), + decoration: InputDecoration( + hintText: hint, + hintStyle: AppTypography.englishCaption + .copyWith(color: c.textCaption, fontSize: 14), + prefixIcon: Icon(prefixIcon, color: c.textCaption, size: 18), + suffixIcon: suffixIcon, + filled: true, + fillColor: c.surface, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 15), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.borderSubtle), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.borderSubtle), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.primary, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.primary.withValues(alpha: 0.6)), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.primary, width: 1.5), + ), + ), + ), + ], + ); + } +} + +// ── Password strength bar ───────────────────────────────────────────────────── + +class _PasswordStrengthBar extends StatelessWidget { + const _PasswordStrengthBar({ + required this.strength, + required this.label, + required this.color, + }); + + final int strength; + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return Row( + children: [ + Expanded( + child: Row( + children: List.generate(4, (i) { + return Expanded( + child: Container( + margin: EdgeInsets.only(right: i < 3 ? 4 : 0), + height: 3, + decoration: BoxDecoration( + color: i < strength ? color : c.borderSubtle, + borderRadius: BorderRadius.circular(4), + ), + ), + ); + }), + ), + ), + const SizedBox(width: 10), + Text( + label, + style: AppTypography.amharicCaption.copyWith( + color: color, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ], + ); + } +} + +// ── Terms row ───────────────────────────────────────────────────────────────── + +class _TermsRow extends StatelessWidget { + const _TermsRow({ + required this.accepted, + required this.enabled, + required this.onChanged, + }); + + final bool accepted; + final bool enabled; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return GestureDetector( + onTap: enabled ? () => onChanged(!accepted) : null, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 20, + height: 20, + margin: const EdgeInsets.only(top: 1), + decoration: BoxDecoration( + color: accepted ? c.primary : Colors.transparent, + borderRadius: BorderRadius.circular(5), + border: Border.all( + color: accepted ? c.primary : c.borderSubtle, + width: 1.5, + ), + ), + child: accepted + ? Icon(Icons.check_rounded, color: c.accent, size: 13) + : null, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'የኅብረተሰቡ ወሎቹን እና የግላዊነት ፖሊሲን ተቀብያለሁ።', + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, + fontSize: 12, + height: 1.5, + ), + ), + ), + ], + ), + ); + } +} + +// ── Error banner ────────────────────────────────────────────────────────────── + +class _ErrorBanner extends StatelessWidget { + const _ErrorBanner({required this.message}); + final String message; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: c.primary.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: c.primary.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + Icon(Icons.error_outline_rounded, color: c.primary, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: AppTypography.amharicCaption + .copyWith(color: c.primary, fontSize: 12), + ), + ), + ], + ), + ); + } +} + +// ── Primary button ──────────────────────────────────────────────────────────── + +class _PrimaryButton extends StatelessWidget { + const _PrimaryButton({ + required this.label, + required this.isLoading, + required this.onTap, + }); + + final String label; + final bool isLoading; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: 54, + decoration: BoxDecoration( + color: onTap == null ? c.primary.withValues(alpha: 0.5) : c.primary, + borderRadius: BorderRadius.circular(14), + boxShadow: onTap == null + ? null + : [ + BoxShadow( + color: c.primary.withValues(alpha: 0.35), + blurRadius: 16, + offset: const Offset(0, 5), + ), + ], ), + alignment: Alignment.center, + child: isLoading + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, color: c.accent), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: AppTypography.amharicLabel + .copyWith(color: c.accent, fontSize: 16), + ), + const SizedBox(width: 6), + Icon(Icons.chevron_right_rounded, color: c.accent, size: 20), + ], + ), ), ); } From 1c9c7e6aef796caa77eb8b5821276a9477ae8b0c Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 19 May 2026 16:28:11 +0300 Subject: [PATCH 07/33] feat: add forgot password and reset password screens matching Figma Forgot password: email/phone tab switcher, key icon header, sends POST /forgot-password, navigates to reset screen. Phone tab shows coming-soon snackbar. Reset password: token field (from email), new/confirm password fields, live requirements checklist with animated check circles (length/uppercase/number/special char), green success snackbar on completion, redirects to login. Co-Authored-By: Claude Sonnet 4.6 --- .../pages/forgot_password_screen.dart | 517 ++++++++++++++++- .../pages/reset_password_screen.dart | 530 ++++++++++++++++++ 2 files changed, 1035 insertions(+), 12 deletions(-) create mode 100644 lib/features/auth/presentation/pages/reset_password_screen.dart diff --git a/lib/features/auth/presentation/pages/forgot_password_screen.dart b/lib/features/auth/presentation/pages/forgot_password_screen.dart index 9754932..2831c11 100644 --- a/lib/features/auth/presentation/pages/forgot_password_screen.dart +++ b/lib/features/auth/presentation/pages/forgot_password_screen.dart @@ -1,29 +1,522 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/api/api_client.dart'; +import '../../../../core/auth/auth_state.dart'; import '../../../../core/theme/app_color_scheme.dart'; import '../../../../core/theme/app_typography.dart'; +import 'login_screen.dart'; +import 'reset_password_screen.dart'; -// Stub — full implementation in Step 5 -class ForgotPasswordScreen extends StatelessWidget { +class ForgotPasswordScreen extends ConsumerStatefulWidget { const ForgotPasswordScreen({super.key}); + @override + ConsumerState createState() => + _ForgotPasswordScreenState(); +} + +class _ForgotPasswordScreenState + extends ConsumerState { + final _emailCtrl = TextEditingController(); + final _phoneCtrl = TextEditingController(); + + bool _useEmail = true; + bool _isLoading = false; + String? _errorMessage; + + @override + void dispose() { + _emailCtrl.dispose(); + _phoneCtrl.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_useEmail) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'ስልክ ቁጥር ዳግም ማስጀመሪያ — በቅርብ ይመጣል', + style: AppTypography.amharicCaption.copyWith(color: Colors.white), + ), + backgroundColor: context.colors.primary, + behavior: SnackBarBehavior.floating, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + return; + } + + final email = _emailCtrl.text.trim(); + if (email.isEmpty || !email.contains('@')) { + setState(() => _errorMessage = 'ትክክለኛ ኢሜል ያስፈልጋል'); + return; + } + + setState(() { _isLoading = true; _errorMessage = null; }); + try { + await ref.read(authRepositoryProvider).forgotPassword(email); + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => ResetPasswordScreen(email: email), + ), + ); + } + } on ApiException catch (e) { + setState(() => _errorMessage = e.message); + } catch (_) { + setState(() => _errorMessage = 'ግንኙነት አልተሳካም። ድጋሚ ይሞክሩ።'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + @override Widget build(BuildContext context) { final c = context.colors; + return Scaffold( backgroundColor: c.parchment, - appBar: AppBar( - backgroundColor: c.parchment, - elevation: 0, - leading: IconButton( - icon: Icon(Icons.arrow_back_ios_new_rounded, color: c.textOnParchment, size: 20), - onPressed: () => Navigator.pop(context), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), + _BackButton(), + const SizedBox(height: 24), + Text( + 'የይለፍ ቃል እንረሱ?', + style: AppTypography.amharicDisplay.copyWith( + color: c.textOnParchment, + fontSize: 30, + ), + ), + const SizedBox(height: 6), + Text( + 'ምንም ሰሞ። የተመዘገቡ ኢሜልዎን ያስገቡ፤ የዳግም ማስጀመሪያ ኮድ እንልካለን።', + style: AppTypography.amharicBody.copyWith( + color: c.textMuted, + fontSize: 13, + height: 1.6, + ), + ), + const SizedBox(height: 32), + _IconBox(icon: Icons.key_rounded, colors: c), + const SizedBox(height: 32), + _TabSwitcher( + useEmail: _useEmail, + onChanged: (v) => setState(() { + _useEmail = v; + _errorMessage = null; + }), + colors: c, + ), + const SizedBox(height: 20), + if (_useEmail) ...[ + _FieldLabel( + label: 'የተመዘገቡ ኢሜል', + active: true, + colors: c, + ), + const SizedBox(height: 6), + _InputField( + controller: _emailCtrl, + hint: 'nehmia@bible.app', + prefixIcon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + enabled: !_isLoading, + ), + const SizedBox(height: 8), + Text( + 'የዳግም ማስጀመሪያ ኮዱ ወደዚህ ኢሜል ይላካል።', + style: AppTypography.amharicCaption.copyWith( + color: c.textCaption, + fontSize: 11, + ), + ), + ] else ...[ + _FieldLabel( + label: 'የተመዘገቡ ስልክ ቁጥር', + active: true, + colors: c, + ), + const SizedBox(height: 6), + _InputField( + controller: _phoneCtrl, + hint: '+251 911 234 567', + prefixIcon: Icons.phone_outlined, + keyboardType: TextInputType.phone, + enabled: !_isLoading, + ), + const SizedBox(height: 8), + Text( + 'የዳግም ማስጀመሪያ ኮዱ ወደዚህ ስልክ ቁጥር ይላካል።', + style: AppTypography.amharicCaption.copyWith( + color: c.textCaption, + fontSize: 11, + ), + ), + ], + if (_errorMessage != null) ...[ + const SizedBox(height: 12), + _ErrorBanner(message: _errorMessage!), + ], + const SizedBox(height: 32), + _PrimaryButton( + label: 'አሮኝ ሊክ', + isLoading: _isLoading, + onTap: _isLoading ? null : _submit, + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'ኮዱን አስታወሱ? ', + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, fontSize: 12), + ), + GestureDetector( + onTap: _isLoading + ? null + : () => Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const LoginScreen()), + ), + child: Text( + 'ይ​ግቡ', + style: AppTypography.amharicCaption.copyWith( + color: c.primary, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ); + } +} + +// ── Tab switcher ────────────────────────────────────────────────────────────── + +class _TabSwitcher extends StatelessWidget { + const _TabSwitcher({ + required this.useEmail, + required this.onChanged, + required this.colors, + }); + + final bool useEmail; + final ValueChanged onChanged; + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + return Container( + decoration: BoxDecoration( + color: c.parchmentDark, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(4), + child: Row( + children: [ + _Tab( + icon: Icons.email_outlined, + label: 'ኢሜል', + active: useEmail, + onTap: () => onChanged(true), + colors: c, + ), + _Tab( + icon: Icons.phone_outlined, + label: 'ስልክ', + active: !useEmail, + onTap: () => onChanged(false), + colors: c, + ), + ], + ), + ); + } +} + +class _Tab extends StatelessWidget { + const _Tab({ + required this.icon, + required this.label, + required this.active, + required this.onTap, + required this.colors, + }); + + final IconData icon; + final String label; + final bool active; + final VoidCallback onTap; + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + return Expanded( + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: active ? c.surface : Colors.transparent, + borderRadius: BorderRadius.circular(9), + boxShadow: active + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.07), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 15, + color: active ? c.primary : c.textMuted, + ), + const SizedBox(width: 6), + Text( + label, + style: AppTypography.amharicCaption.copyWith( + color: active ? c.primary : c.textMuted, + fontWeight: active ? FontWeight.w700 : FontWeight.w400, + fontSize: 13, + ), + ), + ], + ), ), ), - body: Center( - child: Text( - 'የይለፍ ቃል ዳግም ማስጀመር — በቅርብ ይመጣል', - style: AppTypography.amharicBody.copyWith(color: c.textMuted), + ); + } +} + +// ── Field label ─────────────────────────────────────────────────────────────── + +class _FieldLabel extends StatelessWidget { + const _FieldLabel({ + required this.label, + required this.active, + required this.colors, + }); + final String label; + final bool active; + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + return Text( + label, + style: AppTypography.amharicCaption.copyWith( + color: active ? c.primary : c.textMuted, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ); + } +} + +// ── Shared widgets ──────────────────────────────────────────────────────────── + +class _BackButton extends StatelessWidget { + @override + Widget build(BuildContext context) { + final c = context.colors; + return GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: c.borderSubtle), + ), + alignment: Alignment.center, + child: Icon(Icons.chevron_left_rounded, + color: c.textOnParchment, size: 22), + ), + ); + } +} + +class _IconBox extends StatelessWidget { + const _IconBox({required this.icon, required this.colors}); + final IconData icon; + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + return Center( + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: c.primary, + borderRadius: BorderRadius.circular(22), + ), + alignment: Alignment.center, + child: Icon(icon, color: c.accent, size: 36), + ), + ); + } +} + +class _InputField extends StatelessWidget { + const _InputField({ + required this.controller, + required this.hint, + required this.prefixIcon, + this.keyboardType, + this.enabled = true, + }); + + final TextEditingController controller; + final String hint; + final IconData prefixIcon; + final TextInputType? keyboardType; + final bool enabled; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return TextField( + controller: controller, + keyboardType: keyboardType, + enabled: enabled, + style: AppTypography.amharicBody + .copyWith(color: c.textOnParchment, fontSize: 15), + decoration: InputDecoration( + hintText: hint, + hintStyle: AppTypography.englishCaption + .copyWith(color: c.textCaption, fontSize: 14), + prefixIcon: Icon(prefixIcon, color: c.textCaption, size: 18), + filled: true, + fillColor: c.surface, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 15), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.borderSubtle), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.primary, width: 1.5), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.primary, width: 1.5), + ), + ), + ); + } +} + +class _ErrorBanner extends StatelessWidget { + const _ErrorBanner({required this.message}); + final String message; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: c.primary.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: c.primary.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + Icon(Icons.error_outline_rounded, color: c.primary, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text(message, + style: AppTypography.amharicCaption + .copyWith(color: c.primary, fontSize: 12)), + ), + ], + ), + ); + } +} + +class _PrimaryButton extends StatelessWidget { + const _PrimaryButton({ + required this.label, + required this.isLoading, + required this.onTap, + }); + + final String label; + final bool isLoading; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: 54, + decoration: BoxDecoration( + color: onTap == null ? c.primary.withValues(alpha: 0.5) : c.primary, + borderRadius: BorderRadius.circular(14), + boxShadow: onTap == null + ? null + : [ + BoxShadow( + color: c.primary.withValues(alpha: 0.35), + blurRadius: 16, + offset: const Offset(0, 5), + ), + ], ), + alignment: Alignment.center, + child: isLoading + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, color: c.accent), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, + style: AppTypography.amharicLabel + .copyWith(color: c.accent, fontSize: 16)), + const SizedBox(width: 6), + Icon(Icons.chevron_right_rounded, color: c.accent, size: 20), + ], + ), ), ); } diff --git a/lib/features/auth/presentation/pages/reset_password_screen.dart b/lib/features/auth/presentation/pages/reset_password_screen.dart new file mode 100644 index 0000000..40e6ea5 --- /dev/null +++ b/lib/features/auth/presentation/pages/reset_password_screen.dart @@ -0,0 +1,530 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/api/api_client.dart'; +import '../../../../core/auth/auth_state.dart'; +import '../../../../core/theme/app_color_scheme.dart'; +import '../../../../core/theme/app_typography.dart'; +import 'login_screen.dart'; + +class ResetPasswordScreen extends ConsumerStatefulWidget { + const ResetPasswordScreen({super.key, required this.email}); + + final String email; + + @override + ConsumerState createState() => + _ResetPasswordScreenState(); +} + +class _ResetPasswordScreenState extends ConsumerState { + final _tokenCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + final _confirmCtrl = TextEditingController(); + + bool _obscurePassword = true; + bool _obscureConfirm = true; + bool _isLoading = false; + String? _errorMessage; + + @override + void dispose() { + _tokenCtrl.dispose(); + _passwordCtrl.dispose(); + _confirmCtrl.dispose(); + super.dispose(); + } + + // ── Password requirement checks ─────────────────────────────────────────── + + bool get _hasLength => _passwordCtrl.text.length >= 8; + bool get _hasUpper => _passwordCtrl.text.contains(RegExp(r'[A-Z]')); + bool get _hasNumber => _passwordCtrl.text.contains(RegExp(r'[0-9]')); + bool get _hasSpecial => + _passwordCtrl.text.contains(RegExp(r'[!@#\$%^&*(),.?":{}|<>]')); + bool get _passwordsMatch => + _passwordCtrl.text.isNotEmpty && + _passwordCtrl.text == _confirmCtrl.text; + + bool get _canSubmit => + _tokenCtrl.text.isNotEmpty && + _hasLength && + _hasUpper && + _hasNumber && + _passwordsMatch; + + Future _submit() async { + if (!_canSubmit) return; + setState(() { _isLoading = true; _errorMessage = null; }); + try { + await ref.read(authRepositoryProvider).resetPassword( + _tokenCtrl.text.trim(), + _passwordCtrl.text, + ); + if (mounted) { + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const LoginScreen()), + (route) => route.isFirst, + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'የይለፍ ቃልዎ ተቀይሯል። እባክዎ ይግቡ።', + style: AppTypography.amharicCaption + .copyWith(color: Colors.white), + ), + backgroundColor: const Color(0xFF2E7D32), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ); + } + } on ApiException catch (e) { + setState(() => _errorMessage = e.message); + } catch (_) { + setState(() => _errorMessage = 'ግንኙነት አልተሳካም። ድጋሚ ይሞክሩ።'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final c = context.colors; + + return Scaffold( + backgroundColor: c.parchment, + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), + _BackButton(), + const SizedBox(height: 24), + Text( + 'አዲስ የይለፍ ቃል', + style: AppTypography.amharicDisplay.copyWith( + color: c.textOnParchment, + fontSize: 30, + ), + ), + const SizedBox(height: 6), + Text( + 'የሚያስታውሱት ጠንካራ የይለፍ ቃል ለመምረጥ ይሞክሩ።', + style: AppTypography.amharicBody.copyWith( + color: c.textMuted, + fontSize: 13, + height: 1.6, + ), + ), + const SizedBox(height: 32), + _IconBox(colors: c), + const SizedBox(height: 32), + + // Token field + _FieldLabel(label: 'ከኢሜልዎ የተቀበሉት ኮድ', colors: c), + const SizedBox(height: 6), + _PasswordField( + controller: _tokenCtrl, + hint: 'reset-token-from-email', + prefixIcon: Icons.tag_rounded, + obscureText: false, + showSuffix: false, + enabled: !_isLoading, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 20), + + // New password + _FieldLabel(label: 'አዲስ የይለፍ ቃል', colors: c), + const SizedBox(height: 6), + _PasswordField( + controller: _passwordCtrl, + hint: '••••••••', + prefixIcon: Icons.lock_outline_rounded, + obscureText: _obscurePassword, + showSuffix: true, + enabled: !_isLoading, + onToggleObscure: () => + setState(() => _obscurePassword = !_obscurePassword), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + + // Confirm password + _FieldLabel(label: 'የይለፍ ቃል ያረጋግጡ', colors: c), + const SizedBox(height: 6), + _PasswordField( + controller: _confirmCtrl, + hint: '••••••••', + prefixIcon: Icons.lock_outline_rounded, + obscureText: _obscureConfirm, + showSuffix: true, + showMatch: _passwordsMatch, + enabled: !_isLoading, + onToggleObscure: () => + setState(() => _obscureConfirm = !_obscureConfirm), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 20), + + // Requirements checklist + _RequirementsCard( + hasLength: _hasLength, + hasUpper: _hasUpper, + hasNumber: _hasNumber, + hasSpecial: _hasSpecial, + colors: c, + ), + + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + _ErrorBanner(message: _errorMessage!), + ], + const SizedBox(height: 28), + _PrimaryButton( + label: 'አስቀምጥ እና ግባ', + isLoading: _isLoading, + onTap: (!_canSubmit || _isLoading) ? null : _submit, + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ); + } +} + +// ── Requirements card ───────────────────────────────────────────────────────── + +class _RequirementsCard extends StatelessWidget { + const _RequirementsCard({ + required this.hasLength, + required this.hasUpper, + required this.hasNumber, + required this.hasSpecial, + required this.colors, + }); + + final bool hasLength; + final bool hasUpper; + final bool hasNumber; + final bool hasSpecial; + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: c.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'የይለፍ ቃል መስፈርቶች', + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, + fontWeight: FontWeight.w700, + fontSize: 11, + ), + ), + const SizedBox(height: 10), + _Requirement( + met: hasLength, label: 'ቢያንስ ፰ ቁምፊዎች', colors: c), + const SizedBox(height: 6), + _Requirement( + met: hasUpper, label: 'አንድ ትልቅ ፊደል (A–Z)', colors: c), + const SizedBox(height: 6), + _Requirement( + met: hasNumber, label: 'አንድ ቁጥር (0–9)', colors: c), + const SizedBox(height: 6), + _Requirement( + met: hasSpecial, label: 'አንድ ልዩ ምልክት (!@#\$)', colors: c), + ], + ), + ); + } +} + +class _Requirement extends StatelessWidget { + const _Requirement({ + required this.met, + required this.label, + required this.colors, + }); + + final bool met; + final String label; + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + final color = met ? const Color(0xFF2E7D32) : c.textCaption; + + return Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 18, + height: 18, + decoration: BoxDecoration( + color: met ? const Color(0xFF2E7D32).withValues(alpha: 0.12) : Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: met ? const Color(0xFF2E7D32) : c.borderSubtle, + width: 1.5, + ), + ), + child: met + ? const Icon(Icons.check_rounded, + color: Color(0xFF2E7D32), size: 11) + : null, + ), + const SizedBox(width: 10), + Text( + label, + style: AppTypography.amharicCaption.copyWith( + color: color, + fontSize: 12, + ), + ), + ], + ); + } +} + +// ── Password field ──────────────────────────────────────────────────────────── + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.hint, + required this.prefixIcon, + required this.obscureText, + required this.showSuffix, + required this.enabled, + this.showMatch = false, + this.onToggleObscure, + this.onChanged, + }); + + final TextEditingController controller; + final String hint; + final IconData prefixIcon; + final bool obscureText; + final bool showSuffix; + final bool showMatch; + final bool enabled; + final VoidCallback? onToggleObscure; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) { + final c = context.colors; + + Widget? suffix; + if (showMatch) { + suffix = Icon(Icons.check_circle_rounded, + color: const Color(0xFF2E7D32), size: 20); + } else if (showSuffix) { + suffix = IconButton( + icon: Icon( + obscureText ? Icons.visibility_outlined : Icons.visibility_off_outlined, + color: c.textCaption, + size: 20, + ), + onPressed: onToggleObscure, + ); + } + + return TextField( + controller: controller, + obscureText: obscureText, + enabled: enabled, + onChanged: onChanged, + style: AppTypography.amharicBody + .copyWith(color: c.textOnParchment, fontSize: 15), + decoration: InputDecoration( + hintText: hint, + hintStyle: AppTypography.englishCaption + .copyWith(color: c.textCaption, fontSize: 14), + prefixIcon: Icon(prefixIcon, color: c.textCaption, size: 18), + suffixIcon: suffix, + filled: true, + fillColor: c.surface, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 15), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.borderSubtle), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.primary, width: 1.5), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.primary, width: 1.5), + ), + ), + ); + } +} + +// ── Shared widgets ──────────────────────────────────────────────────────────── + +class _BackButton extends StatelessWidget { + @override + Widget build(BuildContext context) { + final c = context.colors; + return GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: c.borderSubtle), + ), + alignment: Alignment.center, + child: Icon(Icons.chevron_left_rounded, + color: c.textOnParchment, size: 22), + ), + ); + } +} + +class _IconBox extends StatelessWidget { + const _IconBox({required this.colors}); + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + return Center( + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: c.primary, + borderRadius: BorderRadius.circular(22), + ), + alignment: Alignment.center, + child: Icon(Icons.lock_reset_rounded, color: c.accent, size: 36), + ), + ); + } +} + +class _FieldLabel extends StatelessWidget { + const _FieldLabel({required this.label, required this.colors}); + final String label; + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + return Text( + label, + style: AppTypography.amharicCaption.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ); + } +} + +class _ErrorBanner extends StatelessWidget { + const _ErrorBanner({required this.message}); + final String message; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: c.primary.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: c.primary.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + Icon(Icons.error_outline_rounded, color: c.primary, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text(message, + style: AppTypography.amharicCaption + .copyWith(color: c.primary, fontSize: 12)), + ), + ], + ), + ); + } +} + +class _PrimaryButton extends StatelessWidget { + const _PrimaryButton({ + required this.label, + required this.isLoading, + required this.onTap, + }); + + final String label; + final bool isLoading; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: 54, + decoration: BoxDecoration( + color: onTap == null ? c.primary.withValues(alpha: 0.5) : c.primary, + borderRadius: BorderRadius.circular(14), + boxShadow: onTap == null + ? null + : [ + BoxShadow( + color: c.primary.withValues(alpha: 0.35), + blurRadius: 16, + offset: const Offset(0, 5), + ), + ], + ), + alignment: Alignment.center, + child: isLoading + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, color: c.accent), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, + style: AppTypography.amharicLabel + .copyWith(color: c.accent, fontSize: 16)), + const SizedBox(width: 6), + Icon(Icons.chevron_right_rounded, color: c.accent, size: 20), + ], + ), + ), + ); + } +} From 4f5cb36d05ccda759e2dfca4c07c139e054f19f2 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 19 May 2026 16:35:25 +0300 Subject: [PATCH 08/33] feat: add profile screen and wire Me tab to auth state - Add ProfileScreen with avatar, stats (streak/bookmarks/progress), achievements section, and logout/delete-account menu - Update MeScreen to ConsumerStatefulWidget; show real name+email when authenticated and navigate to ProfileScreen on tap - Show guest card with login prompt when unauthenticated Co-Authored-By: Claude Sonnet 4.6 --- .../presentation/pages/profile_screen.dart | 711 ++++++++++++++++++ .../me/presentation/pages/me_screen.dart | 132 +++- 2 files changed, 824 insertions(+), 19 deletions(-) create mode 100644 lib/features/auth/presentation/pages/profile_screen.dart diff --git a/lib/features/auth/presentation/pages/profile_screen.dart b/lib/features/auth/presentation/pages/profile_screen.dart new file mode 100644 index 0000000..323be3e --- /dev/null +++ b/lib/features/auth/presentation/pages/profile_screen.dart @@ -0,0 +1,711 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:kenat/kenat.dart'; +import '../../../../core/auth/auth_state.dart'; +import '../../../../core/auth/user_profile.dart'; +import '../../../../core/constants/app_icons.dart'; +import '../../../../core/settings/app_settings.dart'; +import '../../../../core/storage/app_database_provider.dart'; +import '../../../../core/theme/app_color_scheme.dart'; +import '../../../../core/theme/app_typography.dart'; +import '../../../books/providers/reading_progress_providers.dart'; + +// ── Stats provider ───────────────────────────────────────────────────────── + +final _bookmarkCountProvider = FutureProvider.autoDispose((ref) async { + final db = await ref.watch(appDatabaseProvider).database; + final rows = await db.rawQuery( + "SELECT COUNT(*) as c FROM bookmarks WHERE sync_status != 'pendingDelete'", + ); + return (rows.first['c'] as int?) ?? 0; +}); + +// ── Profile screen ───────────────────────────────────────────────────────── + +class ProfileScreen extends ConsumerWidget { + const ProfileScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = context.colors; + final authState = ref.watch(authStateProvider); + final user = authState.user; + if (user == null) return const SizedBox.shrink(); + + return Scaffold( + backgroundColor: c.parchment, + body: SafeArea( + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _TopBar(user: user, colors: c)), + SliverToBoxAdapter(child: const SizedBox(height: 24)), + SliverToBoxAdapter(child: _AvatarSection(user: user, colors: c)), + SliverToBoxAdapter(child: const SizedBox(height: 20)), + SliverToBoxAdapter(child: _StatsRow(colors: c)), + SliverToBoxAdapter(child: const SizedBox(height: 20)), + SliverToBoxAdapter(child: _ActionButtons(colors: c)), + SliverToBoxAdapter(child: const SizedBox(height: 28)), + SliverToBoxAdapter(child: _AchievementsSection(colors: c)), + SliverToBoxAdapter(child: const SizedBox(height: 32)), + ], + ), + ), + ); + } +} + +// ── Top bar ──────────────────────────────────────────────────────────────── + +class _TopBar extends ConsumerWidget { + const _TopBar({required this.user, required this.colors}); + final UserProfile user; + final AppColorScheme colors; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = colors; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: c.borderSubtle), + ), + alignment: Alignment.center, + child: Icon(Icons.chevron_left_rounded, + color: c.textOnParchment, size: 22), + ), + ), + const Spacer(), + Text( + 'ፕሮፋይል', + style: AppTypography.amharicLabel.copyWith( + color: c.textOnParchment, + fontSize: 15, + ), + ), + const Spacer(), + _MenuButton(user: user, colors: c), + ], + ), + ); + } +} + +class _MenuButton extends ConsumerWidget { + const _MenuButton({required this.user, required this.colors}); + final UserProfile user; + final AppColorScheme colors; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = colors; + return GestureDetector( + onTap: () => _showMenu(context, ref, c), + child: Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: c.borderSubtle), + ), + alignment: Alignment.center, + child: Icon(Icons.more_horiz_rounded, color: c.textMuted, size: 20), + ), + ); + } + + void _showMenu(BuildContext context, WidgetRef ref, AppColorScheme c) { + showModalBottomSheet( + context: context, + backgroundColor: c.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => _ProfileMenuSheet(colors: c, ref: ref, context: context), + ); + } +} + +class _ProfileMenuSheet extends StatelessWidget { + const _ProfileMenuSheet({ + required this.colors, + required this.ref, + required this.context, + }); + final AppColorScheme colors; + final WidgetRef ref; + final BuildContext context; + + @override + Widget build(BuildContext ctx) { + final c = colors; + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: c.borderSubtle, + borderRadius: BorderRadius.circular(2), + ), + ), + _SheetTile( + icon: Icons.logout_rounded, + label: 'ውጣ', + color: c.textBody, + onTap: () async { + Navigator.pop(ctx); + await ref.read(authStateProvider.notifier).logout(); + if (context.mounted) Navigator.of(context).pop(); + }, + ), + Divider(color: c.borderSubtle, height: 1, indent: 16), + _SheetTile( + icon: Icons.delete_outline_rounded, + label: 'መለያ ሰርዝ', + color: c.primary, + onTap: () { + Navigator.pop(ctx); + _confirmDelete(context, ref, c); + }, + ), + ], + ), + ), + ); + } + + void _confirmDelete(BuildContext ctx, WidgetRef ref, AppColorScheme c) { + showDialog( + context: ctx, + builder: (dlgCtx) => AlertDialog( + backgroundColor: c.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Text( + 'መለያ ይሰረዝ?', + style: AppTypography.amharicSubheading.copyWith( + color: c.textOnParchment, + ), + ), + content: Text( + 'ሁሉም ውሂብዎ ይጠፋል። ይህ ድርጊት ሊቀለበስ አይችልም።', + style: AppTypography.amharicBody.copyWith( + color: c.textMuted, + fontSize: 13, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dlgCtx), + child: Text('ይቅር', + style: AppTypography.amharicLabel + .copyWith(color: c.textMuted)), + ), + TextButton( + onPressed: () async { + Navigator.pop(dlgCtx); + await ref.read(authStateProvider.notifier).deleteAccount(); + if (ctx.mounted) Navigator.of(ctx).pop(); + }, + child: Text('ሰርዝ', + style: AppTypography.amharicLabel + .copyWith(color: c.primary)), + ), + ], + ), + ); + } +} + +class _SheetTile extends StatelessWidget { + const _SheetTile({ + required this.icon, + required this.label, + required this.color, + required this.onTap, + }); + final IconData icon; + final String label; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: Row( + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 14), + Text( + label, + style: AppTypography.amharicLabel.copyWith(color: color), + ), + ], + ), + ), + ); + } +} + +// ── Avatar section ───────────────────────────────────────────────────────── + +class _AvatarSection extends StatelessWidget { + const _AvatarSection({required this.user, required this.colors}); + final UserProfile user; + final AppColorScheme colors; + + String get _initial => + user.name.isNotEmpty ? user.name.characters.first : '?'; + + @override + Widget build(BuildContext context) { + final c = colors; + return Column( + children: [ + Stack( + alignment: Alignment.center, + children: [ + Container( + width: 96, + height: 96, + decoration: BoxDecoration( + color: c.primary, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: c.primary.withValues(alpha: 0.3), + blurRadius: 20, + offset: const Offset(0, 6), + ), + ], + ), + alignment: Alignment.center, + child: Text( + _initial, + style: TextStyle( + fontFamily: AppTypography.shiromeda, + fontSize: 38, + fontWeight: FontWeight.w700, + color: c.accent, + height: 1, + ), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: c.accent, + shape: BoxShape.circle, + border: Border.all(color: c.parchment, width: 2), + ), + alignment: Alignment.center, + child: + Icon(Icons.camera_alt_rounded, size: 13, color: c.primary), + ), + ), + ], + ), + const SizedBox(height: 14), + Text( + user.name, + style: AppTypography.amharicSubheading.copyWith( + color: c.textOnParchment, + ), + ), + const SizedBox(height: 4), + Text( + user.email, + style: AppTypography.englishCaption.copyWith( + color: c.textMuted, + fontSize: 13, + ), + ), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5), + decoration: BoxDecoration( + color: c.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: c.primary.withValues(alpha: 0.15)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(AppIcons.malteseCross, + style: TextStyle(color: c.accentDeep, fontSize: 11)), + const SizedBox(width: 6), + Text( + 'አባል · የቅዱስ ቤተ-ሰብ', + style: AppTypography.amharicCaption.copyWith( + color: c.primary, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ); + } +} + +// ── Stats row ────────────────────────────────────────────────────────────── + +class _StatsRow extends ConsumerWidget { + const _StatsRow({required this.colors}); + final AppColorScheme colors; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = colors; + final useGeez = Settings.of(context).useGeezNumbers; + final streakAsync = ref.watch(readingStreakStateProvider); + final bookmarkAsync = ref.watch(_bookmarkCountProvider); + final snapshotsAsync = ref.watch(continueReadingSnapshotsProvider); + + final streak = streakAsync.value?.currentStreak ?? 0; + final bookmarks = bookmarkAsync.value ?? 0; + + int overallPct = 0; + if (snapshotsAsync.value != null && snapshotsAsync.value!.isNotEmpty) { + final snapshots = snapshotsAsync.value!; + overallPct = snapshots + .map((s) => s.progressPercent) + .reduce((a, b) => a + b) ~/ + snapshots.length; + } + + String fmt(int n) => useGeez ? toGeez(n) : '$n'; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 18), + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: c.borderSubtle), + ), + child: Row( + children: [ + _StatCell( + value: fmt(streak), + label: 'ቀን ቅ/ነዴ', + colors: c), + _StatDivider(colors: c), + _StatCell( + value: fmt(bookmarks), + label: 'ምልክት', + colors: c), + _StatDivider(colors: c), + _StatCell( + value: '${fmt(overallPct)}%', + label: 'ዕቅድ', + colors: c), + ], + ), + ), + ); + } +} + +class _StatCell extends StatelessWidget { + const _StatCell({ + required this.value, + required this.label, + required this.colors, + }); + final String value; + final String label; + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + return Expanded( + child: Column( + children: [ + Text( + value, + style: AppTypography.amharicSubheading.copyWith( + color: c.textOnParchment, + fontSize: 20, + ), + ), + const SizedBox(height: 4), + Text( + label, + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, + fontSize: 11, + ), + ), + ], + ), + ); + } +} + +class _StatDivider extends StatelessWidget { + const _StatDivider({required this.colors}); + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 36, + child: VerticalDivider(color: colors.borderSubtle, width: 1), + ); + } +} + +// ── Action buttons ───────────────────────────────────────────────────────── + +class _ActionButtons extends StatelessWidget { + const _ActionButtons({required this.colors}); + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () {}, + child: Container( + height: 46, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: c.primary, width: 1.2), + ), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.edit_outlined, color: c.primary, size: 16), + const SizedBox(width: 8), + Text( + 'ፕሮፋይል አስተካክል', + style: AppTypography.amharicCaption.copyWith( + color: c.primary, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 10), + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: c.borderSubtle), + ), + alignment: Alignment.center, + child: Icon(Icons.ios_share_rounded, color: c.textMuted, size: 18), + ), + ], + ), + ); + } +} + +// ── Achievements section ─────────────────────────────────────────────────── + +class _AchievementsSection extends ConsumerWidget { + const _AchievementsSection({required this.colors}); + final AppColorScheme colors; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = colors; + final streakAsync = ref.watch(readingStreakStateProvider); + final streak = streakAsync.value?.currentStreak ?? 0; + final longest = streakAsync.value?.longestStreak ?? 0; + final snapshotsAsync = ref.watch(continueReadingSnapshotsProvider); + final hasReadAnything = + (snapshotsAsync.value?.isNotEmpty ?? false); + + final achievements = [ + _Achievement( + icon: AppIcons.ethiopianCross, + title: 'መጀመሪያ ቀን', + subtitle: 'First Day', + unlocked: hasReadAnything || streak > 0, + ), + _Achievement( + icon: '🔗', + title: '፯ ቀን ሰንሰለት', + subtitle: '7-Day Streak', + unlocked: streak >= 7 || longest >= 7, + ), + _Achievement( + icon: '✦', + title: 'የምዝሙሩ', + subtitle: 'Psalm Reader', + unlocked: snapshotsAsync.value + ?.any((s) => s.entry.bookNameEn.contains('Psalm')) ?? + false, + ), + ]; + + final unlockedCount = achievements.where((a) => a.unlocked).length; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Text( + 'ስኬቶች', + style: AppTypography.amharicLabel.copyWith( + color: c.textOnParchment, + ), + ), + const Spacer(), + Text( + '$unlockedCount/${achievements.length}', + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 110, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: achievements.length, + separatorBuilder: (context, index) => const SizedBox(width: 12), + itemBuilder: (_, i) => + _AchievementCard(achievement: achievements[i], colors: c), + ), + ), + ], + ); + } +} + +class _Achievement { + const _Achievement({ + required this.icon, + required this.title, + required this.subtitle, + required this.unlocked, + }); + final String icon; + final String title; + final String subtitle; + final bool unlocked; +} + +class _AchievementCard extends StatelessWidget { + const _AchievementCard({ + required this.achievement, + required this.colors, + }); + final _Achievement achievement; + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + final unlocked = achievement.unlocked; + return Container( + width: 100, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: unlocked ? c.primary.withValues(alpha: 0.25) : c.borderSubtle, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: unlocked + ? c.primary.withValues(alpha: 0.1) + : c.borderSubtle.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: unlocked + ? Text(achievement.icon, + style: TextStyle( + fontSize: 20, + color: c.primary, + height: 1)) + : Icon(Icons.lock_outline_rounded, + color: c.textCaption, size: 18), + ), + const SizedBox(height: 8), + Text( + achievement.title, + style: AppTypography.amharicCaption.copyWith( + color: unlocked ? c.textOnParchment : c.textCaption, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + achievement.subtitle, + style: AppTypography.englishCaption.copyWith( + color: c.textCaption, + fontSize: 9, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } +} diff --git a/lib/features/me/presentation/pages/me_screen.dart b/lib/features/me/presentation/pages/me_screen.dart index da819ca..cd1992c 100644 --- a/lib/features/me/presentation/pages/me_screen.dart +++ b/lib/features/me/presentation/pages/me_screen.dart @@ -1,18 +1,23 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/auth/auth_state.dart'; +import '../../../../core/auth/user_profile.dart'; import '../../../../core/l10n/l10n.dart'; import '../../../../core/settings/app_settings.dart'; import '../../../../core/theme/app_color_scheme.dart'; import '../../../../core/theme/app_typography.dart'; +import '../../../auth/presentation/pages/login_screen.dart'; +import '../../../auth/presentation/pages/profile_screen.dart'; import 'reading_settings_page.dart'; -class MeScreen extends StatefulWidget { +class MeScreen extends ConsumerStatefulWidget { const MeScreen({super.key}); @override - State createState() => _MeScreenState(); + ConsumerState createState() => _MeScreenState(); } -class _MeScreenState extends State { +class _MeScreenState extends ConsumerState { bool _dailyVerse = true; @override @@ -20,6 +25,7 @@ class _MeScreenState extends State { final s = L10n.of(context); final settings = Settings.of(context); final isAmharic = s is AmStrings; + final authState = ref.watch(authStateProvider); return SafeArea( child: CustomScrollView( @@ -27,7 +33,9 @@ class _MeScreenState extends State { slivers: [ SliverToBoxAdapter(child: _MeAppBar(s: s)), const SliverToBoxAdapter(child: SizedBox(height: 16)), - SliverToBoxAdapter(child: _ProfileCard(s: s)), + SliverToBoxAdapter( + child: _ProfileCard(s: s, user: authState.user), + ), const SliverToBoxAdapter(child: SizedBox(height: 24)), // ── Reading ────────────────────────────────────────────────────── @@ -154,18 +162,24 @@ class _MeAppBar extends StatelessWidget { // ── Profile card ─────────────────────────────────────────────────────────────── class _ProfileCard extends StatelessWidget { - const _ProfileCard({required this.s}); + const _ProfileCard({required this.s, required this.user}); final AppStrings s; + final UserProfile? user; @override Widget build(BuildContext context) { + final c = context.colors; + if (user == null) return _GuestCard(s: s, colors: c); + final initial = user!.name.isNotEmpty ? user!.name.characters.first : '?'; + return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: GestureDetector( - onTap: () {}, - child: Builder(builder: (context) { - final c = context.colors; - return Container( + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ProfileScreen()), + ), + child: Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: c.primary, @@ -181,7 +195,6 @@ class _ProfileCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - // Avatar Container( width: 52, height: 52, @@ -191,7 +204,7 @@ class _ProfileCard extends StatelessWidget { ), alignment: Alignment.center, child: Text( - 'ን', + initial, style: TextStyle( fontFamily: AppTypography.shiromeda, fontSize: 22, @@ -201,14 +214,13 @@ class _ProfileCard extends StatelessWidget { ), ), const SizedBox(width: 14), - // Name + info + badge Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'ነህምያ ተስፋዬ', - style: TextStyle( + Text( + user!.name, + style: const TextStyle( fontFamily: AppTypography.shiromeda, fontSize: 16, fontWeight: FontWeight.w700, @@ -218,7 +230,7 @@ class _ProfileCard extends StatelessWidget { ), const SizedBox(height: 2), Text( - 'nehemiah@email.com • 12 ቀናት', + user!.email, style: AppTypography.amharicCaption.copyWith( color: Colors.white.withValues(alpha: 0.65), fontSize: 11, @@ -226,7 +238,8 @@ class _ProfileCard extends StatelessWidget { ), const SizedBox(height: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), decoration: BoxDecoration( color: c.accent, borderRadius: BorderRadius.circular(20), @@ -251,8 +264,89 @@ class _ProfileCard extends StatelessWidget { ), ], ), - ); - }), + ), + ), + ); + } +} + +class _GuestCard extends StatelessWidget { + const _GuestCard({required this.s, required this.colors}); + final AppStrings s; + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: GestureDetector( + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const LoginScreen()), + ), + child: Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: c.primary, + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: c.primary.withValues(alpha: 0.28), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: c.accent.withValues(alpha: 0.2), + shape: BoxShape.circle, + border: Border.all( + color: c.accent.withValues(alpha: 0.4), width: 1.5), + ), + alignment: Alignment.center, + child: Icon(Icons.person_outline_rounded, + color: c.accent, size: 26), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'ግባ ወይም ተመዝገብ', + style: const TextStyle( + fontFamily: AppTypography.shiromeda, + fontSize: 15, + fontWeight: FontWeight.w700, + color: Colors.white, + height: 1.3, + ), + ), + const SizedBox(height: 3), + Text( + 'ማስታወሻዎትን ወደ ደመና ያስቀምጡ', + style: AppTypography.amharicCaption.copyWith( + color: Colors.white.withValues(alpha: 0.65), + fontSize: 11, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right_rounded, + color: Colors.white.withValues(alpha: 0.5), + size: 22, + ), + ], + ), + ), ), ); } From ab1554e28e31ab1ae1333304a95d8dde0a04ab4c Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 19 May 2026 16:45:04 +0300 Subject: [PATCH 09/33] feat: add backend sync for bookmarks, notes, highlights, and progress - SyncRepository: HTTP calls to POST/PUT/DELETE /bookmarks, /notes, /highlights, and POST /progress/log-reading - SyncService: processes pending (pendingCreate/Update/Delete) items against the API, updates local sync_status and remote_id on success; errors are silently skipped and retried on next sync - AppDatabase: add soft-delete variants (mark pendingDelete when remote_id exists) and getPending* / updateAnnotationSync helpers - Auth: trigger syncAll() fire-and-forget after every login path (login, verifyOtp, signInWithGoogle, token restore on cold start) - Annotations: use soft-delete for removes; trigger sync after every bookmark/note/highlight mutation - Reader: fire-and-forget logReadingProgress after qualifying dwell Co-Authored-By: Claude Sonnet 4.6 --- lib/core/auth/auth_state.dart | 19 ++- lib/core/storage/app_database.dart | 98 +++++++++++++ lib/core/sync/sync_repository.dart | 135 ++++++++++++++++++ lib/core/sync/sync_service.dart | 118 +++++++++++++++ .../providers/annotation_providers.dart | 31 +++- .../presentation/pages/reader_screen.dart | 10 ++ 6 files changed, 405 insertions(+), 6 deletions(-) create mode 100644 lib/core/sync/sync_repository.dart create mode 100644 lib/core/sync/sync_service.dart diff --git a/lib/core/auth/auth_state.dart b/lib/core/auth/auth_state.dart index de5cd63..f79acea 100644 --- a/lib/core/auth/auth_state.dart +++ b/lib/core/auth/auth_state.dart @@ -1,6 +1,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../api/api_client.dart'; +import '../storage/app_database_provider.dart'; +import '../sync/sync_repository.dart'; +import '../sync/sync_service.dart'; import 'auth_repository.dart'; import 'auth_storage.dart'; import 'user_profile.dart'; @@ -21,6 +24,7 @@ final authStateProvider = StateNotifierProvider( (ref) => AuthNotifier( ref.read(authRepositoryProvider), ref.read(authStorageProvider), + ref, ), ); @@ -47,12 +51,14 @@ class AuthState { // ── Notifier ────────────────────────────────────────────────────────────────── class AuthNotifier extends StateNotifier { - AuthNotifier(this._repo, this._storage) : super(const AuthState.unknown()) { + AuthNotifier(this._repo, this._storage, this._ref) + : super(const AuthState.unknown()) { _init(); } final AuthRepository _repo; final AuthStorage _storage; + final Ref _ref; Future _init() async { final token = await _storage.readToken(); @@ -63,6 +69,7 @@ class AuthNotifier extends StateNotifier { try { final profile = await _repo.fetchProfile(token); state = AuthState.authenticated(profile, token); + _syncAfterAuth(token); } on ApiException catch (e) { if (e.isUnauthorized) await _storage.clearToken(); state = const AuthState.unauthenticated(); @@ -77,6 +84,7 @@ class AuthNotifier extends StateNotifier { await _storage.saveToken(token); final profile = await _repo.fetchProfile(token); state = AuthState.authenticated(profile, token); + _syncAfterAuth(token); } Future register(String name, String email, String password) async { @@ -88,6 +96,7 @@ class AuthNotifier extends StateNotifier { await _storage.saveToken(token); final profile = await _repo.fetchProfile(token); state = AuthState.authenticated(profile, token); + _syncAfterAuth(token); } Future signInWithGoogle() async { @@ -95,6 +104,14 @@ class AuthNotifier extends StateNotifier { await _storage.saveToken(token); final profile = await _repo.fetchProfile(token); state = AuthState.authenticated(profile, token); + _syncAfterAuth(token); + } + + void _syncAfterAuth(String token) { + SyncService( + db: _ref.read(appDatabaseProvider), + repo: SyncRepository(_ref.read(apiClientProvider), token), + ).syncAll(); } Future logout() async { diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index ca6e158..a22e8ce 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -395,6 +395,104 @@ class AppDatabase { return rows.map(Note.fromMap).toList(); } + // ── Sync helpers ─────────────────────────────────────────────────────────── + + Future updateAnnotationSync( + String table, + int id, + SyncStatus status, { + String? remoteId, + }) async { + final db = await database; + final values = {'sync_status': status.name}; + if (remoteId != null) values['remote_id'] = remoteId; + await db.update(table, values, where: 'id = ?', whereArgs: [id]); + } + + Future softDeleteBookmark(int id, {required bool hasRemoteId}) async { + final db = await database; + if (hasRemoteId) { + await db.update( + 'bookmarks', + {'sync_status': SyncStatus.pendingDelete.name}, + where: 'id = ?', + whereArgs: [id], + ); + } else { + await db.delete('bookmarks', where: 'id = ?', whereArgs: [id]); + } + } + + Future softDeleteHighlight(int id, {required bool hasRemoteId}) async { + final db = await database; + if (hasRemoteId) { + await db.update( + 'highlights', + {'sync_status': SyncStatus.pendingDelete.name}, + where: 'id = ?', + whereArgs: [id], + ); + } else { + await db.delete('highlights', where: 'id = ?', whereArgs: [id]); + } + } + + Future softDeleteNote(int id, {required bool hasRemoteId}) async { + final db = await database; + if (hasRemoteId) { + await db.update( + 'notes', + {'sync_status': SyncStatus.pendingDelete.name}, + where: 'id = ?', + whereArgs: [id], + ); + } else { + await db.delete('notes', where: 'id = ?', whereArgs: [id]); + } + } + + Future> getPendingBookmarks() async { + final db = await database; + final rows = await db.query( + 'bookmarks', + where: 'sync_status IN (?, ?, ?)', + whereArgs: [ + SyncStatus.pendingCreate.name, + SyncStatus.pendingUpdate.name, + SyncStatus.pendingDelete.name, + ], + ); + return rows.map(Bookmark.fromMap).toList(); + } + + Future> getPendingNotes() async { + final db = await database; + final rows = await db.query( + 'notes', + where: 'sync_status IN (?, ?, ?)', + whereArgs: [ + SyncStatus.pendingCreate.name, + SyncStatus.pendingUpdate.name, + SyncStatus.pendingDelete.name, + ], + ); + return rows.map(Note.fromMap).toList(); + } + + Future> getPendingHighlights() async { + final db = await database; + final rows = await db.query( + 'highlights', + where: 'sync_status IN (?, ?, ?)', + whereArgs: [ + SyncStatus.pendingCreate.name, + SyncStatus.pendingUpdate.name, + SyncStatus.pendingDelete.name, + ], + ); + return rows.map(Highlight.fromMap).toList(); + } + Future close() async { await _db?.close(); _db = null; diff --git a/lib/core/sync/sync_repository.dart b/lib/core/sync/sync_repository.dart new file mode 100644 index 0000000..eaa6627 --- /dev/null +++ b/lib/core/sync/sync_repository.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import '../annotations/annotation_models.dart'; +import '../api/api_client.dart'; + +class SyncRepository { + const SyncRepository(this._client, this._token); + + final ApiClient _client; + final String _token; + + // ── Bookmarks ────────────────────────────────────────────────────────────── + + Future createBookmark(Bookmark b) async { + final res = await _client.post( + '/bookmarks', + body: { + 'bookId': b.bookId, + 'chapter': b.chapter, + 'verseStart': b.verseStart, + 'verseCount': b.verseCount, + }, + token: _token, + ); + return _remoteId(res, 'bookmark'); + } + + Future updateBookmark(String remoteId, Bookmark b) => _client.put( + '/bookmarks/$remoteId', + body: { + 'bookId': b.bookId, + 'chapter': b.chapter, + 'verseStart': b.verseStart, + 'verseCount': b.verseCount, + }, + token: _token, + ); + + Future deleteBookmark(String remoteId) => + _client.delete('/bookmarks/$remoteId', token: _token); + + // ── Notes ────────────────────────────────────────────────────────────────── + + Future createNote(Note n) async { + final res = await _client.post( + '/notes', + body: { + 'bookId': n.bookId, + 'chapter': n.chapter, + 'verseStart': n.verseStart, + 'verseCount': n.verseCount, + 'content': n.content, + 'visibility': n.isPrivate ? 'private' : 'public', + }, + token: _token, + ); + return _remoteId(res, 'note'); + } + + Future updateNote(String remoteId, Note n) => _client.put( + '/notes/$remoteId', + body: { + 'content': n.content, + 'visibility': n.isPrivate ? 'private' : 'public', + }, + token: _token, + ); + + Future deleteNote(String remoteId) => + _client.delete('/notes/$remoteId', token: _token); + + // ── Highlights ───────────────────────────────────────────────────────────── + + Future createHighlight(Highlight h) async { + final res = await _client.post( + '/highlights', + body: { + 'bookId': h.bookId, + 'chapter': h.chapter, + 'verseStart': h.verseStart, + 'verseCount': h.verseCount, + 'color': _colorName(h.color), + if (h.note != null) 'note': h.note, + }, + token: _token, + ); + return _remoteId(res, 'highlight'); + } + + Future updateHighlight(String remoteId, Highlight h) => _client.put( + '/highlights/$remoteId', + body: { + 'color': _colorName(h.color), + if (h.note != null) 'note': h.note, + }, + token: _token, + ); + + Future deleteHighlight(String remoteId) => + _client.delete('/highlights/$remoteId', token: _token); + + // ── Progress ─────────────────────────────────────────────────────────────── + + Future logReadingProgress({ + required String bookId, + required int chapter, + }) => + _client.post( + '/progress/log-reading', + body: {'bookId': bookId, 'chapter': chapter}, + token: _token, + ); + + // ── Helpers ──────────────────────────────────────────────────────────────── + + static String _remoteId(Map res, String key) { + final data = res['data']; + if (data is Map) { + final obj = data[key]; + if (obj is Map && obj['_id'] is String) return obj['_id'] as String; + if (data['_id'] is String) return data['_id'] as String; + } + throw Exception('Cannot parse remote id for $key'); + } + + static const _colorNames = { + 0xFFFFEB3B: 'yellow', + 0xFF80CBC4: 'teal', + 0xFF90CAF9: 'blue', + 0xFFEF9A9A: 'red', + 0xFFCE93D8: 'purple', + }; + + static String _colorName(Color color) => + _colorNames[color.toARGB32()] ?? 'yellow'; +} diff --git a/lib/core/sync/sync_service.dart b/lib/core/sync/sync_service.dart new file mode 100644 index 0000000..6abfbe9 --- /dev/null +++ b/lib/core/sync/sync_service.dart @@ -0,0 +1,118 @@ +import '../annotations/annotation_models.dart'; +import '../storage/app_database.dart'; +import 'sync_repository.dart'; + +class SyncService { + const SyncService({required AppDatabase db, required SyncRepository repo}) + : _db = db, + _repo = repo; + + final AppDatabase _db; + final SyncRepository _repo; + + Future syncAll() => Future.wait([ + syncBookmarks(), + syncNotes(), + syncHighlights(), + ]); + + // ── Bookmarks ────────────────────────────────────────────────────────────── + + Future syncBookmarks() async { + final pending = await _db.getPendingBookmarks(); + for (final b in pending) { + try { + switch (b.syncStatus) { + case SyncStatus.pendingCreate: + final remoteId = await _repo.createBookmark(b); + await _db.updateAnnotationSync( + 'bookmarks', b.id!, SyncStatus.synced, + remoteId: remoteId, + ); + case SyncStatus.pendingUpdate: + if (b.remoteId != null) { + await _repo.updateBookmark(b.remoteId!, b); + await _db.updateAnnotationSync( + 'bookmarks', b.id!, SyncStatus.synced); + } + case SyncStatus.pendingDelete: + if (b.remoteId != null) await _repo.deleteBookmark(b.remoteId!); + await _db.deleteBookmark(b.id!); + case SyncStatus.synced: + break; + } + } catch (_) { + // Silent — will retry on next sync + } + } + } + + // ── Notes ────────────────────────────────────────────────────────────────── + + Future syncNotes() async { + final pending = await _db.getPendingNotes(); + for (final n in pending) { + try { + switch (n.syncStatus) { + case SyncStatus.pendingCreate: + final remoteId = await _repo.createNote(n); + await _db.updateAnnotationSync( + 'notes', n.id!, SyncStatus.synced, + remoteId: remoteId, + ); + case SyncStatus.pendingUpdate: + if (n.remoteId != null) { + await _repo.updateNote(n.remoteId!, n); + await _db.updateAnnotationSync( + 'notes', n.id!, SyncStatus.synced); + } + case SyncStatus.pendingDelete: + if (n.remoteId != null) await _repo.deleteNote(n.remoteId!); + await _db.deleteNote(n.id!); + case SyncStatus.synced: + break; + } + } catch (_) {} + } + } + + // ── Highlights ───────────────────────────────────────────────────────────── + + Future syncHighlights() async { + final pending = await _db.getPendingHighlights(); + for (final h in pending) { + try { + switch (h.syncStatus) { + case SyncStatus.pendingCreate: + final remoteId = await _repo.createHighlight(h); + await _db.updateAnnotationSync( + 'highlights', h.id!, SyncStatus.synced, + remoteId: remoteId, + ); + case SyncStatus.pendingUpdate: + if (h.remoteId != null) { + await _repo.updateHighlight(h.remoteId!, h); + await _db.updateAnnotationSync( + 'highlights', h.id!, SyncStatus.synced); + } + case SyncStatus.pendingDelete: + if (h.remoteId != null) await _repo.deleteHighlight(h.remoteId!); + await _db.deleteHighlight(h.id!); + case SyncStatus.synced: + break; + } + } catch (_) {} + } + } + + // ── Progress ─────────────────────────────────────────────────────────────── + + Future logReadingProgress({ + required String bookId, + required int chapter, + }) async { + try { + await _repo.logReadingProgress(bookId: bookId, chapter: chapter); + } catch (_) {} + } +} diff --git a/lib/features/annotations/providers/annotation_providers.dart b/lib/features/annotations/providers/annotation_providers.dart index 040035d..0601553 100644 --- a/lib/features/annotations/providers/annotation_providers.dart +++ b/lib/features/annotations/providers/annotation_providers.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/annotations/annotation_models.dart'; +import '../../../core/auth/auth_state.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/app_database_provider.dart'; +import '../../../core/sync/sync_repository.dart'; +import '../../../core/sync/sync_service.dart'; export '../../../core/storage/app_database_provider.dart' show appDatabaseProvider, annotationDbProvider; @@ -15,12 +18,13 @@ typedef ChapterKey = ({String bookId, int chapter}); class ChapterAnnotationsNotifier extends StateNotifier> { - ChapterAnnotationsNotifier(this._db, this._key) + ChapterAnnotationsNotifier(this._db, this._ref, this._key) : super(const AsyncValue.loading()) { _load(); } final AppDatabase _db; + final Ref _ref; final ChapterKey _key; Future _load() async { @@ -51,7 +55,8 @@ class ChapterAnnotationsNotifier final existing = annotations.bookmarks.where((b) => b.verseStart == verseStart).firstOrNull; if (existing != null) { - await _db.deleteBookmark(existing.id!); + await _db.softDeleteBookmark( + existing.id!, hasRemoteId: existing.remoteId != null); } else { final now = DateTime.now(); await _db.insertBookmark(Bookmark( @@ -64,6 +69,7 @@ class ChapterAnnotationsNotifier )); } await _load(); + _triggerSync(); } Future setHighlight({ @@ -96,6 +102,7 @@ class ChapterAnnotationsNotifier )); } await _load(); + _triggerSync(); } Future removeHighlight(int verseStart) async { @@ -103,8 +110,10 @@ class ChapterAnnotationsNotifier .where((h) => h.verseStart == verseStart) .firstOrNull; if (existing != null) { - await _db.deleteHighlight(existing.id!); + await _db.softDeleteHighlight( + existing.id!, hasRemoteId: existing.remoteId != null); await _load(); + _triggerSync(); } } @@ -120,7 +129,8 @@ class ChapterAnnotationsNotifier final now = DateTime.now(); if (existing != null) { if (content.trim().isEmpty) { - await _db.deleteNote(existing.id!); + await _db.softDeleteNote( + existing.id!, hasRemoteId: existing.remoteId != null); } else { await _db.updateNote(existing.copyWith( content: content.trim(), @@ -142,6 +152,16 @@ class ChapterAnnotationsNotifier )); } await _load(); + _triggerSync(); + } + + void _triggerSync() { + final token = _ref.read(authStateProvider).token; + if (token == null) return; + SyncService( + db: _db, + repo: SyncRepository(_ref.read(apiClientProvider), token), + ).syncAll(); } } @@ -151,5 +171,6 @@ final chapterAnnotationsProvider = StateNotifierProvider.family< ChapterAnnotationsNotifier, AsyncValue, ChapterKey>( - (ref, key) => ChapterAnnotationsNotifier(ref.watch(annotationDbProvider), key), + (ref, key) => + ChapterAnnotationsNotifier(ref.watch(annotationDbProvider), ref, key), ); diff --git a/lib/features/books/presentation/pages/reader_screen.dart b/lib/features/books/presentation/pages/reader_screen.dart index 83fa7bd..eb591d2 100644 --- a/lib/features/books/presentation/pages/reader_screen.dart +++ b/lib/features/books/presentation/pages/reader_screen.dart @@ -7,9 +7,12 @@ import 'package:kenat/kenat.dart'; import 'package:share_plus/share_plus.dart'; import '../../../../core/annotations/annotation_models.dart'; +import '../../../../core/auth/auth_state.dart'; import '../../../../core/l10n/l10n.dart'; import '../../../../core/services/repository_provider.dart'; import '../../../../core/settings/app_settings.dart'; +import '../../../../core/sync/sync_repository.dart'; +import '../../../../core/sync/sync_service.dart'; import '../../../../core/theme/app_colors.dart'; import '../../data/models/book.dart'; import '../../data/models/book_index_entry.dart'; @@ -210,6 +213,13 @@ class _ReaderScreenState extends ConsumerState await container .read(readingProgressRepositoryProvider) .recordQualifiedChapterRead(bookId: bookId, chapter: chNum); + final token = container.read(authStateProvider).token; + if (token != null) { + SyncService( + db: container.read(appDatabaseProvider), + repo: SyncRepository(container.read(apiClientProvider), token), + ).logReadingProgress(bookId: bookId, chapter: chNum); + } container.invalidate(continueReadingSnapshotsProvider); container.invalidate(readingStreakStateProvider); }, From bf9c84ff5d0d5890e142bbcb2a41b3d5a816a70e Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 19 May 2026 16:49:41 +0300 Subject: [PATCH 10/33] feat: wire home header avatar and greeting to auth state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Google login: show profile picture (NetworkImage) with initials fallback on error - Email/password login: show first letter of user name in primary- color circle - Guest: keep existing placeholder 'ን' circle - Greeting updates to 'ሰላም, [firstName]' / 'Hello, [firstName]' when authenticated Co-Authored-By: Claude Sonnet 4.6 --- .../presentation/widgets/home_header.dart | 67 +++++++++++++++++-- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/lib/features/home/presentation/widgets/home_header.dart b/lib/features/home/presentation/widgets/home_header.dart index 16b527b..e92b243 100644 --- a/lib/features/home/presentation/widgets/home_header.dart +++ b/lib/features/home/presentation/widgets/home_header.dart @@ -1,17 +1,25 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/auth/auth_state.dart'; +import '../../../../core/auth/user_profile.dart'; import '../../../../core/l10n/l10n.dart'; +import '../../../../core/theme/app_color_scheme.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_typography.dart'; -class HomeHeader extends StatelessWidget { +class HomeHeader extends ConsumerWidget { const HomeHeader({super.key, required this.dateLabel}); final String dateLabel; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final s = L10n.of(context); final c = context.colors; + final user = ref.watch(authStateProvider).user; + + final greeting = _greeting(s, user); + return Padding( padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), child: Row( @@ -30,7 +38,7 @@ class HomeHeader extends StatelessWidget { ), const SizedBox(height: 6), Text( - s.welcomeGreeting, + greeting, style: AppTypography.amharicHeading.copyWith( color: c.textOnParchment, ), @@ -41,20 +49,67 @@ class HomeHeader extends StatelessWidget { ), ), const SizedBox(width: 12), - const _Avatar(letter: 'ን'), + _Avatar(user: user, colors: c), ], ), ); } + + static String _greeting(AppStrings s, UserProfile? user) { + if (user == null) return s.welcomeGreeting; + final firstName = user.name.split(' ').first; + return s is AmStrings ? 'ሰላም, $firstName' : 'Hello, $firstName'; + } } +// ── Avatar ───────────────────────────────────────────────────────────────────── + class _Avatar extends StatelessWidget { - const _Avatar({required this.letter}); + const _Avatar({required this.user, required this.colors}); + + final UserProfile? user; + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + + // Google / social login — show profile picture + if (user?.avatar != null) { + return ClipOval( + child: SizedBox( + width: 50, + height: 50, + child: Image.network( + user!.avatar!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => _InitialsCircle( + letter: _initial(user!.name), + colors: c, + ), + ), + ), + ); + } + + // Email/password login — first letter of name + final letter = user != null ? _initial(user!.name) : 'ን'; + return _InitialsCircle(letter: letter, colors: c); + } + + static String _initial(String name) => + name.isNotEmpty ? name.characters.first : '?'; +} + +class _InitialsCircle extends StatelessWidget { + const _InitialsCircle({required this.letter, required this.colors}); + final String letter; + final AppColorScheme colors; @override Widget build(BuildContext context) { - final c = context.colors; + final c = colors; return Container( width: 50, height: 50, From 2bc3ab2db2d8e8ef1edd0995fddef90396bd9e9f Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 19 May 2026 16:51:11 +0300 Subject: [PATCH 11/33] feat: replace Apple sign-in with Facebook (coming soon) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows 'Facebook Sign In — በቅርብ ይመጣል' snackbar on tap. Co-Authored-By: Claude Sonnet 4.6 --- .../auth/presentation/pages/login_screen.dart | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/features/auth/presentation/pages/login_screen.dart b/lib/features/auth/presentation/pages/login_screen.dart index ff06a73..ff8756b 100644 --- a/lib/features/auth/presentation/pages/login_screen.dart +++ b/lib/features/auth/presentation/pages/login_screen.dart @@ -70,11 +70,11 @@ class _LoginScreenState extends ConsumerState { } } - void _appleSignIn() { + void _facebookSignIn() { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - 'Apple Sign In — በቅርብ ይመጣል', + 'Facebook Sign In — በቅርብ ይመጣል', style: AppTypography.amharicCaption.copyWith(color: Colors.white), ), backgroundColor: context.colors.primary, @@ -179,7 +179,7 @@ class _LoginScreenState extends ConsumerState { isGoogleLoading: _isGoogleLoading, allDisabled: isLoading, onGoogle: _googleSignIn, - onApple: _appleSignIn, + onFacebook: _facebookSignIn, ), const SizedBox(height: 28), _RegisterPrompt(enabled: !isLoading), @@ -597,13 +597,13 @@ class _SocialRow extends StatelessWidget { required this.isGoogleLoading, required this.allDisabled, required this.onGoogle, - required this.onApple, + required this.onFacebook, }); final bool isGoogleLoading; final bool allDisabled; final VoidCallback onGoogle; - final VoidCallback onApple; + final VoidCallback onFacebook; @override Widget build(BuildContext context) { @@ -621,11 +621,11 @@ class _SocialRow extends StatelessWidget { const SizedBox(width: 12), Expanded( child: _SocialButton( - onTap: allDisabled ? null : onApple, + onTap: allDisabled ? null : onFacebook, isLoading: false, - label: 'Apple', + label: 'Facebook', dark: true, - icon: const Icon(Icons.apple_rounded, color: Colors.white, size: 20), + icon: const Icon(Icons.facebook_rounded, color: Colors.white, size: 20), ), ), ], From b75fa04e209290bead6fd72a096019ae2c46acb3 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 19 May 2026 17:09:57 +0300 Subject: [PATCH 12/33] fix: correct highlight colors and add book field to bookmark/highlight sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Highlights were silently rejected by the backend because 'teal' and 'red' are not valid color names. Backend accepts: yellow, green, pink, blue, red, purple. - 0xFF80CBC4 (teal) → 'green' - 0xFFEF9A9A (pink-red) → 'pink' Add 'book' field alongside 'bookId' on both bookmark and highlight create/update payloads, matching the web FE format. The backend uses 'book' for verse reference lookup; without it bookmarks showed 'verse not found' on the web. Co-Authored-By: Claude Sonnet 4.6 --- lib/core/sync/sync_repository.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/core/sync/sync_repository.dart b/lib/core/sync/sync_repository.dart index eaa6627..1edb0bf 100644 --- a/lib/core/sync/sync_repository.dart +++ b/lib/core/sync/sync_repository.dart @@ -14,6 +14,7 @@ class SyncRepository { final res = await _client.post( '/bookmarks', body: { + 'book': b.bookId, 'bookId': b.bookId, 'chapter': b.chapter, 'verseStart': b.verseStart, @@ -27,6 +28,7 @@ class SyncRepository { Future updateBookmark(String remoteId, Bookmark b) => _client.put( '/bookmarks/$remoteId', body: { + 'book': b.bookId, 'bookId': b.bookId, 'chapter': b.chapter, 'verseStart': b.verseStart, @@ -74,6 +76,7 @@ class SyncRepository { final res = await _client.post( '/highlights', body: { + 'book': h.bookId, 'bookId': h.bookId, 'chapter': h.chapter, 'verseStart': h.verseStart, @@ -122,11 +125,13 @@ class SyncRepository { throw Exception('Cannot parse remote id for $key'); } + // Maps local ARGB palette to the backend's accepted color names: + // yellow | green | pink | blue | red | purple static const _colorNames = { 0xFFFFEB3B: 'yellow', - 0xFF80CBC4: 'teal', + 0xFF80CBC4: 'green', // teal → closest accepted name 0xFF90CAF9: 'blue', - 0xFFEF9A9A: 'red', + 0xFFEF9A9A: 'pink', // pink-red → pink 0xFFCE93D8: 'purple', }; From c7365b60658fe379c1ebd4deb1e722133c2456c3 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 19 May 2026 18:09:27 +0300 Subject: [PATCH 13/33] feat: implement user authentication strings and update login/register screens --- android/app/google-services.json | 1 + lib/core/auth/auth_repository.dart | 2 +- lib/core/l10n/am_strings.dart | 53 +++++++++++++++ lib/core/l10n/app_strings.dart | 52 +++++++++++++++ lib/core/l10n/en_strings.dart | 53 +++++++++++++++ .../auth/presentation/pages/login_screen.dart | 41 ++++++------ .../auth/presentation/pages/otp_screen.dart | 27 ++++---- .../presentation/pages/register_screen.dart | 64 ++++++++----------- 8 files changed, 220 insertions(+), 73 deletions(-) create mode 100644 android/app/google-services.json diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..162535a --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1 @@ +{"installed":{"client_id":"633243120991-l5fk4j564cqvi334ablo9o4s3vocnpig.apps.googleusercontent.com","project_id":"eotc-bible","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs"}} \ No newline at end of file diff --git a/lib/core/auth/auth_repository.dart b/lib/core/auth/auth_repository.dart index ba39ed7..e253c2a 100644 --- a/lib/core/auth/auth_repository.dart +++ b/lib/core/auth/auth_repository.dart @@ -3,7 +3,7 @@ import '../api/api_client.dart'; import 'user_profile.dart'; const _webClientId = - '633243120991-53mhcrmdns9ngi06hj2f1gj5ja7t6n1p.apps.googleusercontent.com'; + '1088248193729-ttstc3b946pk4fl9s02n2hm4mjqc4mh9.apps.googleusercontent.com'; class AuthRepository { const AuthRepository(this._api); diff --git a/lib/core/l10n/am_strings.dart b/lib/core/l10n/am_strings.dart index 1ec54b0..ad7c338 100644 --- a/lib/core/l10n/am_strings.dart +++ b/lib/core/l10n/am_strings.dart @@ -210,4 +210,57 @@ class AmStrings extends AppStrings { String get verseShare => 'አጋራ'; @override String get comingSoon => 'በቅርቡ ይመጣል'; + + // ── Auth — shared ───────────────────────────────────────────────────────── + @override String get authEmail => 'ኢሜል'; + @override String get authEmailRequired => 'ኢሜል ያስፈልጋል'; + @override String get authEmailInvalid => 'ትክክለኛ ኢሜል ያስገቡ'; + @override String get authPassword => 'የይለፍ ቃል'; + @override String get authPasswordRequired => 'የይለፍ ቃል ያስፈልጋል'; + @override String get authConnectionError => 'ግንኙነት አልተሳካም። ድጋሚ ይሞክሩ።'; + + // ── Auth — login ────────────────────────────────────────────────────────── + @override String get loginTitle => 'እንኳን ደህና መጡ'; + @override String get loginSubtitle => 'መጽሐፍ ቅዱስ ማንበብ ለመቀጠል ይግቡ።'; + @override String get loginRememberMe => 'አስታወስኝ'; + @override String get loginForgotPassword => 'የይለፍ ቃል ረሳህ?'; + @override String get loginButton => 'ግባ'; + @override String get loginOrDivider => 'ወይም በነዚህ ይግቡ'; + @override String get loginNoAccount => 'አካውንት የለዎትም? '; + @override String get loginRegisterLink => 'ይ​መዝ​ገቡ'; + @override String get loginVerseQuote => 'ቃልህ ለመንገዴ ብርሃን ነው'; + @override String get loginAccountLocked => 'መለያዎ ተቆልፏል። ከ2 ሰዓት በኋላ ይሞክሩ።'; + @override String get loginGoogleFailed => 'Google ግባ አልተሳካም። ድጋሚ ይሞክሩ።'; + @override String get loginFacebookComingSoon => 'Facebook Sign In — በቅርብ ይመጣል'; + + // ── Auth — register ─────────────────────────────────────────────────────── + @override String get registerTitle => 'አካውንት ይፍጠሩ'; + @override String get registerSubtitle => 'የ ንባብ ሂደቶን፣ ኖቶቹን፣ እና ቀለሞችን '; + @override String get registerFullName => 'ሙሉ ስም'; + @override String get registerFullNameRequired => 'ሙሉ ስም ያስፈልጋል'; + @override String get registerFullNameTooShort => 'ስም ቢያንስ 2 ፊደላት ያስፈልጋሉ'; + @override String get registerPhone => 'ስልክ ቁጥር'; + @override String get registerPasswordTooShort => 'ቢያንስ 8 ቁምፊዎች ያስፈልጋሉ'; + @override String get registerAcceptTerms => 'ውሎቹን እና ሁኔታዎቹን ይቀበሉ'; + @override String get registerButton => 'ይ​መዝ​ጋቡ'; + @override String get registerHaveAccount => 'መለያ አለዎት? '; + @override String get registerLoginLink => 'ይ​ግቡ'; + @override String get registerTermsText => 'I accept the Community Terms and Privacy Policy.'; + @override String get passwordWeak => 'ደካማ'; + @override String get passwordFair => 'መካከለኛ'; + @override String get passwordGood => 'ጥሩ'; + @override String get passwordStrong => 'ጠንካራ'; + + // ── Auth — OTP ──────────────────────────────────────────────────────────── + @override String get otpTitle => 'ኮድዎን ያረጋግጡ'; + @override String get otpSentPrefix => 'ወደ '; + @override String get otpSentSuffix => ' 6 አሃዝ ኮድ ልከናል። አባክዎ ከታች ያስገቡ።'; + @override String otpDigitsRequired(int n) => 'የ$n ቁጥር ኮድ ያስፈልጋል'; + @override String get otpNotReceived => 'ኮድ አልደረሰዎትም? '; + @override String otpResendIn(String t) => 'ድጋሚ ላክ $t'; + @override String get otpResend => 'ድጋሚ ላክ'; + @override String get otpVerifyButton => 'አረጋጥ'; + @override String get otpChangePhone => 'ስልክ ቁጥር ለውጥ'; + @override String get otpChangeEmail => 'ኢሜል ለውጥ'; + @override String get otpResendFailed => 'ኮድ መላክ አልተሳካም። ድጋሚ ይሞክሩ።'; } diff --git a/lib/core/l10n/app_strings.dart b/lib/core/l10n/app_strings.dart index c48f4f0..9fa6e5b 100644 --- a/lib/core/l10n/app_strings.dart +++ b/lib/core/l10n/app_strings.dart @@ -128,4 +128,56 @@ String get searchScopeTitle; String get verseCopy; String get verseShare; String get comingSoon; + + // ── Auth — shared ───────────────────────────────────────────────────────── + String get authEmail; + String get authEmailRequired; + String get authEmailInvalid; + String get authPassword; + String get authPasswordRequired; + String get authConnectionError; + + // ── Auth — login ────────────────────────────────────────────────────────── + String get loginTitle; + String get loginSubtitle; + String get loginRememberMe; + String get loginForgotPassword; + String get loginButton; + String get loginOrDivider; + String get loginNoAccount; + String get loginRegisterLink; + String get loginVerseQuote; + String get loginAccountLocked; + String get loginGoogleFailed; + String get loginFacebookComingSoon; + + // ── Auth — register ─────────────────────────────────────────────────────── + String get registerTitle; + String get registerSubtitle; + String get registerFullName; + String get registerFullNameRequired; + String get registerFullNameTooShort; + String get registerPasswordTooShort; + String get registerAcceptTerms; + String get registerButton; + String get registerHaveAccount; + String get registerLoginLink; + String get registerTermsText; + String get passwordWeak; + String get passwordFair; + String get passwordGood; + String get passwordStrong; + + // ── Auth — OTP ──────────────────────────────────────────────────────────── + String get otpTitle; + String get otpSentPrefix; + String get otpSentSuffix; + String otpDigitsRequired(int n); + String get otpNotReceived; + String otpResendIn(String t); + String get otpResend; + String get otpVerifyButton; + String get otpChangePhone; + String get otpChangeEmail; + String get otpResendFailed; } diff --git a/lib/core/l10n/en_strings.dart b/lib/core/l10n/en_strings.dart index 4964e8f..e2f6058 100644 --- a/lib/core/l10n/en_strings.dart +++ b/lib/core/l10n/en_strings.dart @@ -120,4 +120,57 @@ class EnStrings extends AppStrings { @override String get verseCopy => 'Copy'; @override String get verseShare => 'Share'; @override String get comingSoon => 'Coming soon'; + + // ── Auth — shared ───────────────────────────────────────────────────────── + @override String get authEmail => 'Email'; + @override String get authEmailRequired => 'Email is required'; + @override String get authEmailInvalid => 'Enter a valid email'; + @override String get authPassword => 'Password'; + @override String get authPasswordRequired => 'Password is required'; + @override String get authConnectionError => 'Connection failed. Please try again.'; + + // ── Auth — login ────────────────────────────────────────────────────────── + @override String get loginTitle => 'Welcome Back'; + @override String get loginSubtitle => 'Sign in to continue reading the Holy Word.'; + @override String get loginRememberMe => 'Remember me'; + @override String get loginForgotPassword => 'Forgot password?'; + @override String get loginButton => 'Sign In'; + @override String get loginOrDivider => 'Or sign in with'; + @override String get loginNoAccount => "Don't have an account? "; + @override String get loginRegisterLink => 'Register'; + @override String get loginVerseQuote => 'Your word is a lamp forever'; + @override String get loginAccountLocked => 'Account locked. Try again in 2 hours.'; + @override String get loginGoogleFailed => 'Google sign-in failed. Try again.'; + @override String get loginFacebookComingSoon => 'Facebook Sign In — Coming soon'; + + // ── Auth — register ─────────────────────────────────────────────────────── + @override String get registerTitle => 'Create Account'; + @override String get registerSubtitle => 'Save your progress, highlights, and notes.'; + @override String get registerFullName => 'Full Name'; + @override String get registerFullNameRequired => 'Full name is required'; + @override String get registerFullNameTooShort => 'Name must be at least 2 characters'; + @override String get registerPhone => 'Phone Number'; + @override String get registerPasswordTooShort => 'At least 8 characters required'; + @override String get registerAcceptTerms => 'Please accept the terms and conditions'; + @override String get registerButton => 'Register'; + @override String get registerHaveAccount => 'Already have an account? '; + @override String get registerLoginLink => 'Sign In'; + @override String get registerTermsText => 'I accept the Community Terms and Privacy Policy.'; + @override String get passwordWeak => 'Weak'; + @override String get passwordFair => 'Fair'; + @override String get passwordGood => 'Good'; + @override String get passwordStrong => 'Strong'; + + // ── Auth — OTP ──────────────────────────────────────────────────────────── + @override String get otpTitle => 'Verify Your Code'; + @override String get otpSentPrefix => 'We sent a 6-digit code to '; + @override String get otpSentSuffix => '. Enter it below.'; + @override String otpDigitsRequired(int n) => '$n-digit code required'; + @override String get otpNotReceived => "Didn't receive the code? "; + @override String otpResendIn(String t) => 'Resend in $t'; + @override String get otpResend => 'Resend'; + @override String get otpVerifyButton => 'Verify'; + @override String get otpChangePhone => 'Change phone number'; + @override String get otpChangeEmail => 'Change email'; + @override String get otpResendFailed => 'Failed to resend. Try again.'; } diff --git a/lib/features/auth/presentation/pages/login_screen.dart b/lib/features/auth/presentation/pages/login_screen.dart index ff8756b..ef4dcb5 100644 --- a/lib/features/auth/presentation/pages/login_screen.dart +++ b/lib/features/auth/presentation/pages/login_screen.dart @@ -46,11 +46,11 @@ class _LoginScreenState extends ConsumerState { } on ApiException catch (e) { setState(() { _errorMessage = e.isAccountLocked - ? 'መለያዎ ተቆልፏል። ከ2 ሰዓት በኋላ ይሞክሩ።' + ? L10n.of(context).loginAccountLocked : e.message; }); } catch (_) { - setState(() => _errorMessage = 'ግንኙነት አልተሳካም። ድጋሚ ይሞክሩ።'); + setState(() => _errorMessage = L10n.of(context).authConnectionError); } finally { if (mounted) setState(() => _isLoading = false); } @@ -64,7 +64,7 @@ class _LoginScreenState extends ConsumerState { } on ApiException catch (e) { setState(() => _errorMessage = e.message); } catch (_) { - setState(() => _errorMessage = 'Google ግባ አልተሳካም። ድጋሚ ይሞክሩ።'); + setState(() => _errorMessage = L10n.of(context).loginGoogleFailed); } finally { if (mounted) setState(() => _isGoogleLoading = false); } @@ -74,7 +74,7 @@ class _LoginScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - 'Facebook Sign In — በቅርብ ይመጣል', + L10n.of(context).loginFacebookComingSoon, style: AppTypography.amharicCaption.copyWith(color: Colors.white), ), backgroundColor: context.colors.primary, @@ -113,21 +113,21 @@ class _LoginScreenState extends ConsumerState { children: [ _InputField( controller: _emailCtrl, - label: 'ኢሜል', + label: s.authEmail, hint: 'nehmia@bible.app', prefixIcon: Icons.email_outlined, keyboardType: TextInputType.emailAddress, enabled: !isLoading, validator: (v) { - if (v == null || v.trim().isEmpty) return 'ኢሜል ያስፈልጋል'; - if (!v.contains('@')) return 'ትክክለኛ ኢሜል ያስፈልጋል'; + if (v == null || v.trim().isEmpty) return s.authEmailRequired; + if (!v.contains('@')) return s.authEmailInvalid; return null; }, ), const SizedBox(height: 16), _InputField( controller: _passwordCtrl, - label: 'የይለፍ ቃል', + label: s.authPassword, hint: '••••••••', prefixIcon: Icons.lock_outline_rounded, obscureText: _obscurePassword, @@ -144,9 +144,7 @@ class _LoginScreenState extends ConsumerState { () => _obscurePassword = !_obscurePassword), ), validator: (v) { - if (v == null || v.isEmpty) { - return 'የይለፍ ቃል ያስፈልጋል'; - } + if (v == null || v.isEmpty) return s.authPasswordRequired; return null; }, ), @@ -168,7 +166,7 @@ class _LoginScreenState extends ConsumerState { ], const SizedBox(height: 20), _PrimaryButton( - label: 'ግባ', + label: s.loginButton, isLoading: _isLoading, onTap: isLoading ? null : _login, ), @@ -258,7 +256,7 @@ class _AppHeader extends StatelessWidget { child: Row( children: [ Text( - isAmharic ? 'አማርኛ' : 'English', + isAmharic ? s.langAmharic : s.langEnglish, style: AppTypography.amharicCaption.copyWith( color: c.textMuted, fontSize: 12, @@ -283,12 +281,13 @@ class _TitleSection extends StatelessWidget { @override Widget build(BuildContext context) { + final s = L10n.of(context); final c = colors; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'እንኳን ደህና መጡ', + s.loginTitle, style: AppTypography.amharicDisplay.copyWith( color: c.textOnParchment, fontSize: 32, @@ -296,7 +295,7 @@ class _TitleSection extends StatelessWidget { ), const SizedBox(height: 6), Text( - 'ቅዱስ ቃልን ንዝምን ለመቀጠል ይግቡ።', + s.loginSubtitle, style: AppTypography.amharicBody.copyWith( color: c.textMuted, fontSize: 14, @@ -440,7 +439,7 @@ class _RememberForgotRow extends StatelessWidget { ), const SizedBox(width: 8), Text( - 'አስታወስኝ', + L10n.of(context).loginRememberMe, style: AppTypography.amharicCaption.copyWith( color: c.textMuted, fontSize: 12, @@ -453,7 +452,7 @@ class _RememberForgotRow extends StatelessWidget { GestureDetector( onTap: enabled ? onForgotTap : null, child: Text( - 'የይለፍ ቃል ረሳህ?', + L10n.of(context).loginForgotPassword, style: AppTypography.amharicCaption.copyWith( color: c.primary, fontWeight: FontWeight.w700, @@ -577,7 +576,7 @@ class _OrDivider extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 14), child: Text( - 'ወይም ቤዝህ ይግቡ', + L10n.of(context).loginOrDivider, style: AppTypography.amharicCaption.copyWith( color: c.textCaption, fontSize: 11, @@ -761,7 +760,7 @@ class _RegisterPrompt extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - 'አካውንት የለዎትም? ', + L10n.of(context).loginNoAccount, style: AppTypography.amharicCaption.copyWith( color: c.textMuted, fontSize: 12), ), @@ -773,7 +772,7 @@ class _RegisterPrompt extends StatelessWidget { ) : null, child: Text( - 'ይ​መዝ​ጋቡ', + L10n.of(context).loginRegisterLink, style: AppTypography.amharicCaption.copyWith( color: c.primary, fontWeight: FontWeight.w700, @@ -802,7 +801,7 @@ class _VerseQuote extends StatelessWidget { style: TextStyle(color: c.accentDeep, fontSize: 12)), const SizedBox(width: 8), Text( - 'ቃልህ ለምንጊዜም ብርሃን ነው', + L10n.of(context).loginVerseQuote, style: AppTypography.amharicCaption.copyWith( color: c.textCaption, fontSize: 11, diff --git a/lib/features/auth/presentation/pages/otp_screen.dart b/lib/features/auth/presentation/pages/otp_screen.dart index 0dfa3b0..38c7af2 100644 --- a/lib/features/auth/presentation/pages/otp_screen.dart +++ b/lib/features/auth/presentation/pages/otp_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/api/api_client.dart'; import '../../../../core/auth/auth_state.dart'; import '../../../../core/constants/app_icons.dart'; +import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/app_color_scheme.dart'; import '../../../../core/theme/app_typography.dart'; @@ -103,7 +104,7 @@ class _OtpScreenState extends ConsumerState { Future _verify() async { final otp = _otp; if (otp.length < _otpLength) { - setState(() => _errorMessage = 'የ$_otpLength ቁጥር ኮድ ያስፈልጋል'); + setState(() => _errorMessage = L10n.of(context).otpDigitsRequired(_otpLength)); return; } setState(() { _isLoading = true; _errorMessage = null; }); @@ -114,7 +115,7 @@ class _OtpScreenState extends ConsumerState { setState(() => _errorMessage = e.message); _clearOtp(); } catch (_) { - setState(() => _errorMessage = 'ግንኙነት አልተሳካም። ድጋሚ ይሞክሩ።'); + setState(() => _errorMessage = L10n.of(context).authConnectionError); } finally { if (mounted) setState(() => _isLoading = false); } @@ -130,7 +131,7 @@ class _OtpScreenState extends ConsumerState { } on ApiException catch (e) { setState(() => _errorMessage = e.message); } catch (_) { - setState(() => _errorMessage = 'ኮድ መላክ አልተሳካም። ድጋሚ ይሞክሩ።'); + setState(() => _errorMessage = L10n.of(context).otpResendFailed); } finally { if (mounted) setState(() => _isResending = false); } @@ -150,6 +151,7 @@ class _OtpScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final s = L10n.of(context); final c = context.colors; final otpFilled = _otp.length == _otpLength; @@ -165,7 +167,7 @@ class _OtpScreenState extends ConsumerState { _BackButton(), const SizedBox(height: 24), Text( - 'ኮድዎን ያረጋግጡ', + s.otpTitle, style: AppTypography.amharicDisplay.copyWith( color: c.textOnParchment, fontSize: 30, @@ -180,15 +182,14 @@ class _OtpScreenState extends ConsumerState { height: 1.6, ), children: [ - const TextSpan(text: 'ወደ '), + TextSpan(text: s.otpSentPrefix), TextSpan( text: _maskedContact, style: TextStyle( color: c.textOnParchment, fontWeight: FontWeight.w700), ), - const TextSpan( - text: ' 6 አሃዝ ኮድ ልከናል። አባክዎ ከታች ያስገቡ።'), + TextSpan(text: s.otpSentSuffix), ], ), ), @@ -216,7 +217,7 @@ class _OtpScreenState extends ConsumerState { ), const SizedBox(height: 28), _PrimaryButton( - label: 'አረጋጥ', + label: s.otpVerifyButton, isLoading: _isLoading, onTap: (!otpFilled || _isLoading) ? null : _verify, ), @@ -232,8 +233,8 @@ class _OtpScreenState extends ConsumerState { const SizedBox(width: 6), Text( widget.phone != null && widget.phone!.isNotEmpty - ? 'ስልክ ቁጥር ለውጥ' - : 'ኢሜል ለውጥ', + ? s.otpChangePhone + : s.otpChangeEmail, style: AppTypography.amharicCaption.copyWith( color: c.textMuted, fontSize: 12, @@ -375,13 +376,13 @@ class _ResendRow extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - 'ኮድ አልደረሰዎትም? ', + L10n.of(context).otpNotReceived, style: AppTypography.amharicCaption.copyWith( color: c.textMuted, fontSize: 12), ), if (countdown > 0) Text( - 'ደጎሚ ሊክ ቤ $countdownLabel', + L10n.of(context).otpResendIn(countdownLabel), style: AppTypography.amharicCaption.copyWith( color: c.textCaption, fontSize: 12, @@ -398,7 +399,7 @@ class _ResendRow extends StatelessWidget { strokeWidth: 1.5, color: c.primary), ) : Text( - 'ደጎሚ ሊክ', + L10n.of(context).otpResend, style: AppTypography.amharicCaption.copyWith( color: c.primary, fontWeight: FontWeight.w700, diff --git a/lib/features/auth/presentation/pages/register_screen.dart b/lib/features/auth/presentation/pages/register_screen.dart index e379f15..ee0853f 100644 --- a/lib/features/auth/presentation/pages/register_screen.dart +++ b/lib/features/auth/presentation/pages/register_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/api/api_client.dart'; import '../../../../core/auth/auth_state.dart'; +import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/app_color_scheme.dart'; import '../../../../core/theme/app_typography.dart'; import 'login_screen.dart'; @@ -18,7 +19,6 @@ class _RegisterScreenState extends ConsumerState { final _formKey = GlobalKey(); final _nameCtrl = TextEditingController(); final _emailCtrl = TextEditingController(); - final _phoneCtrl = TextEditingController(); final _passwordCtrl = TextEditingController(); bool _obscurePassword = true; @@ -31,7 +31,6 @@ class _RegisterScreenState extends ConsumerState { void dispose() { _nameCtrl.dispose(); _emailCtrl.dispose(); - _phoneCtrl.dispose(); _passwordCtrl.dispose(); super.dispose(); } @@ -46,12 +45,12 @@ class _RegisterScreenState extends ConsumerState { return score; } - String _strengthLabel(int s) { + String _strengthLabel(int s, AppStrings strings) { switch (s) { - case 1: return 'ደካማ'; - case 2: return 'መካከለኛ'; - case 3: return 'ጥሩ'; - case 4: return 'ጠንካራ'; + case 1: return strings.passwordWeak; + case 2: return strings.passwordFair; + case 3: return strings.passwordGood; + case 4: return strings.passwordStrong; default: return ''; } } @@ -69,7 +68,7 @@ class _RegisterScreenState extends ConsumerState { Future _register() async { if (!_formKey.currentState!.validate()) return; if (!_acceptedTerms) { - setState(() => _errorMessage = 'ውሎቹን እና ሁኔታዎቹን ይቀበሉ'); + setState(() => _errorMessage = L10n.of(context).registerAcceptTerms); return; } setState(() { _isLoading = true; _errorMessage = null; }); @@ -83,17 +82,14 @@ class _RegisterScreenState extends ConsumerState { Navigator.pushReplacement( context, MaterialPageRoute( - builder: (_) => OtpScreen( - email: _emailCtrl.text.trim(), - phone: _phoneCtrl.text.trim(), - ), + builder: (_) => OtpScreen(email: _emailCtrl.text.trim()), ), ); } } on ApiException catch (e) { setState(() => _errorMessage = e.message); } catch (_) { - setState(() => _errorMessage = 'ግንኙነት አልተሳካም። ድጋሚ ይሞክሩ።'); + setState(() => _errorMessage = L10n.of(context).authConnectionError); } finally { if (mounted) setState(() => _isLoading = false); } @@ -101,6 +97,7 @@ class _RegisterScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final s = L10n.of(context); final c = context.colors; return Scaffold( @@ -115,7 +112,7 @@ class _RegisterScreenState extends ConsumerState { _BackButton(), const SizedBox(height: 24), Text( - 'አካውንት ይፍጠሩ', + s.registerTitle, style: AppTypography.amharicDisplay.copyWith( color: c.textOnParchment, fontSize: 30, @@ -123,7 +120,7 @@ class _RegisterScreenState extends ConsumerState { ), const SizedBox(height: 6), Text( - 'የጎልብ ሂደትዎን፣ ምልክቶቾዎን እና ማስታወሻዎቾዎን ያስቀምጡ።', + s.registerSubtitle, style: AppTypography.amharicBody.copyWith( color: c.textMuted, fontSize: 13, @@ -140,43 +137,34 @@ class _RegisterScreenState extends ConsumerState { children: [ _InputField( controller: _nameCtrl, - label: 'ሙሉ ስም', + label: s.registerFullName, hint: 'ነህምያ ተስፋዬ', prefixIcon: Icons.person_outline_rounded, enabled: !_isLoading, validator: (v) { - if (v == null || v.trim().isEmpty) return 'ሙሉ ስም ያስፈልጋል'; - if (v.trim().length < 2) return 'ስም ቢያንስ 2 ፊደላት ያስፈልጋሉ'; + if (v == null || v.trim().isEmpty) return s.registerFullNameRequired; + if (v.trim().length < 2) return s.registerFullNameTooShort; return null; }, ), const SizedBox(height: 16), _InputField( controller: _emailCtrl, - label: 'ኢሜል', + label: s.authEmail, hint: 'nehmia@bible.app', prefixIcon: Icons.email_outlined, keyboardType: TextInputType.emailAddress, enabled: !_isLoading, validator: (v) { - if (v == null || v.trim().isEmpty) return 'ኢሜል ያስፈልጋል'; - if (!v.contains('@')) return 'ትክክለኛ ኢሜል ያስፈልጋል'; + if (v == null || v.trim().isEmpty) return s.authEmailRequired; + if (!v.contains('@')) return s.authEmailInvalid; return null; }, ), const SizedBox(height: 16), - _InputField( - controller: _phoneCtrl, - label: 'ስልክ ቁጥር', - hint: '+251 911 234 567', - prefixIcon: Icons.phone_outlined, - keyboardType: TextInputType.phone, - enabled: !_isLoading, - ), - const SizedBox(height: 16), _InputField( controller: _passwordCtrl, - label: 'የይለፍ ቃል', + label: s.authPassword, hint: '••••••••', prefixIcon: Icons.lock_outline_rounded, obscureText: _obscurePassword, @@ -195,8 +183,8 @@ class _RegisterScreenState extends ConsumerState { () => _obscurePassword = !_obscurePassword), ), validator: (v) { - if (v == null || v.isEmpty) return 'የይለፍ ቃል ያስፈልጋል'; - if (v.length < 8) return 'ቢያንስ 8 ቁምፊዎች ያስፈልጋሉ'; + if (v == null || v.isEmpty) return s.authPasswordRequired; + if (v.length < 8) return s.registerPasswordTooShort; return null; }, ), @@ -204,7 +192,7 @@ class _RegisterScreenState extends ConsumerState { const SizedBox(height: 8), _PasswordStrengthBar( strength: _passwordStrength, - label: _strengthLabel(_passwordStrength), + label: _strengthLabel(_passwordStrength, s), color: _strengthColor(_passwordStrength), ), ], @@ -225,7 +213,7 @@ class _RegisterScreenState extends ConsumerState { ], const SizedBox(height: 24), _PrimaryButton( - label: 'ይ​መዝ​ጋቡ', + label: s.registerButton, isLoading: _isLoading, onTap: _isLoading ? null : _register, ), @@ -234,7 +222,7 @@ class _RegisterScreenState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - 'መለያ አለዎት? ', + s.registerHaveAccount, style: AppTypography.amharicCaption.copyWith( color: c.textMuted, fontSize: 12), ), @@ -247,7 +235,7 @@ class _RegisterScreenState extends ConsumerState { builder: (_) => const LoginScreen()), ), child: Text( - 'ይ​ግቡ', + s.registerLoginLink, style: AppTypography.amharicCaption.copyWith( color: c.primary, fontWeight: FontWeight.w700, @@ -522,7 +510,7 @@ class _TermsRow extends StatelessWidget { const SizedBox(width: 10), Expanded( child: Text( - 'የኅብረተሰቡ ወሎቹን እና የግላዊነት ፖሊሲን ተቀብያለሁ።', + L10n.of(context).registerTermsText, style: AppTypography.amharicCaption.copyWith( color: c.textMuted, fontSize: 12, From 06f1dd170064ace562edc76ced6190c4b1a5d29b Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 19 May 2026 18:10:18 +0300 Subject: [PATCH 14/33] fix: remove phone number field from registration strings in AmStrings and EnStrings --- lib/core/l10n/am_strings.dart | 1 - lib/core/l10n/en_strings.dart | 1 - 2 files changed, 2 deletions(-) diff --git a/lib/core/l10n/am_strings.dart b/lib/core/l10n/am_strings.dart index ad7c338..6c3577e 100644 --- a/lib/core/l10n/am_strings.dart +++ b/lib/core/l10n/am_strings.dart @@ -239,7 +239,6 @@ class AmStrings extends AppStrings { @override String get registerFullName => 'ሙሉ ስም'; @override String get registerFullNameRequired => 'ሙሉ ስም ያስፈልጋል'; @override String get registerFullNameTooShort => 'ስም ቢያንስ 2 ፊደላት ያስፈልጋሉ'; - @override String get registerPhone => 'ስልክ ቁጥር'; @override String get registerPasswordTooShort => 'ቢያንስ 8 ቁምፊዎች ያስፈልጋሉ'; @override String get registerAcceptTerms => 'ውሎቹን እና ሁኔታዎቹን ይቀበሉ'; @override String get registerButton => 'ይ​መዝ​ጋቡ'; diff --git a/lib/core/l10n/en_strings.dart b/lib/core/l10n/en_strings.dart index e2f6058..b035358 100644 --- a/lib/core/l10n/en_strings.dart +++ b/lib/core/l10n/en_strings.dart @@ -149,7 +149,6 @@ class EnStrings extends AppStrings { @override String get registerFullName => 'Full Name'; @override String get registerFullNameRequired => 'Full name is required'; @override String get registerFullNameTooShort => 'Name must be at least 2 characters'; - @override String get registerPhone => 'Phone Number'; @override String get registerPasswordTooShort => 'At least 8 characters required'; @override String get registerAcceptTerms => 'Please accept the terms and conditions'; @override String get registerButton => 'Register'; From 61452cbfa48eab803ba6540bab6c4ed0fbc36ef2 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Wed, 20 May 2026 13:45:45 +0300 Subject: [PATCH 15/33] fix: correct Google OAuth client ID and add server pull on login - Fix wrong webClientId in AuthRepository (was pointing to a different Google Cloud project entirely, preventing idToken from being issued) - Add pullAll() to SyncService and wire it before syncAll() on auth - Add upsertServer* methods to AppDatabase for conflict-safe pulls - Add fetch methods to SyncRepository to pull bookmarks/highlights/notes - Trigger pullAll in SavedScreen on open when authenticated - Align highlight palette colors with backend accepted color names Co-Authored-By: Claude Sonnet 4.6 --- lib/core/annotations/annotation_models.dart | 11 +- lib/core/auth/auth_repository.dart | 2 +- lib/core/auth/auth_state.dart | 5 +- lib/core/storage/app_database.dart | 44 ++++++++ lib/core/sync/sync_repository.dart | 102 ++++++++++++++++-- lib/core/sync/sync_service.dart | 27 +++++ .../presentation/pages/saved_screen.dart | 11 ++ 7 files changed, 187 insertions(+), 15 deletions(-) diff --git a/lib/core/annotations/annotation_models.dart b/lib/core/annotations/annotation_models.dart index af9a232..01b137a 100644 --- a/lib/core/annotations/annotation_models.dart +++ b/lib/core/annotations/annotation_models.dart @@ -3,11 +3,12 @@ import 'package:flutter/material.dart'; enum SyncStatus { pendingCreate, pendingUpdate, pendingDelete, synced } const highlightPalette = [ - Color(0xFFFFEB3B), // yellow - Color(0xFF80CBC4), // teal - Color(0xFF90CAF9), // sky blue - Color(0xFFEF9A9A), // pink-red - Color(0xFFCE93D8), // purple + Color(0xFFFFE062), // yellow + Color(0xFF3BAD49), // green + Color(0xFFFF4B26), // pink + Color(0xFF5778C5), // blue + Color(0xFFB61F21), // red + Color(0xFF704A6A), // purple ]; // ── Bookmark ────────────────────────────────────────────────────────────────── diff --git a/lib/core/auth/auth_repository.dart b/lib/core/auth/auth_repository.dart index e253c2a..7a770f6 100644 --- a/lib/core/auth/auth_repository.dart +++ b/lib/core/auth/auth_repository.dart @@ -3,7 +3,7 @@ import '../api/api_client.dart'; import 'user_profile.dart'; const _webClientId = - '1088248193729-ttstc3b946pk4fl9s02n2hm4mjqc4mh9.apps.googleusercontent.com'; + '633243120991-2fj8v1g9ree4dkgklqe7613sdm6e6idc.apps.googleusercontent.com'; class AuthRepository { const AuthRepository(this._api); diff --git a/lib/core/auth/auth_state.dart b/lib/core/auth/auth_state.dart index f79acea..90ddcf9 100644 --- a/lib/core/auth/auth_state.dart +++ b/lib/core/auth/auth_state.dart @@ -108,10 +108,11 @@ class AuthNotifier extends StateNotifier { } void _syncAfterAuth(String token) { - SyncService( + final svc = SyncService( db: _ref.read(appDatabaseProvider), repo: SyncRepository(_ref.read(apiClientProvider), token), - ).syncAll(); + ); + svc.pullAll().then((_) => svc.syncAll()); } Future logout() async { diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index a22e8ce..26f83e7 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -493,6 +493,50 @@ class AppDatabase { return rows.map(Highlight.fromMap).toList(); } + // ── Server pull upserts ──────────────────────────────────────────────────── + + Future upsertServerBookmarks(List items) async { + if (items.isEmpty) return; + final db = await database; + final knownIds = (await db.query('bookmarks', columns: ['remote_id'])) + .map((r) => r['remote_id'] as String?) + .whereType() + .toSet(); + for (final b in items) { + if (b.remoteId == null || knownIds.contains(b.remoteId)) continue; + await db.insert('bookmarks', b.toMap(), + conflictAlgorithm: ConflictAlgorithm.ignore); + } + } + + Future upsertServerHighlights(List items) async { + if (items.isEmpty) return; + final db = await database; + final knownIds = (await db.query('highlights', columns: ['remote_id'])) + .map((r) => r['remote_id'] as String?) + .whereType() + .toSet(); + for (final h in items) { + if (h.remoteId == null || knownIds.contains(h.remoteId)) continue; + await db.insert('highlights', h.toMap(), + conflictAlgorithm: ConflictAlgorithm.ignore); + } + } + + Future upsertServerNotes(List items) async { + if (items.isEmpty) return; + final db = await database; + final knownIds = (await db.query('notes', columns: ['remote_id'])) + .map((r) => r['remote_id'] as String?) + .whereType() + .toSet(); + for (final n in items) { + if (n.remoteId == null || knownIds.contains(n.remoteId)) continue; + await db.insert('notes', n.toMap(), + conflictAlgorithm: ConflictAlgorithm.ignore); + } + } + Future close() async { await _db?.close(); _db = null; diff --git a/lib/core/sync/sync_repository.dart b/lib/core/sync/sync_repository.dart index 1edb0bf..3e23a77 100644 --- a/lib/core/sync/sync_repository.dart +++ b/lib/core/sync/sync_repository.dart @@ -125,16 +125,104 @@ class SyncRepository { throw Exception('Cannot parse remote id for $key'); } - // Maps local ARGB palette to the backend's accepted color names: - // yellow | green | pink | blue | red | purple + // Maps local ARGB palette to the backend's accepted color names. static const _colorNames = { - 0xFFFFEB3B: 'yellow', - 0xFF80CBC4: 'green', // teal → closest accepted name - 0xFF90CAF9: 'blue', - 0xFFEF9A9A: 'pink', // pink-red → pink - 0xFFCE93D8: 'purple', + 0xFFFFE062: 'yellow', + 0xFF3BAD49: 'green', + 0xFFFF4B26: 'pink', + 0xFF5778C5: 'blue', + 0xFFB61F21: 'red', + 0xFF704A6A: 'purple', + }; + + static const _nameToColor = { + 'yellow': Color(0xFFFFE062), + 'green': Color(0xFF3BAD49), + 'pink': Color(0xFFFF4B26), + 'blue': Color(0xFF5778C5), + 'red': Color(0xFFB61F21), + 'purple': Color(0xFF704A6A), }; static String _colorName(Color color) => _colorNames[color.toARGB32()] ?? 'yellow'; + + static Color _colorFromName(String name) => + _nameToColor[name] ?? const Color(0xFFFFE062); + + // ── Fetch (pull from server) ──────────────────────────────────────────────── + + Future> fetchBookmarks() async { + final res = await _client.get('/bookmarks', token: _token); + final data = res['data']; + final list = (data is Map ? data['data'] : data) as List? ?? []; + final now = DateTime.now(); + return list.whereType>().map((m) { + final bookId = (m['book'] ?? m['bookId'] ?? '') as String; + return Bookmark( + bookId: bookId, + bookNumber: 0, + chapter: (m['chapter'] as num).toInt(), + verseStart: (m['verseStart'] as num).toInt(), + verseCount: (m['verseCount'] as num?)?.toInt() ?? 1, + createdAt: _parseDate(m['createdAt']) ?? now, + updatedAt: _parseDate(m['updatedAt']) ?? now, + syncStatus: SyncStatus.synced, + remoteId: m['_id'] as String?, + ); + }).toList(); + } + + Future> fetchHighlights() async { + final res = await _client.get('/highlights', token: _token); + final data = res['data']; + final list = (data is Map ? data['data'] : data) as List? ?? []; + final now = DateTime.now(); + return list.whereType>().map((m) { + final bookId = (m['book'] ?? m['bookId'] ?? '') as String; + final colorName = (m['color'] as String?) ?? 'yellow'; + return Highlight( + bookId: bookId, + bookNumber: 0, + chapter: (m['chapter'] as num).toInt(), + verseStart: (m['verseStart'] as num).toInt(), + verseCount: (m['verseCount'] as num?)?.toInt() ?? 1, + color: _colorFromName(colorName), + note: m['note'] as String?, + createdAt: _parseDate(m['createdAt']) ?? now, + updatedAt: _parseDate(m['updatedAt']) ?? now, + syncStatus: SyncStatus.synced, + remoteId: m['_id'] as String?, + ); + }).toList(); + } + + Future> fetchNotes() async { + final res = await _client.get('/notes', token: _token); + final data = res['data']; + final list = (data is Map ? data['notes'] : data) as List? ?? []; + final now = DateTime.now(); + return list.whereType>().map((m) { + final bookId = (m['bookId'] ?? m['book'] ?? '') as String; + return Note( + bookId: bookId, + bookNumber: 0, + chapter: (m['chapter'] as num).toInt(), + verseStart: (m['verseStart'] as num).toInt(), + verseCount: (m['verseCount'] as num?)?.toInt() ?? 1, + content: (m['content'] as String?) ?? '', + isPrivate: (m['visibility'] as String?) != 'public', + createdAt: _parseDate(m['createdAt']) ?? now, + updatedAt: _parseDate(m['updatedAt']) ?? now, + syncStatus: SyncStatus.synced, + remoteId: m['_id'] as String?, + ); + }).where((n) => n.content.isNotEmpty).toList(); + } + + static DateTime? _parseDate(dynamic v) { + if (v is String) return DateTime.tryParse(v); + if (v is int) return DateTime.fromMillisecondsSinceEpoch(v); + return null; + } } diff --git a/lib/core/sync/sync_service.dart b/lib/core/sync/sync_service.dart index 6abfbe9..ec1d898 100644 --- a/lib/core/sync/sync_service.dart +++ b/lib/core/sync/sync_service.dart @@ -16,6 +16,33 @@ class SyncService { syncHighlights(), ]); + Future pullAll() => Future.wait([ + _pullBookmarks(), + _pullNotes(), + _pullHighlights(), + ]); + + Future _pullBookmarks() async { + try { + final items = await _repo.fetchBookmarks(); + await _db.upsertServerBookmarks(items); + } catch (_) {} + } + + Future _pullNotes() async { + try { + final items = await _repo.fetchNotes(); + await _db.upsertServerNotes(items); + } catch (_) {} + } + + Future _pullHighlights() async { + try { + final items = await _repo.fetchHighlights(); + await _db.upsertServerHighlights(items); + } catch (_) {} + } + // ── Bookmarks ────────────────────────────────────────────────────────────── Future syncBookmarks() async { diff --git a/lib/features/saved/presentation/pages/saved_screen.dart b/lib/features/saved/presentation/pages/saved_screen.dart index f93a78b..a8dab40 100644 --- a/lib/features/saved/presentation/pages/saved_screen.dart +++ b/lib/features/saved/presentation/pages/saved_screen.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/annotations/annotation_models.dart'; +import '../../../../core/auth/auth_state.dart'; import '../../../../core/services/repository_provider.dart'; +import '../../../../core/sync/sync_repository.dart'; +import '../../../../core/sync/sync_service.dart'; import '../../../../core/theme/app_color_scheme.dart'; import '../../../../core/theme/app_typography.dart'; import '../../../annotations/providers/annotation_providers.dart'; @@ -50,6 +53,14 @@ class _SavedScreenState extends ConsumerState { final db = ref.read(annotationDbProvider); final repo = BibleRepositoryProvider.of(context); + final token = ref.read(authStateProvider).token; + if (token != null) { + await SyncService( + db: db, + repo: SyncRepository(ref.read(apiClientProvider), token), + ).pullAll(); + } + final results = await Future.wait([ db.getAllBookmarks(), db.getAllHighlights(), From 7c72e0b5992f7304dbf0129a58e466c03b5d6f6d Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Wed, 20 May 2026 14:24:00 +0300 Subject: [PATCH 16/33] feat: update Google sign-in to return photo URL and enhance profile handling --- lib/core/auth/auth_repository.dart | 4 +- lib/core/auth/auth_state.dart | 7 +- .../presentation/widgets/home_header.dart | 8 ++- .../me/presentation/pages/me_screen.dart | 65 ++++++++++++++----- 4 files changed, 63 insertions(+), 21 deletions(-) diff --git a/lib/core/auth/auth_repository.dart b/lib/core/auth/auth_repository.dart index 7a770f6..32f23a9 100644 --- a/lib/core/auth/auth_repository.dart +++ b/lib/core/auth/auth_repository.dart @@ -67,7 +67,7 @@ class AuthRepository { // ── Social auth ─────────────────────────────────────────────────────────── - Future signInWithGoogle() async { + Future<(String token, String? photoUrl)> signInWithGoogle() async { final googleSignIn = GoogleSignIn(serverClientId: _webClientId); final account = await googleSignIn.signIn(); if (account == null) throw const ApiException('Google sign-in cancelled'); @@ -79,6 +79,6 @@ class AuthRepository { } final res = await _api.post('/auth/social/google', body: {'idToken': idToken}); - return res['data']['token'] as String; + return (res['data']['token'] as String, account.photoUrl); } } diff --git a/lib/core/auth/auth_state.dart b/lib/core/auth/auth_state.dart index 90ddcf9..3ad11ed 100644 --- a/lib/core/auth/auth_state.dart +++ b/lib/core/auth/auth_state.dart @@ -100,9 +100,12 @@ class AuthNotifier extends StateNotifier { } Future signInWithGoogle() async { - final token = await _repo.signInWithGoogle(); + final (token, photoUrl) = await _repo.signInWithGoogle(); await _storage.saveToken(token); - final profile = await _repo.fetchProfile(token); + var profile = await _repo.fetchProfile(token); + if (profile.avatar == null && photoUrl != null) { + profile = profile.copyWith(avatar: photoUrl); + } state = AuthState.authenticated(profile, token); _syncAfterAuth(token); } diff --git a/lib/features/home/presentation/widgets/home_header.dart b/lib/features/home/presentation/widgets/home_header.dart index e92b243..78b75d8 100644 --- a/lib/features/home/presentation/widgets/home_header.dart +++ b/lib/features/home/presentation/widgets/home_header.dart @@ -6,6 +6,7 @@ import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/app_color_scheme.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_typography.dart'; +import '../../../auth/presentation/pages/profile_screen.dart'; class HomeHeader extends ConsumerWidget { const HomeHeader({super.key, required this.dateLabel}); @@ -49,7 +50,12 @@ class HomeHeader extends ConsumerWidget { ), ), const SizedBox(width: 12), - _Avatar(user: user, colors: c), + GestureDetector( + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ProfileScreen()), + ), + child: _Avatar(user: user, colors: c), + ), ], ), ); diff --git a/lib/features/me/presentation/pages/me_screen.dart b/lib/features/me/presentation/pages/me_screen.dart index cd1992c..dddbabd 100644 --- a/lib/features/me/presentation/pages/me_screen.dart +++ b/lib/features/me/presentation/pages/me_screen.dart @@ -195,24 +195,26 @@ class _ProfileCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Container( - width: 52, - height: 52, - decoration: BoxDecoration( - color: c.accent, - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Text( - initial, - style: TextStyle( - fontFamily: AppTypography.shiromeda, - fontSize: 22, - fontWeight: FontWeight.w700, - color: c.primary, + if (user!.avatar != null) + ClipOval( + child: Image.network( + user!.avatar!, + width: 52, + height: 52, + fit: BoxFit.cover, + errorBuilder: (context, e, s) => _InitialsCircle( + initial: initial, + accent: c.accent, + primary: c.primary, + ), ), + ) + else + _InitialsCircle( + initial: initial, + accent: c.accent, + primary: c.primary, ), - ), const SizedBox(width: 14), Expanded( child: Column( @@ -270,6 +272,37 @@ class _ProfileCard extends StatelessWidget { } } +class _InitialsCircle extends StatelessWidget { + const _InitialsCircle({ + required this.initial, + required this.accent, + required this.primary, + }); + + final String initial; + final Color accent; + final Color primary; + + @override + Widget build(BuildContext context) { + return Container( + width: 52, + height: 52, + decoration: BoxDecoration(color: accent, shape: BoxShape.circle), + alignment: Alignment.center, + child: Text( + initial, + style: TextStyle( + fontFamily: AppTypography.shiromeda, + fontSize: 22, + fontWeight: FontWeight.w700, + color: primary, + ), + ), + ); + } +} + class _GuestCard extends StatelessWidget { const _GuestCard({required this.s, required this.colors}); final AppStrings s; From e293d17f6d149a7eefafaebe19c967f32bd6c785 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Wed, 20 May 2026 14:27:58 +0300 Subject: [PATCH 17/33] feat: enhance avatar display with initials fallback and improved layout --- .../presentation/pages/profile_screen.dart | 80 +++++++++---------- 1 file changed, 36 insertions(+), 44 deletions(-) diff --git a/lib/features/auth/presentation/pages/profile_screen.dart b/lib/features/auth/presentation/pages/profile_screen.dart index 323be3e..96f62c1 100644 --- a/lib/features/auth/presentation/pages/profile_screen.dart +++ b/lib/features/auth/presentation/pages/profile_screen.dart @@ -275,58 +275,50 @@ class _AvatarSection extends StatelessWidget { String get _initial => user.name.isNotEmpty ? user.name.characters.first : '?'; + Widget _initialsCircle(AppColorScheme c) => Container( + width: 96, + height: 96, + decoration: BoxDecoration( + color: c.primary, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: c.primary.withValues(alpha: 0.3), + blurRadius: 20, + offset: const Offset(0, 6), + ), + ], + ), + alignment: Alignment.center, + child: Text( + _initial, + style: TextStyle( + fontFamily: AppTypography.shiromeda, + fontSize: 38, + fontWeight: FontWeight.w700, + color: c.accent, + height: 1, + ), + ), + ); + @override Widget build(BuildContext context) { final c = colors; return Column( children: [ - Stack( - alignment: Alignment.center, - children: [ - Container( + if (user.avatar != null) + ClipOval( + child: Image.network( + user.avatar!, width: 96, height: 96, - decoration: BoxDecoration( - color: c.primary, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: c.primary.withValues(alpha: 0.3), - blurRadius: 20, - offset: const Offset(0, 6), - ), - ], - ), - alignment: Alignment.center, - child: Text( - _initial, - style: TextStyle( - fontFamily: AppTypography.shiromeda, - fontSize: 38, - fontWeight: FontWeight.w700, - color: c.accent, - height: 1, - ), - ), - ), - Positioned( - bottom: 0, - right: 0, - child: Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: c.accent, - shape: BoxShape.circle, - border: Border.all(color: c.parchment, width: 2), - ), - alignment: Alignment.center, - child: - Icon(Icons.camera_alt_rounded, size: 13, color: c.primary), - ), + fit: BoxFit.cover, + errorBuilder: (context, e, s) => _initialsCircle(c), ), - ], - ), + ) + else + _initialsCircle(c), const SizedBox(height: 14), Text( user.name, From 9be17f32edd1dc706d5ac7b5618ed3df258d56a6 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Wed, 20 May 2026 16:20:38 +0300 Subject: [PATCH 18/33] feat: move all hardcoded strings to l10n files Add 60 new keys across four new sections in app_strings.dart, am_strings.dart, and en_strings.dart: profile screen, saved/collection screen, forgot password, and reset password. Update all nine affected screens to use L10n.of(context) instead of inline Amharic/English literals. Co-Authored-By: Claude Sonnet 4.6 --- lib/core/l10n/am_strings.dart | 72 +++++++++++++++++++ lib/core/l10n/app_strings.dart | 72 +++++++++++++++++++ lib/core/l10n/en_strings.dart | 72 +++++++++++++++++++ .../pages/forgot_password_screen.dart | 31 ++++---- .../presentation/pages/profile_screen.dart | 42 +++++------ .../pages/reset_password_screen.dart | 32 ++++----- .../presentation/pages/bookmarks_tab.dart | 28 ++++---- .../presentation/pages/highlights_tab.dart | 28 ++++---- .../saved/presentation/pages/notes_tab.dart | 28 ++++---- .../presentation/pages/saved_common.dart | 25 ++++--- .../presentation/pages/saved_screen.dart | 12 ++-- 11 files changed, 339 insertions(+), 103 deletions(-) diff --git a/lib/core/l10n/am_strings.dart b/lib/core/l10n/am_strings.dart index 6c3577e..6eafb76 100644 --- a/lib/core/l10n/am_strings.dart +++ b/lib/core/l10n/am_strings.dart @@ -262,4 +262,76 @@ class AmStrings extends AppStrings { @override String get otpChangePhone => 'ስልክ ቁጥር ለውጥ'; @override String get otpChangeEmail => 'ኢሜል ለውጥ'; @override String get otpResendFailed => 'ኮድ መላክ አልተሳካም። ድጋሚ ይሞክሩ።'; + + // ── Forgot password ─────────────────────────────────────────────────────── + @override String get forgotTitle => 'የይለፍ ቃል ረሱ?'; + @override String get forgotSubtitle => 'ምንም አያስቡ። የተመዘገቡበትን ኢሜልዎን ያስገቡ፤ የዳግም ማስጀመሪያ ኮድ እንልካለን።'; + @override String get forgotEmailLabel => 'የተመዘገቡበት ኢሜል'; + @override String get forgotEmailHelper => 'የዳግም ማስጀመሪያ ኮዱ ወደዚህ ኢሜል ይላካል።'; + @override String get forgotPhoneLabel => 'የተመዘገቡ ስልክ ቁጥር'; + @override String get forgotPhoneHelper => 'የዳግም ማስጀመሪያ ኮዱ ወደዚህ ስልክ ቁጥር ይላካል።'; + @override String get forgotSendButton => 'ኮድ ላክ'; + @override String get forgotRememberPassword => 'ኮዱን አስታወሱ? '; + @override String get forgotPhoneComingSoon => 'ስልክ ቁጥር ዳግም ማስጀመሪያ — በቅርብ ይመጣል'; + @override String get forgotTabPhone => 'ስልክ'; + + // ── Reset password ──────────────────────────────────────────────────────── + @override String get resetTitle => 'አዲስ የይለፍ ቃል'; + @override String get resetSubtitle => 'የሚያስታውሱት ጠንካራ የይለፍ ቃል ለመምረጥ ይሞክሩ።'; + @override String get resetTokenLabel => 'ከኢሜልዎ የተቀበሉት ኮድ'; + @override String get resetNewPasswordLabel => 'አዲስ የይለፍ ቃል'; + @override String get resetConfirmLabel => 'የይለፍ ቃል ያረጋግጡ'; + @override String get resetRequirementsTitle => 'የይለፍ ቃል መስፈርቶች'; + @override String get resetReqLength => 'ቢያንስ 8 ቁምፊዎች'; + @override String get resetReqUpper => 'አንድ ትልቅ ፊደል (A–Z)'; + @override String get resetReqNumber => 'አንድ ቁጥር (0–9)'; + @override String get resetReqSpecial => 'አንድ ልዩ ምልክት (!@#\$)'; + @override String get resetSaveButton => 'አስቀምጥ እና ግባ'; + @override String get resetSuccessMessage => 'የይለፍ ቃልዎ ተቀይሯል። እባክዎ ይግቡ።'; + + // ── Profile screen ──────────────────────────────────────────────────────── + @override String get profileTitle => 'ፕሮፋይል'; + @override String get profileMemberBadge => 'አባል'; + @override String get profileLogout => 'ውጣ'; + @override String get profileDeleteAccount => 'መለያ ሰርዝ'; + @override String get profileEditButton => 'ፕሮፋይል አስተካክል'; + @override String get profileAchievements => 'ስኬቶች'; + @override String get profileStatStreak => 'የቀን ስኬቶች'; + @override String get profileStatBookmarks => 'ምልክት'; + @override String get profileStatPlan => 'ዕቅድ'; + @override String get profileDeleteTitle => 'መለያ ይሰረዝ?'; + @override String get profileDeleteMessage => 'ሁሉም ዳታ ይጠፋል። ይህ ድርጊት ሊመለስ አይችልም።'; + @override String get profileDeleteCancel => 'ይቅር'; + @override String get profileDeleteConfirm => 'ሰርዝ'; + @override String get achievementFirstDayTitle => 'መጀመሪያ ቀን'; + @override String get achievementFirstDaySub => 'First Day'; + @override String get achievement7DayTitle => '፯ ቀን ሰንሰለት'; + @override String get achievement7DaySub => '7-Day Streak'; + @override String get achievementPsalmTitle => 'የምዝሙሩ'; + @override String get achievementPsalmSub => 'Psalm Reader'; + + // ── Saved / Collection screen ───────────────────────────────────────────── + @override String get savedScreenTitle => 'ስብስቤ'; + @override String get savedScreenSubtitle => 'ያቀቡት'; + @override String get savedTabHighlights => 'ምልክቶ'; + @override String get savedTabBookmarks => 'ክታቦ'; + @override String get savedTabNotes => 'ማስታወሻ'; + @override String get savedHighlightsEmpty => 'ምንም ምልክት የለም'; + @override String get savedHighlightsEmptyHint => 'ምንባብ ሲያነቡ ምልክት ያስቀምጡ'; + @override String get savedBookmarksEmpty => 'ምንም ክታቦ የለም'; + @override String get savedBookmarksEmptyHint => 'ምንባብ ሲያነቡ ክታቦ ያስቀምጡ'; + @override String get savedNotesEmpty => 'ምንም ማስታወሻ የለም'; + @override String get savedNotesEmptyHint => 'ምንባብ ሲያነቡ ማስታወሻ ይጻፉ'; + @override String get savedToday => 'ዛሬ'; + @override String get savedYesterday => 'ትናንት'; + @override String savedDaysAgo(int n) => '$n ቀን'; + @override String get savedFilterAll => 'ሁሉም'; + @override String get savedFilterOT => 'ብሉይ'; + @override String get savedFilterNT => 'አዲስ'; + @override String get savedFilterAllChapters => 'ሁሉም ምዕ.'; + @override String savedFilterChapter(int n) => 'ምዕ. $n'; + @override String get savedPickBook => 'መጽሐፍ ምረጥ'; + @override String get savedPickAllBooks => 'ሁሉም መጻሕፍ'; + @override String get savedPickChapter => 'ምዕራፍ ምረጥ'; + @override String get savedPickAllChapters => 'ሁሉም ምዕራፍ'; } diff --git a/lib/core/l10n/app_strings.dart b/lib/core/l10n/app_strings.dart index 9fa6e5b..48fff2e 100644 --- a/lib/core/l10n/app_strings.dart +++ b/lib/core/l10n/app_strings.dart @@ -180,4 +180,76 @@ String get searchScopeTitle; String get otpChangePhone; String get otpChangeEmail; String get otpResendFailed; + + // ── Forgot password ─────────────────────────────────────────────────────── + String get forgotTitle; + String get forgotSubtitle; + String get forgotEmailLabel; + String get forgotEmailHelper; + String get forgotPhoneLabel; + String get forgotPhoneHelper; + String get forgotSendButton; + String get forgotRememberPassword; + String get forgotPhoneComingSoon; + String get forgotTabPhone; + + // ── Reset password ──────────────────────────────────────────────────────── + String get resetTitle; + String get resetSubtitle; + String get resetTokenLabel; + String get resetNewPasswordLabel; + String get resetConfirmLabel; + String get resetRequirementsTitle; + String get resetReqLength; + String get resetReqUpper; + String get resetReqNumber; + String get resetReqSpecial; + String get resetSaveButton; + String get resetSuccessMessage; + + // ── Profile screen ──────────────────────────────────────────────────────── + String get profileTitle; + String get profileMemberBadge; + String get profileLogout; + String get profileDeleteAccount; + String get profileEditButton; + String get profileAchievements; + String get profileStatStreak; + String get profileStatBookmarks; + String get profileStatPlan; + String get profileDeleteTitle; + String get profileDeleteMessage; + String get profileDeleteCancel; + String get profileDeleteConfirm; + String get achievementFirstDayTitle; + String get achievementFirstDaySub; + String get achievement7DayTitle; + String get achievement7DaySub; + String get achievementPsalmTitle; + String get achievementPsalmSub; + + // ── Saved / Collection screen ───────────────────────────────────────────── + String get savedScreenTitle; + String get savedScreenSubtitle; + String get savedTabHighlights; + String get savedTabBookmarks; + String get savedTabNotes; + String get savedHighlightsEmpty; + String get savedHighlightsEmptyHint; + String get savedBookmarksEmpty; + String get savedBookmarksEmptyHint; + String get savedNotesEmpty; + String get savedNotesEmptyHint; + String get savedToday; + String get savedYesterday; + String savedDaysAgo(int n); + String get savedFilterAll; + String get savedFilterOT; + String get savedFilterNT; + String get savedFilterAllChapters; + String savedFilterChapter(int n); + String get savedPickBook; + String get savedPickAllBooks; + String get savedPickChapter; + String get savedPickAllChapters; } diff --git a/lib/core/l10n/en_strings.dart b/lib/core/l10n/en_strings.dart index b035358..b34b863 100644 --- a/lib/core/l10n/en_strings.dart +++ b/lib/core/l10n/en_strings.dart @@ -172,4 +172,76 @@ class EnStrings extends AppStrings { @override String get otpChangePhone => 'Change phone number'; @override String get otpChangeEmail => 'Change email'; @override String get otpResendFailed => 'Failed to resend. Try again.'; + + // ── Forgot password ─────────────────────────────────────────────────────── + @override String get forgotTitle => 'Forgot Password?'; + @override String get forgotSubtitle => 'No worries. Enter your registered email and we\'ll send a reset code.'; + @override String get forgotEmailLabel => 'Registered Email'; + @override String get forgotEmailHelper => 'A reset code will be sent to this email.'; + @override String get forgotPhoneLabel => 'Registered Phone Number'; + @override String get forgotPhoneHelper => 'A reset code will be sent to this phone number.'; + @override String get forgotSendButton => 'Send Code'; + @override String get forgotRememberPassword => 'Remember your password? '; + @override String get forgotPhoneComingSoon => 'Phone reset — coming soon'; + @override String get forgotTabPhone => 'Phone'; + + // ── Reset password ──────────────────────────────────────────────────────── + @override String get resetTitle => 'New Password'; + @override String get resetSubtitle => 'Choose a strong password you\'ll remember.'; + @override String get resetTokenLabel => 'Code from your email'; + @override String get resetNewPasswordLabel => 'New Password'; + @override String get resetConfirmLabel => 'Confirm Password'; + @override String get resetRequirementsTitle => 'Password Requirements'; + @override String get resetReqLength => 'At least 8 characters'; + @override String get resetReqUpper => 'One uppercase letter (A–Z)'; + @override String get resetReqNumber => 'One number (0–9)'; + @override String get resetReqSpecial => 'One special character (!@#\$)'; + @override String get resetSaveButton => 'Save and Sign In'; + @override String get resetSuccessMessage => 'Password changed. Please sign in.'; + + // ── Profile screen ──────────────────────────────────────────────────────── + @override String get profileTitle => 'Profile'; + @override String get profileMemberBadge => 'Member'; + @override String get profileLogout => 'Sign Out'; + @override String get profileDeleteAccount => 'Delete Account'; + @override String get profileEditButton => 'Edit Profile'; + @override String get profileAchievements => 'Achievements'; + @override String get profileStatStreak => 'Day Streak'; + @override String get profileStatBookmarks => 'Marks'; + @override String get profileStatPlan => 'Plan'; + @override String get profileDeleteTitle => 'Delete Account?'; + @override String get profileDeleteMessage => 'All your data will be lost. This cannot be undone.'; + @override String get profileDeleteCancel => 'Cancel'; + @override String get profileDeleteConfirm => 'Delete'; + @override String get achievementFirstDayTitle => 'First Day'; + @override String get achievementFirstDaySub => 'First Day'; + @override String get achievement7DayTitle => '7-Day Streak'; + @override String get achievement7DaySub => '7-Day Streak'; + @override String get achievementPsalmTitle => 'Psalm Reader'; + @override String get achievementPsalmSub => 'Psalm Reader'; + + // ── Saved / Collection screen ───────────────────────────────────────────── + @override String get savedScreenTitle => 'Collection'; + @override String get savedScreenSubtitle => 'Your saved'; + @override String get savedTabHighlights => 'Highlights'; + @override String get savedTabBookmarks => 'Bookmarks'; + @override String get savedTabNotes => 'Notes'; + @override String get savedHighlightsEmpty => 'No highlights yet'; + @override String get savedHighlightsEmptyHint => 'Highlight verses while reading'; + @override String get savedBookmarksEmpty => 'No bookmarks yet'; + @override String get savedBookmarksEmptyHint => 'Bookmark verses while reading'; + @override String get savedNotesEmpty => 'No notes yet'; + @override String get savedNotesEmptyHint => 'Write notes while reading'; + @override String get savedToday => 'Today'; + @override String get savedYesterday => 'Yesterday'; + @override String savedDaysAgo(int n) => '$n days ago'; + @override String get savedFilterAll => 'All'; + @override String get savedFilterOT => 'OT'; + @override String get savedFilterNT => 'NT'; + @override String get savedFilterAllChapters => 'All Chs.'; + @override String savedFilterChapter(int n) => 'Ch. $n'; + @override String get savedPickBook => 'Pick a Book'; + @override String get savedPickAllBooks => 'All Books'; + @override String get savedPickChapter => 'Pick a Chapter'; + @override String get savedPickAllChapters => 'All Chapters'; } diff --git a/lib/features/auth/presentation/pages/forgot_password_screen.dart b/lib/features/auth/presentation/pages/forgot_password_screen.dart index 2831c11..66c106c 100644 --- a/lib/features/auth/presentation/pages/forgot_password_screen.dart +++ b/lib/features/auth/presentation/pages/forgot_password_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/api/api_client.dart'; import '../../../../core/auth/auth_state.dart'; +import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/app_color_scheme.dart'; import '../../../../core/theme/app_typography.dart'; import 'login_screen.dart'; @@ -32,11 +33,12 @@ class _ForgotPasswordScreenState } Future _submit() async { + final s = L10n.of(context); if (!_useEmail) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - 'ስልክ ቁጥር ዳግም ማስጀመሪያ — በቅርብ ይመጣል', + s.forgotPhoneComingSoon, style: AppTypography.amharicCaption.copyWith(color: Colors.white), ), backgroundColor: context.colors.primary, @@ -50,7 +52,7 @@ class _ForgotPasswordScreenState final email = _emailCtrl.text.trim(); if (email.isEmpty || !email.contains('@')) { - setState(() => _errorMessage = 'ትክክለኛ ኢሜል ያስፈልጋል'); + setState(() => _errorMessage = s.authEmailInvalid); return; } @@ -68,7 +70,7 @@ class _ForgotPasswordScreenState } on ApiException catch (e) { setState(() => _errorMessage = e.message); } catch (_) { - setState(() => _errorMessage = 'ግንኙነት አልተሳካም። ድጋሚ ይሞክሩ።'); + setState(() => _errorMessage = L10n.of(context).authConnectionError); } finally { if (mounted) setState(() => _isLoading = false); } @@ -77,6 +79,7 @@ class _ForgotPasswordScreenState @override Widget build(BuildContext context) { final c = context.colors; + final s = L10n.of(context); return Scaffold( backgroundColor: c.parchment, @@ -90,7 +93,7 @@ class _ForgotPasswordScreenState _BackButton(), const SizedBox(height: 24), Text( - 'የይለፍ ቃል እንረሱ?', + s.forgotTitle, style: AppTypography.amharicDisplay.copyWith( color: c.textOnParchment, fontSize: 30, @@ -98,7 +101,7 @@ class _ForgotPasswordScreenState ), const SizedBox(height: 6), Text( - 'ምንም ሰሞ። የተመዘገቡ ኢሜልዎን ያስገቡ፤ የዳግም ማስጀመሪያ ኮድ እንልካለን።', + s.forgotSubtitle, style: AppTypography.amharicBody.copyWith( color: c.textMuted, fontSize: 13, @@ -119,7 +122,7 @@ class _ForgotPasswordScreenState const SizedBox(height: 20), if (_useEmail) ...[ _FieldLabel( - label: 'የተመዘገቡ ኢሜል', + label: s.forgotEmailLabel, active: true, colors: c, ), @@ -133,7 +136,7 @@ class _ForgotPasswordScreenState ), const SizedBox(height: 8), Text( - 'የዳግም ማስጀመሪያ ኮዱ ወደዚህ ኢሜል ይላካል።', + s.forgotEmailHelper, style: AppTypography.amharicCaption.copyWith( color: c.textCaption, fontSize: 11, @@ -141,7 +144,7 @@ class _ForgotPasswordScreenState ), ] else ...[ _FieldLabel( - label: 'የተመዘገቡ ስልክ ቁጥር', + label: s.forgotPhoneLabel, active: true, colors: c, ), @@ -155,7 +158,7 @@ class _ForgotPasswordScreenState ), const SizedBox(height: 8), Text( - 'የዳግም ማስጀመሪያ ኮዱ ወደዚህ ስልክ ቁጥር ይላካል።', + s.forgotPhoneHelper, style: AppTypography.amharicCaption.copyWith( color: c.textCaption, fontSize: 11, @@ -168,7 +171,7 @@ class _ForgotPasswordScreenState ], const SizedBox(height: 32), _PrimaryButton( - label: 'አሮኝ ሊክ', + label: s.forgotSendButton, isLoading: _isLoading, onTap: _isLoading ? null : _submit, ), @@ -177,7 +180,7 @@ class _ForgotPasswordScreenState mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - 'ኮዱን አስታወሱ? ', + s.forgotRememberPassword, style: AppTypography.amharicCaption.copyWith( color: c.textMuted, fontSize: 12), ), @@ -190,7 +193,7 @@ class _ForgotPasswordScreenState builder: (_) => const LoginScreen()), ), child: Text( - 'ይ​ግቡ', + s.loginButton, style: AppTypography.amharicCaption.copyWith( color: c.primary, fontWeight: FontWeight.w700, @@ -235,14 +238,14 @@ class _TabSwitcher extends StatelessWidget { children: [ _Tab( icon: Icons.email_outlined, - label: 'ኢሜል', + label: L10n.of(context).authEmail, active: useEmail, onTap: () => onChanged(true), colors: c, ), _Tab( icon: Icons.phone_outlined, - label: 'ስልክ', + label: L10n.of(context).forgotTabPhone, active: !useEmail, onTap: () => onChanged(false), colors: c, diff --git a/lib/features/auth/presentation/pages/profile_screen.dart b/lib/features/auth/presentation/pages/profile_screen.dart index 96f62c1..d2605ea 100644 --- a/lib/features/auth/presentation/pages/profile_screen.dart +++ b/lib/features/auth/presentation/pages/profile_screen.dart @@ -4,6 +4,7 @@ import 'package:kenat/kenat.dart'; import '../../../../core/auth/auth_state.dart'; import '../../../../core/auth/user_profile.dart'; import '../../../../core/constants/app_icons.dart'; +import '../../../../core/l10n/l10n.dart'; import '../../../../core/settings/app_settings.dart'; import '../../../../core/storage/app_database_provider.dart'; import '../../../../core/theme/app_color_scheme.dart'; @@ -86,7 +87,7 @@ class _TopBar extends ConsumerWidget { ), const Spacer(), Text( - 'ፕሮፋይል', + L10n.of(context).profileTitle, style: AppTypography.amharicLabel.copyWith( color: c.textOnParchment, fontSize: 15, @@ -166,7 +167,7 @@ class _ProfileMenuSheet extends StatelessWidget { ), _SheetTile( icon: Icons.logout_rounded, - label: 'ውጣ', + label: L10n.of(context).profileLogout, color: c.textBody, onTap: () async { Navigator.pop(ctx); @@ -177,7 +178,7 @@ class _ProfileMenuSheet extends StatelessWidget { Divider(color: c.borderSubtle, height: 1, indent: 16), _SheetTile( icon: Icons.delete_outline_rounded, - label: 'መለያ ሰርዝ', + label: L10n.of(context).profileDeleteAccount, color: c.primary, onTap: () { Navigator.pop(ctx); @@ -197,13 +198,13 @@ class _ProfileMenuSheet extends StatelessWidget { backgroundColor: c.surface, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), title: Text( - 'መለያ ይሰረዝ?', + L10n.of(ctx).profileDeleteTitle, style: AppTypography.amharicSubheading.copyWith( color: c.textOnParchment, ), ), content: Text( - 'ሁሉም ውሂብዎ ይጠፋል። ይህ ድርጊት ሊቀለበስ አይችልም።', + L10n.of(ctx).profileDeleteMessage, style: AppTypography.amharicBody.copyWith( color: c.textMuted, fontSize: 13, @@ -212,7 +213,7 @@ class _ProfileMenuSheet extends StatelessWidget { actions: [ TextButton( onPressed: () => Navigator.pop(dlgCtx), - child: Text('ይቅር', + child: Text(L10n.of(ctx).profileDeleteCancel, style: AppTypography.amharicLabel .copyWith(color: c.textMuted)), ), @@ -222,7 +223,7 @@ class _ProfileMenuSheet extends StatelessWidget { await ref.read(authStateProvider.notifier).deleteAccount(); if (ctx.mounted) Navigator.of(ctx).pop(); }, - child: Text('ሰርዝ', + child: Text(L10n.of(ctx).profileDeleteConfirm, style: AppTypography.amharicLabel .copyWith(color: c.primary)), ), @@ -349,7 +350,7 @@ class _AvatarSection extends StatelessWidget { style: TextStyle(color: c.accentDeep, fontSize: 11)), const SizedBox(width: 6), Text( - 'አባል · የቅዱስ ቤተ-ሰብ', + L10n.of(context).profileMemberBadge, style: AppTypography.amharicCaption.copyWith( color: c.primary, fontSize: 11, @@ -405,17 +406,17 @@ class _StatsRow extends ConsumerWidget { children: [ _StatCell( value: fmt(streak), - label: 'ቀን ቅ/ነዴ', + label: L10n.of(context).profileStatStreak, colors: c), _StatDivider(colors: c), _StatCell( value: fmt(bookmarks), - label: 'ምልክት', + label: L10n.of(context).profileStatBookmarks, colors: c), _StatDivider(colors: c), _StatCell( value: '${fmt(overallPct)}%', - label: 'ዕቅድ', + label: L10n.of(context).profileStatPlan, colors: c), ], ), @@ -504,7 +505,7 @@ class _ActionButtons extends StatelessWidget { Icon(Icons.edit_outlined, color: c.primary, size: 16), const SizedBox(width: 8), Text( - 'ፕሮፋይል አስተካክል', + L10n.of(context).profileEditButton, style: AppTypography.amharicCaption.copyWith( color: c.primary, fontWeight: FontWeight.w700, @@ -550,25 +551,26 @@ class _AchievementsSection extends ConsumerWidget { final hasReadAnything = (snapshotsAsync.value?.isNotEmpty ?? false); + final s = L10n.of(context); final achievements = [ _Achievement( icon: AppIcons.ethiopianCross, - title: 'መጀመሪያ ቀን', - subtitle: 'First Day', + title: s.achievementFirstDayTitle, + subtitle: s.achievementFirstDaySub, unlocked: hasReadAnything || streak > 0, ), _Achievement( icon: '🔗', - title: '፯ ቀን ሰንሰለት', - subtitle: '7-Day Streak', + title: s.achievement7DayTitle, + subtitle: s.achievement7DaySub, unlocked: streak >= 7 || longest >= 7, ), _Achievement( icon: '✦', - title: 'የምዝሙሩ', - subtitle: 'Psalm Reader', + title: s.achievementPsalmTitle, + subtitle: s.achievementPsalmSub, unlocked: snapshotsAsync.value - ?.any((s) => s.entry.bookNameEn.contains('Psalm')) ?? + ?.any((snap) => snap.entry.bookNameEn.contains('Psalm')) ?? false, ), ]; @@ -583,7 +585,7 @@ class _AchievementsSection extends ConsumerWidget { child: Row( children: [ Text( - 'ስኬቶች', + L10n.of(context).profileAchievements, style: AppTypography.amharicLabel.copyWith( color: c.textOnParchment, ), diff --git a/lib/features/auth/presentation/pages/reset_password_screen.dart b/lib/features/auth/presentation/pages/reset_password_screen.dart index 40e6ea5..223aacf 100644 --- a/lib/features/auth/presentation/pages/reset_password_screen.dart +++ b/lib/features/auth/presentation/pages/reset_password_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/api/api_client.dart'; import '../../../../core/auth/auth_state.dart'; +import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/app_color_scheme.dart'; import '../../../../core/theme/app_typography.dart'; import 'login_screen.dart'; @@ -69,7 +70,7 @@ class _ResetPasswordScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - 'የይለፍ ቃልዎ ተቀይሯል። እባክዎ ይግቡ።', + L10n.of(context).resetSuccessMessage, style: AppTypography.amharicCaption .copyWith(color: Colors.white), ), @@ -83,7 +84,7 @@ class _ResetPasswordScreenState extends ConsumerState { } on ApiException catch (e) { setState(() => _errorMessage = e.message); } catch (_) { - setState(() => _errorMessage = 'ግንኙነት አልተሳካም። ድጋሚ ይሞክሩ።'); + setState(() => _errorMessage = L10n.of(context).authConnectionError); } finally { if (mounted) setState(() => _isLoading = false); } @@ -92,6 +93,7 @@ class _ResetPasswordScreenState extends ConsumerState { @override Widget build(BuildContext context) { final c = context.colors; + final s = L10n.of(context); return Scaffold( backgroundColor: c.parchment, @@ -105,7 +107,7 @@ class _ResetPasswordScreenState extends ConsumerState { _BackButton(), const SizedBox(height: 24), Text( - 'አዲስ የይለፍ ቃል', + s.resetTitle, style: AppTypography.amharicDisplay.copyWith( color: c.textOnParchment, fontSize: 30, @@ -113,7 +115,7 @@ class _ResetPasswordScreenState extends ConsumerState { ), const SizedBox(height: 6), Text( - 'የሚያስታውሱት ጠንካራ የይለፍ ቃል ለመምረጥ ይሞክሩ።', + s.resetSubtitle, style: AppTypography.amharicBody.copyWith( color: c.textMuted, fontSize: 13, @@ -125,7 +127,7 @@ class _ResetPasswordScreenState extends ConsumerState { const SizedBox(height: 32), // Token field - _FieldLabel(label: 'ከኢሜልዎ የተቀበሉት ኮድ', colors: c), + _FieldLabel(label: s.resetTokenLabel, colors: c), const SizedBox(height: 6), _PasswordField( controller: _tokenCtrl, @@ -139,7 +141,7 @@ class _ResetPasswordScreenState extends ConsumerState { const SizedBox(height: 20), // New password - _FieldLabel(label: 'አዲስ የይለፍ ቃል', colors: c), + _FieldLabel(label: s.resetNewPasswordLabel, colors: c), const SizedBox(height: 6), _PasswordField( controller: _passwordCtrl, @@ -155,7 +157,7 @@ class _ResetPasswordScreenState extends ConsumerState { const SizedBox(height: 16), // Confirm password - _FieldLabel(label: 'የይለፍ ቃል ያረጋግጡ', colors: c), + _FieldLabel(label: s.resetConfirmLabel, colors: c), const SizedBox(height: 6), _PasswordField( controller: _confirmCtrl, @@ -186,7 +188,7 @@ class _ResetPasswordScreenState extends ConsumerState { ], const SizedBox(height: 28), _PrimaryButton( - label: 'አስቀምጥ እና ግባ', + label: s.resetSaveButton, isLoading: _isLoading, onTap: (!_canSubmit || _isLoading) ? null : _submit, ), @@ -230,7 +232,7 @@ class _RequirementsCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'የይለፍ ቃል መስፈርቶች', + L10n.of(context).resetRequirementsTitle, style: AppTypography.amharicCaption.copyWith( color: c.textMuted, fontWeight: FontWeight.w700, @@ -238,17 +240,13 @@ class _RequirementsCard extends StatelessWidget { ), ), const SizedBox(height: 10), - _Requirement( - met: hasLength, label: 'ቢያንስ ፰ ቁምፊዎች', colors: c), + _Requirement(met: hasLength, label: L10n.of(context).resetReqLength, colors: c), const SizedBox(height: 6), - _Requirement( - met: hasUpper, label: 'አንድ ትልቅ ፊደል (A–Z)', colors: c), + _Requirement(met: hasUpper, label: L10n.of(context).resetReqUpper, colors: c), const SizedBox(height: 6), - _Requirement( - met: hasNumber, label: 'አንድ ቁጥር (0–9)', colors: c), + _Requirement(met: hasNumber, label: L10n.of(context).resetReqNumber, colors: c), const SizedBox(height: 6), - _Requirement( - met: hasSpecial, label: 'አንድ ልዩ ምልክት (!@#\$)', colors: c), + _Requirement(met: hasSpecial, label: L10n.of(context).resetReqSpecial, colors: c), ], ), ); diff --git a/lib/features/saved/presentation/pages/bookmarks_tab.dart b/lib/features/saved/presentation/pages/bookmarks_tab.dart index 0f2f100..7c4c5b2 100644 --- a/lib/features/saved/presentation/pages/bookmarks_tab.dart +++ b/lib/features/saved/presentation/pages/bookmarks_tab.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/app_color_scheme.dart'; import 'saved_common.dart'; @@ -64,14 +65,15 @@ class _BookmarksTabState extends State { .toList() ..sort((a, b) => a.bookNumber.compareTo(b.bookNumber)); + final s = L10n.of(context); showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20))), builder: (_) => AnnotationPickerSheet( - title: 'መጽሐፍ ምረጥ', + title: s.savedPickBook, items: [ - const AnnotationPickerItem(id: null, label: 'ሁሉም መጻሕፍ'), + AnnotationPickerItem(id: null, label: s.savedPickAllBooks), ...books.map( (e) => AnnotationPickerItem(id: e.bookNameEn, label: e.bookNameAm)), ], @@ -90,17 +92,18 @@ class _BookmarksTabState extends State { void _showChapterPicker() { if (_bookFilter == null) return; final chapters = _availableChapters.toList()..sort(); + final s = L10n.of(context); showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20))), builder: (_) => AnnotationPickerSheet( - title: 'ምዕራፍ ምረጥ', + title: s.savedPickChapter, items: [ - const AnnotationPickerItem(id: null, label: 'ሁሉም ምዕራፍ'), - ...chapters - .map((ch) => AnnotationPickerItem(id: ch, label: 'ምዕ. $ch')), + AnnotationPickerItem(id: null, label: s.savedPickAllChapters), + ...chapters.map( + (ch) => AnnotationPickerItem(id: ch, label: s.savedFilterChapter(ch))), ], selectedId: _chapterFilter, onSelect: (id) { @@ -113,6 +116,7 @@ class _BookmarksTabState extends State { @override Widget build(BuildContext context) { + final s = L10n.of(context); final items = _filtered; final availableBookIds = _availableBookIds; final availableChapters = _availableChapters; @@ -133,13 +137,13 @@ class _BookmarksTabState extends State { child: Row( children: [ SavedFilterChip( - label: 'ሁሉም', + label: s.savedFilterAll, active: _testamentFilter == null, onTap: () => setState(() => _testamentFilter = null), ), const SizedBox(width: 6), SavedFilterChip( - label: 'ብሉይ', + label: s.savedFilterOT, active: _testamentFilter == 'OT', onTap: () => setState(() { _testamentFilter = @@ -150,7 +154,7 @@ class _BookmarksTabState extends State { ), const SizedBox(width: 6), SavedFilterChip( - label: 'አዲስ', + label: s.savedFilterNT, active: _testamentFilter == 'NT', onTap: () => setState(() { _testamentFilter = @@ -162,7 +166,7 @@ class _BookmarksTabState extends State { if (availableBookIds.length > 1) ...[ const SizedBox(width: 6), SavedFilterChip( - label: currentBookEntry?.bookNameAm ?? 'ሁሉም', + label: currentBookEntry?.bookNameAm ?? s.savedFilterAll, active: _bookFilter != null, trailing: Icons.expand_more_rounded, onTap: _showBookPicker, @@ -172,8 +176,8 @@ class _BookmarksTabState extends State { const SizedBox(width: 6), SavedFilterChip( label: _chapterFilter != null - ? 'ምዕ. $_chapterFilter' - : 'ሁሉም ምዕ.', + ? s.savedFilterChapter(_chapterFilter!) + : s.savedFilterAllChapters, active: _chapterFilter != null, trailing: Icons.expand_more_rounded, onTap: _showChapterPicker, diff --git a/lib/features/saved/presentation/pages/highlights_tab.dart b/lib/features/saved/presentation/pages/highlights_tab.dart index dbf7df5..68f7e84 100644 --- a/lib/features/saved/presentation/pages/highlights_tab.dart +++ b/lib/features/saved/presentation/pages/highlights_tab.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../../../core/annotations/annotation_models.dart'; +import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/app_color_scheme.dart'; import 'saved_common.dart'; @@ -74,14 +75,15 @@ class _HighlightsTabState extends State { .toList() ..sort((a, b) => a.bookNumber.compareTo(b.bookNumber)); + final s = L10n.of(context); showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20))), builder: (_) => AnnotationPickerSheet( - title: 'መጽሐፍ ምረጥ', + title: s.savedPickBook, items: [ - const AnnotationPickerItem(id: null, label: 'ሁሉም መጻሕፍ'), + AnnotationPickerItem(id: null, label: s.savedPickAllBooks), ...books.map( (e) => AnnotationPickerItem(id: e.bookNameEn, label: e.bookNameAm)), ], @@ -100,17 +102,18 @@ class _HighlightsTabState extends State { void _showChapterPicker() { if (_bookFilter == null) return; final chapters = _availableChapters.toList()..sort(); + final s = L10n.of(context); showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20))), builder: (_) => AnnotationPickerSheet( - title: 'ምዕራፍ ምረጥ', + title: s.savedPickChapter, items: [ - const AnnotationPickerItem(id: null, label: 'ሁሉም ምዕራፍ'), - ...chapters - .map((ch) => AnnotationPickerItem(id: ch, label: 'ምዕ. $ch')), + AnnotationPickerItem(id: null, label: s.savedPickAllChapters), + ...chapters.map( + (ch) => AnnotationPickerItem(id: ch, label: s.savedFilterChapter(ch))), ], selectedId: _chapterFilter, onSelect: (id) { @@ -123,6 +126,7 @@ class _HighlightsTabState extends State { @override Widget build(BuildContext context) { + final s = L10n.of(context); final items = _filtered; final availableBookIds = _availableBookIds; final availableChapters = _availableChapters; @@ -163,13 +167,13 @@ class _HighlightsTabState extends State { color: context.colors.borderSubtle, ), SavedFilterChip( - label: 'ሁሉም', + label: s.savedFilterAll, active: _testamentFilter == null, onTap: () => setState(() => _testamentFilter = null), ), const SizedBox(width: 6), SavedFilterChip( - label: 'ብሉይ', + label: s.savedFilterOT, active: _testamentFilter == 'OT', onTap: () => setState(() { _testamentFilter = @@ -180,7 +184,7 @@ class _HighlightsTabState extends State { ), const SizedBox(width: 6), SavedFilterChip( - label: 'አዲስ', + label: s.savedFilterNT, active: _testamentFilter == 'NT', onTap: () => setState(() { _testamentFilter = @@ -192,7 +196,7 @@ class _HighlightsTabState extends State { if (availableBookIds.length > 1) ...[ const SizedBox(width: 6), SavedFilterChip( - label: currentBookEntry?.bookNameAm ?? 'ሁሉም', + label: currentBookEntry?.bookNameAm ?? s.savedFilterAll, active: _bookFilter != null, trailing: Icons.expand_more_rounded, onTap: _showBookPicker, @@ -202,8 +206,8 @@ class _HighlightsTabState extends State { const SizedBox(width: 6), SavedFilterChip( label: _chapterFilter != null - ? 'ምዕ. $_chapterFilter' - : 'ሁሉም ምዕ.', + ? s.savedFilterChapter(_chapterFilter!) + : s.savedFilterAllChapters, active: _chapterFilter != null, trailing: Icons.expand_more_rounded, onTap: _showChapterPicker, diff --git a/lib/features/saved/presentation/pages/notes_tab.dart b/lib/features/saved/presentation/pages/notes_tab.dart index e918e6f..1d25365 100644 --- a/lib/features/saved/presentation/pages/notes_tab.dart +++ b/lib/features/saved/presentation/pages/notes_tab.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/app_color_scheme.dart'; import 'saved_common.dart'; @@ -64,14 +65,15 @@ class _NotesTabState extends State { .toList() ..sort((a, b) => a.bookNumber.compareTo(b.bookNumber)); + final s = L10n.of(context); showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20))), builder: (_) => AnnotationPickerSheet( - title: 'መጽሐፍ ምረጥ', + title: s.savedPickBook, items: [ - const AnnotationPickerItem(id: null, label: 'ሁሉም መጻሕፍ'), + AnnotationPickerItem(id: null, label: s.savedPickAllBooks), ...books.map( (e) => AnnotationPickerItem(id: e.bookNameEn, label: e.bookNameAm)), ], @@ -90,17 +92,18 @@ class _NotesTabState extends State { void _showChapterPicker() { if (_bookFilter == null) return; final chapters = _availableChapters.toList()..sort(); + final s = L10n.of(context); showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20))), builder: (_) => AnnotationPickerSheet( - title: 'ምዕራፍ ምረጥ', + title: s.savedPickChapter, items: [ - const AnnotationPickerItem(id: null, label: 'ሁሉም ምዕራፍ'), - ...chapters - .map((ch) => AnnotationPickerItem(id: ch, label: 'ምዕ. $ch')), + AnnotationPickerItem(id: null, label: s.savedPickAllChapters), + ...chapters.map( + (ch) => AnnotationPickerItem(id: ch, label: s.savedFilterChapter(ch))), ], selectedId: _chapterFilter, onSelect: (id) { @@ -113,6 +116,7 @@ class _NotesTabState extends State { @override Widget build(BuildContext context) { + final s = L10n.of(context); final items = _filtered; final availableBookIds = _availableBookIds; final availableChapters = _availableChapters; @@ -133,13 +137,13 @@ class _NotesTabState extends State { child: Row( children: [ SavedFilterChip( - label: 'ሁሉም', + label: s.savedFilterAll, active: _testamentFilter == null, onTap: () => setState(() => _testamentFilter = null), ), const SizedBox(width: 6), SavedFilterChip( - label: 'ብሉይ', + label: s.savedFilterOT, active: _testamentFilter == 'OT', onTap: () => setState(() { _testamentFilter = @@ -150,7 +154,7 @@ class _NotesTabState extends State { ), const SizedBox(width: 6), SavedFilterChip( - label: 'አዲስ', + label: s.savedFilterNT, active: _testamentFilter == 'NT', onTap: () => setState(() { _testamentFilter = @@ -162,7 +166,7 @@ class _NotesTabState extends State { if (availableBookIds.length > 1) ...[ const SizedBox(width: 6), SavedFilterChip( - label: currentBookEntry?.bookNameAm ?? 'ሁሉም', + label: currentBookEntry?.bookNameAm ?? s.savedFilterAll, active: _bookFilter != null, trailing: Icons.expand_more_rounded, onTap: _showBookPicker, @@ -172,8 +176,8 @@ class _NotesTabState extends State { const SizedBox(width: 6), SavedFilterChip( label: _chapterFilter != null - ? 'ምዕ. $_chapterFilter' - : 'ሁሉም ምዕ.', + ? s.savedFilterChapter(_chapterFilter!) + : s.savedFilterAllChapters, active: _chapterFilter != null, trailing: Icons.expand_more_rounded, onTap: _showChapterPicker, diff --git a/lib/features/saved/presentation/pages/saved_common.dart b/lib/features/saved/presentation/pages/saved_common.dart index 6689636..a80d9ab 100644 --- a/lib/features/saved/presentation/pages/saved_common.dart +++ b/lib/features/saved/presentation/pages/saved_common.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/app_color_scheme.dart'; import '../../../../core/theme/app_typography.dart'; import '../../../books/data/models/book_index_entry.dart'; @@ -41,15 +42,16 @@ class AnnotationCard extends StatelessWidget { final int tab; final VoidCallback onTap; - String _daysAgo() { + String _daysAgo(AppStrings s) { final diff = DateTime.now().difference(item.createdAt); - if (diff.inDays == 0) return 'ዛሬ'; - if (diff.inDays == 1) return 'ትናንት'; - return '${diff.inDays} ቀን'; + if (diff.inDays == 0) return s.savedToday; + if (diff.inDays == 1) return s.savedYesterday; + return s.savedDaysAgo(diff.inDays); } @override Widget build(BuildContext context) { + final s = L10n.of(context); final c = context.colors; final accent = tab == 0 && item.highlightColor != null ? item.highlightColor! @@ -108,7 +110,7 @@ class AnnotationCard extends StatelessWidget { ), const Spacer(), Text( - _daysAgo(), + _daysAgo(s), style: AppTypography.amharicCaption.copyWith( fontSize: 11, color: c.textCaption, @@ -248,21 +250,22 @@ class AnnotationEmptyState extends StatelessWidget { @override Widget build(BuildContext context) { + final s = L10n.of(context); final (icon, label, hint) = switch (tab) { 0 => ( Icons.format_color_fill_rounded, - 'ምንም ምልክቶ የለም', - 'ምንባብ ሲያነቡ ቁጥር ጎልቶ ይሰምጡ' + s.savedHighlightsEmpty, + s.savedHighlightsEmptyHint, ), 1 => ( Icons.bookmark_border_rounded, - 'ምንም ክታቦ የለም', - 'ምንባብ ሲያነቡ ቁጥር ያቆዩ' + s.savedBookmarksEmpty, + s.savedBookmarksEmptyHint, ), _ => ( Icons.sticky_note_2_outlined, - 'ምንም ማስታወሻ የለም', - 'ምንባብ ሲያነቡ ማስታወሻ ይጻፉ' + s.savedNotesEmpty, + s.savedNotesEmptyHint, ), }; diff --git a/lib/features/saved/presentation/pages/saved_screen.dart b/lib/features/saved/presentation/pages/saved_screen.dart index a8dab40..aba92ae 100644 --- a/lib/features/saved/presentation/pages/saved_screen.dart +++ b/lib/features/saved/presentation/pages/saved_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/annotations/annotation_models.dart'; import '../../../../core/auth/auth_state.dart'; +import '../../../../core/l10n/l10n.dart'; import '../../../../core/services/repository_provider.dart'; import '../../../../core/sync/sync_repository.dart'; import '../../../../core/sync/sync_service.dart'; @@ -178,6 +179,7 @@ class _SavedScreenState extends ConsumerState { @override Widget build(BuildContext context) { final c = context.colors; + final s = L10n.of(context); return SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -189,7 +191,7 @@ class _SavedScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'ያቀቡት', + s.savedScreenSubtitle, style: AppTypography.amharicCaption.copyWith( color: c.textMuted, fontSize: 12, @@ -198,7 +200,7 @@ class _SavedScreenState extends ConsumerState { ), const SizedBox(height: 2), Text( - 'ስብስቤ', + s.savedScreenTitle, style: AppTypography.amharicHeading.copyWith( color: c.textOnParchment, ), @@ -214,21 +216,21 @@ class _SavedScreenState extends ConsumerState { child: Row( children: [ _TabLabel( - label: 'ምልክቶ', + label: s.savedTabHighlights, count: _highlights.length, active: _tab == 0, onTap: () => setState(() => _tab = 0), ), const SizedBox(width: 24), _TabLabel( - label: 'ክታቦ', + label: s.savedTabBookmarks, count: _bookmarks.length, active: _tab == 1, onTap: () => setState(() => _tab = 1), ), const SizedBox(width: 24), _TabLabel( - label: 'ማስታወሻ', + label: s.savedTabNotes, count: _notes.length, active: _tab == 2, onTap: () => setState(() => _tab = 2), From 18f2e56c0d58d09ab9a2a4d255c929f26e4841b9 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Wed, 20 May 2026 16:39:53 +0300 Subject: [PATCH 19/33] feat: replace unauthenticated avatar placeholder with sign-in button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When no user is signed in the header now shows a pill-shaped "Sign In" button that navigates to the login screen, removing the stray 'ን' initial. _Avatar is now non-nullable so it only renders for authenticated users. Co-Authored-By: Claude Sonnet 4.6 --- .../presentation/widgets/home_header.dart | 57 ++++++++++++++----- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/lib/features/home/presentation/widgets/home_header.dart b/lib/features/home/presentation/widgets/home_header.dart index 78b75d8..31dfa00 100644 --- a/lib/features/home/presentation/widgets/home_header.dart +++ b/lib/features/home/presentation/widgets/home_header.dart @@ -6,6 +6,7 @@ import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/app_color_scheme.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_typography.dart'; +import '../../../auth/presentation/pages/login_screen.dart'; import '../../../auth/presentation/pages/profile_screen.dart'; class HomeHeader extends ConsumerWidget { @@ -50,12 +51,15 @@ class HomeHeader extends ConsumerWidget { ), ), const SizedBox(width: 12), - GestureDetector( - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const ProfileScreen()), + if (user == null) + _SignInButton(colors: c) + else + GestureDetector( + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ProfileScreen()), + ), + child: _Avatar(user: user, colors: c), ), - child: _Avatar(user: user, colors: c), - ), ], ), ); @@ -70,27 +74,56 @@ class HomeHeader extends ConsumerWidget { // ── Avatar ───────────────────────────────────────────────────────────────────── +class _SignInButton extends StatelessWidget { + const _SignInButton({required this.colors}); + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + final s = L10n.of(context); + return GestureDetector( + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LoginScreen()), + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: c.primary, + borderRadius: BorderRadius.circular(25), + ), + child: Text( + s.loginButton, + style: AppTypography.amharicLabel.copyWith( + color: c.textOnDark, + fontSize: 13, + ), + ), + ), + ); + } +} + class _Avatar extends StatelessWidget { const _Avatar({required this.user, required this.colors}); - final UserProfile? user; + final UserProfile user; final AppColorScheme colors; @override Widget build(BuildContext context) { final c = colors; - // Google / social login — show profile picture - if (user?.avatar != null) { + if (user.avatar != null) { return ClipOval( child: SizedBox( width: 50, height: 50, child: Image.network( - user!.avatar!, + user.avatar!, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => _InitialsCircle( - letter: _initial(user!.name), + letter: _initial(user.name), colors: c, ), ), @@ -98,9 +131,7 @@ class _Avatar extends StatelessWidget { ); } - // Email/password login — first letter of name - final letter = user != null ? _initial(user!.name) : 'ን'; - return _InitialsCircle(letter: letter, colors: c); + return _InitialsCircle(letter: _initial(user.name), colors: c); } static String _initial(String name) => From 8eede9c9323141b96154a868371124195af08700 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Wed, 20 May 2026 17:29:52 +0300 Subject: [PATCH 20/33] feat: use official Google SVG assets and Facebook blue for social buttons - Add flutter_svg dependency and declare assets/signin/ in pubspec - Replace hand-drawn Google logo painter with the official web_light_sq_na / web_dark_sq_na SVG assets, switching on theme brightness - Set Facebook button background to the official Facebook blue (#1877F2) instead of the generic dark color Co-Authored-By: Claude Sonnet 4.6 --- .../auth/presentation/pages/login_screen.dart | 75 ++++--------------- pubspec.lock | 56 ++++++++++++++ pubspec.yaml | 2 + 3 files changed, 72 insertions(+), 61 deletions(-) diff --git a/lib/features/auth/presentation/pages/login_screen.dart b/lib/features/auth/presentation/pages/login_screen.dart index ef4dcb5..46603c4 100644 --- a/lib/features/auth/presentation/pages/login_screen.dart +++ b/lib/features/auth/presentation/pages/login_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import '../../../../core/api/api_client.dart'; import '../../../../core/auth/auth_state.dart'; import '../../../../core/constants/app_icons.dart'; @@ -606,6 +607,7 @@ class _SocialRow extends StatelessWidget { @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; return Row( children: [ Expanded( @@ -614,7 +616,13 @@ class _SocialRow extends StatelessWidget { isLoading: isGoogleLoading, label: 'Google', dark: false, - icon: _GoogleLogo(), + icon: SvgPicture.asset( + isDark + ? 'assets/signin/web_dark_sq_na.svg' + : 'assets/signin/web_light_sq_na.svg', + width: 22, + height: 22, + ), ), ), const SizedBox(width: 12), @@ -624,6 +632,7 @@ class _SocialRow extends StatelessWidget { isLoading: false, label: 'Facebook', dark: true, + backgroundColor: const Color(0xFF1877F2), icon: const Icon(Icons.facebook_rounded, color: Colors.white, size: 20), ), ), @@ -639,6 +648,7 @@ class _SocialButton extends StatelessWidget { required this.label, required this.dark, required this.icon, + this.backgroundColor, }); final VoidCallback? onTap; @@ -646,16 +656,18 @@ class _SocialButton extends StatelessWidget { final String label; final bool dark; final Widget icon; + final Color? backgroundColor; @override Widget build(BuildContext context) { final c = context.colors; + final bgColor = backgroundColor ?? (dark ? const Color(0xFF1A1A1A) : c.surface); return GestureDetector( onTap: onTap, child: Container( height: 50, decoration: BoxDecoration( - color: dark ? const Color(0xFF1A1A1A) : c.surface, + color: bgColor, borderRadius: BorderRadius.circular(12), border: dark ? null : Border.all(color: c.borderSubtle, width: 1.2), ), @@ -688,65 +700,6 @@ class _SocialButton extends StatelessWidget { } } -class _GoogleLogo extends StatelessWidget { - @override - Widget build(BuildContext context) { - return const _GooglePainter(); - } -} - -class _GooglePainter extends StatelessWidget { - const _GooglePainter(); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 18, - height: 18, - child: CustomPaint(painter: _GoogleLogoPainter()), - ); - } -} - -class _GoogleLogoPainter extends CustomPainter { - @override - void paint(Canvas canvas, Size size) { - final w = size.width; - final h = size.height; - final stroke = w * 0.17; - - final paint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = stroke - ..strokeCap = StrokeCap.round; - - final rect = Rect.fromLTWH(stroke / 2, stroke / 2, - w - stroke, h - stroke); - - paint.color = const Color(0xFF4285F4); - canvas.drawArc(rect, -1.57, 2.8, false, paint); - - paint.color = const Color(0xFFEA4335); - canvas.drawArc(rect, 1.57 + 0.37, 1.2, false, paint); - - paint.color = const Color(0xFFFBBC05); - canvas.drawArc(rect, 3.14, 0.74, false, paint); - - paint.color = const Color(0xFF34A853); - canvas.drawArc(rect, 3.93, 0.83, false, paint); - - final center = Offset(w / 2, h / 2); - canvas.drawRect( - Rect.fromLTRB( - center.dx, center.dy - h * 0.1, w - stroke / 2, center.dy + h * 0.1), - Paint()..color = const Color(0xFF4285F4), - ); - } - - @override - bool shouldRepaint(covariant CustomPainter old) => false; -} - // ── Register prompt ─────────────────────────────────────────────────────────── class _RegisterPrompt extends StatelessWidget { diff --git a/pubspec.lock b/pubspec.lock index 879b164..22029ed 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -222,6 +222,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.1" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -456,6 +464,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: transitive description: @@ -504,6 +520,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -733,6 +757,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: b9b3f391857781aa96acacef96066f2f49b4cd03cf9fce3ca4d8da2ef5ea129e + url: "https://pub.dev" + source: hosted + version: "1.2.3" vector_math: dependency: transitive description: @@ -773,6 +821,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 321deae..3efbf4c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,7 @@ dependencies: http: ^1.2.0 flutter_secure_storage: ^10.2.0 google_sign_in: ^6.0.0 + flutter_svg: ^2.0.0 dev_dependencies: flutter_test: @@ -70,6 +71,7 @@ flutter: assets: - assets/bibledata/ + - assets/signin/ fonts: - family: Shiromeda From df7537f614a90fa9174c78f70a09bb2ed8096c96 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Wed, 20 May 2026 17:32:40 +0300 Subject: [PATCH 21/33] feat: add SVG assets for dark and light sign-in buttons --- assets/signin/web_dark_sq_na.svg | 15 +++++++++++++++ assets/signin/web_light_sq_na.svg | 15 +++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 assets/signin/web_dark_sq_na.svg create mode 100644 assets/signin/web_light_sq_na.svg diff --git a/assets/signin/web_dark_sq_na.svg b/assets/signin/web_dark_sq_na.svg new file mode 100644 index 0000000..191774b --- /dev/null +++ b/assets/signin/web_dark_sq_na.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/signin/web_light_sq_na.svg b/assets/signin/web_light_sq_na.svg new file mode 100644 index 0000000..814e100 --- /dev/null +++ b/assets/signin/web_light_sq_na.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + From 7f45ee49df578492f2973647cb7a15119f445e34 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Wed, 20 May 2026 17:33:59 +0300 Subject: [PATCH 22/33] fix: always show Google account picker on sign-in Sign out of any cached Google session before calling signIn() so the account chooser sheet is always presented instead of silently reusing the last account. Co-Authored-By: Claude Sonnet 4.6 --- lib/core/auth/auth_repository.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/core/auth/auth_repository.dart b/lib/core/auth/auth_repository.dart index 32f23a9..4609159 100644 --- a/lib/core/auth/auth_repository.dart +++ b/lib/core/auth/auth_repository.dart @@ -69,6 +69,7 @@ class AuthRepository { Future<(String token, String? photoUrl)> signInWithGoogle() async { final googleSignIn = GoogleSignIn(serverClientId: _webClientId); + await googleSignIn.signOut(); final account = await googleSignIn.signIn(); if (account == null) throw const ApiException('Google sign-in cancelled'); From b8581ec238c7ba02add01dda95f70a136114fbc4 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Wed, 20 May 2026 17:47:50 +0300 Subject: [PATCH 23/33] =?UTF-8?q?feat:=20full=20profile=20editing=20?= =?UTF-8?q?=E2=80=94=20avatar=20upload,=20name/email,=20password=20change,?= =?UTF-8?q?=20logout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data layer: - UserProfile gains `provider` field ('email'|'google') + isGoogleUser helper - ApiClient gains multipart uploadFile() method - AuthRepository gains updateProfile(), changePassword(), uploadAvatar() - AuthNotifier exposes the three new actions and dart:io import UI: - ProfileScreen rewritten as ConsumerStatefulWidget with inline editing: - Avatar section shows camera badge and 'Change Photo' link for email users; Google users see avatar read-only - Account Info section with First Name, Last Name, Email fields; email field is locked with 🔒 badge for Google accounts + info notice - Security section (email users only) — Change Password row opens a bottom sheet with current/new/confirm password fields - Preferences section with language chips and night-mode toggle - Save Changes button appears only when there are unsaved edits - Logout button rendered as a subtle pill at the bottom of the screen; the ⋯ menu now only contains Delete Account - MeScreen (Settings tab) gains a Logout button below Reminders, visible only when the user is authenticated - 18 new l10n keys in app_strings / am_strings / en_strings Co-Authored-By: Claude Sonnet 4.6 --- lib/core/api/api_client.dart | 15 + lib/core/auth/auth_repository.dart | 31 + lib/core/auth/auth_state.dart | 31 + lib/core/auth/user_profile.dart | 10 +- lib/core/l10n/am_strings.dart | 19 + lib/core/l10n/app_strings.dart | 19 + lib/core/l10n/en_strings.dart | 19 + .../presentation/pages/profile_screen.dart | 1205 ++++++++++++++--- .../me/presentation/pages/me_screen.dart | 46 + pubspec.lock | 104 ++ pubspec.yaml | 1 + 11 files changed, 1295 insertions(+), 205 deletions(-) diff --git a/lib/core/api/api_client.dart b/lib/core/api/api_client.dart index c161fdf..39d1534 100644 --- a/lib/core/api/api_client.dart +++ b/lib/core/api/api_client.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:http/http.dart' as http; const _baseUrl = 'https://eotcbiblebe.onrender.com/api/v1'; @@ -57,6 +58,20 @@ class ApiClient { return _parse(res); } + Future> uploadFile( + String path, { + required File file, + String field = 'avatar', + String? token, + }) async { + final request = http.MultipartRequest('POST', Uri.parse('$_baseUrl$path')); + if (token != null) request.headers['Authorization'] = 'Bearer $token'; + request.files.add(await http.MultipartFile.fromPath(field, file.path)); + final streamed = await request.send(); + final response = await http.Response.fromStream(streamed); + return _parse(response); + } + Future> delete(String path, {String? token}) async { final res = await http.delete( Uri.parse('$_baseUrl$path'), diff --git a/lib/core/auth/auth_repository.dart b/lib/core/auth/auth_repository.dart index 4609159..fd0d455 100644 --- a/lib/core/auth/auth_repository.dart +++ b/lib/core/auth/auth_repository.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:google_sign_in/google_sign_in.dart'; import '../api/api_client.dart'; import 'user_profile.dart'; @@ -65,6 +66,36 @@ class AuthRepository { await _api.delete('/auth/account', token: token); } + Future updateProfile( + String token, { + required String name, + required String email, + }) async { + final res = await _api.put( + '/auth/profile', + body: {'name': name, 'email': email}, + token: token, + ); + return UserProfile.fromJson(res['data']['user'] as Map); + } + + Future changePassword( + String token, { + required String currentPassword, + required String newPassword, + }) async { + await _api.post( + '/auth/change-password', + body: {'currentPassword': currentPassword, 'newPassword': newPassword}, + token: token, + ); + } + + Future uploadAvatar(String token, File file) async { + final res = await _api.uploadFile('/auth/avatar', file: file, token: token); + return UserProfile.fromJson(res['data']['user'] as Map); + } + // ── Social auth ─────────────────────────────────────────────────────────── Future<(String token, String? photoUrl)> signInWithGoogle() async { diff --git a/lib/core/auth/auth_state.dart b/lib/core/auth/auth_state.dart index 3ad11ed..ef9cd27 100644 --- a/lib/core/auth/auth_state.dart +++ b/lib/core/auth/auth_state.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../api/api_client.dart'; @@ -137,4 +138,34 @@ class AuthNotifier extends StateNotifier { await _storage.clearToken(); state = const AuthState.unauthenticated(); } + + Future updateProfile({ + required String name, + required String email, + }) async { + final token = state.token; + if (token == null) return; + final updated = await _repo.updateProfile(token, name: name, email: email); + state = AuthState.authenticated(updated, token); + } + + Future changePassword({ + required String currentPassword, + required String newPassword, + }) async { + final token = state.token; + if (token == null) return; + await _repo.changePassword(token, + currentPassword: currentPassword, newPassword: newPassword); + } + + Future uploadAvatar(File file) async { + final token = state.token; + if (token == null) return; + final updated = await _repo.uploadAvatar(token, file); + final withAvatar = updated.avatar != null + ? updated + : state.user!.copyWith(avatar: null); + state = AuthState.authenticated(withAvatar, token); + } } diff --git a/lib/core/auth/user_profile.dart b/lib/core/auth/user_profile.dart index cfa869d..81c30d0 100644 --- a/lib/core/auth/user_profile.dart +++ b/lib/core/auth/user_profile.dart @@ -4,12 +4,16 @@ class UserProfile { required this.name, required this.email, this.avatar, + this.provider = 'email', }); final String id; final String name; final String email; final String? avatar; + final String provider; + + bool get isGoogleUser => provider == 'google'; factory UserProfile.fromJson(Map json) { return UserProfile( @@ -17,15 +21,17 @@ class UserProfile { name: json['name'] as String, email: json['email'] as String, avatar: json['avatar'] as String?, + provider: json['provider'] as String? ?? 'email', ); } - UserProfile copyWith({String? name, String? avatar}) { + UserProfile copyWith({String? name, String? email, String? avatar}) { return UserProfile( id: id, name: name ?? this.name, - email: email, + email: email ?? this.email, avatar: avatar ?? this.avatar, + provider: provider, ); } } diff --git a/lib/core/l10n/am_strings.dart b/lib/core/l10n/am_strings.dart index 6eafb76..0720a05 100644 --- a/lib/core/l10n/am_strings.dart +++ b/lib/core/l10n/am_strings.dart @@ -310,6 +310,25 @@ class AmStrings extends AppStrings { @override String get achievementPsalmTitle => 'የምዝሙሩ'; @override String get achievementPsalmSub => 'Psalm Reader'; + // ── Profile editing ─────────────────────────────────────────────────────── + @override String get profileFirstName => 'ስም'; + @override String get profileLastName => 'ያባት ስም'; + @override String get profileSaveChanges => 'ለውጦች አስቀምጥ'; + @override String get profileSaved => 'ፕሮፋይሉ ተዘምኗል'; + @override String get profileSectionInfo => 'የመለያ መረጃ'; + @override String get profileSectionSecurity => 'ደህንነት'; + @override String get profileSectionPreferences => 'ምርጫዎች'; + @override String get profileChangePhoto => 'ፎቶ ቀይር'; + @override String get profileGoogleNote => 'Google አካውንት ነዎት — ኢሜይሉ ሊቀየር አይችልም'; + @override String get profileChangePassword => 'ይለፍ ቃሉን ቀይር'; + @override String get profileCurrentPassword => 'አሁን ያለ ይለፍ ቃሉ'; + @override String get profileNewPassword => 'አዲስ ይለፍ ቃሉ'; + @override String get profileConfirmNewPassword => 'ይለፍ ቃሉን ያረጋግጡ'; + @override String get profileUpdatePassword => 'ይለፍ ቃሉን ዘምን'; + @override String get profilePasswordChanged => 'ይለፍ ቃሉ ተቀይሯል'; + @override String get profilePasswordMismatch => 'ይለፍ ቃሎቹ አይዛመዱም'; + @override String get profileUpdateFailed => 'ማዘምን አልተሳካም። እንደገና ይሞክሩ።'; + // ── Saved / Collection screen ───────────────────────────────────────────── @override String get savedScreenTitle => 'ስብስቤ'; @override String get savedScreenSubtitle => 'ያቀቡት'; diff --git a/lib/core/l10n/app_strings.dart b/lib/core/l10n/app_strings.dart index 48fff2e..f4f9d8b 100644 --- a/lib/core/l10n/app_strings.dart +++ b/lib/core/l10n/app_strings.dart @@ -228,6 +228,25 @@ String get searchScopeTitle; String get achievementPsalmTitle; String get achievementPsalmSub; + // ── Profile editing ─────────────────────────────────────────────────────── + String get profileFirstName; + String get profileLastName; + String get profileSaveChanges; + String get profileSaved; + String get profileSectionInfo; + String get profileSectionSecurity; + String get profileSectionPreferences; + String get profileChangePhoto; + String get profileGoogleNote; + String get profileChangePassword; + String get profileCurrentPassword; + String get profileNewPassword; + String get profileConfirmNewPassword; + String get profileUpdatePassword; + String get profilePasswordChanged; + String get profilePasswordMismatch; + String get profileUpdateFailed; + // ── Saved / Collection screen ───────────────────────────────────────────── String get savedScreenTitle; String get savedScreenSubtitle; diff --git a/lib/core/l10n/en_strings.dart b/lib/core/l10n/en_strings.dart index b34b863..6bb4f4a 100644 --- a/lib/core/l10n/en_strings.dart +++ b/lib/core/l10n/en_strings.dart @@ -220,6 +220,25 @@ class EnStrings extends AppStrings { @override String get achievementPsalmTitle => 'Psalm Reader'; @override String get achievementPsalmSub => 'Psalm Reader'; + // ── Profile editing ─────────────────────────────────────────────────────── + @override String get profileFirstName => 'First Name'; + @override String get profileLastName => 'Last Name'; + @override String get profileSaveChanges => 'Save Changes'; + @override String get profileSaved => 'Profile updated'; + @override String get profileSectionInfo => 'Account Info'; + @override String get profileSectionSecurity => 'Security'; + @override String get profileSectionPreferences => 'Preferences'; + @override String get profileChangePhoto => 'Change Photo'; + @override String get profileGoogleNote => 'Signed in with Google — email cannot be changed'; + @override String get profileChangePassword => 'Change Password'; + @override String get profileCurrentPassword => 'Current Password'; + @override String get profileNewPassword => 'New Password'; + @override String get profileConfirmNewPassword => 'Confirm New Password'; + @override String get profileUpdatePassword => 'Update Password'; + @override String get profilePasswordChanged => 'Password changed successfully'; + @override String get profilePasswordMismatch => 'Passwords do not match'; + @override String get profileUpdateFailed => 'Update failed. Please try again.'; + // ── Saved / Collection screen ───────────────────────────────────────────── @override String get savedScreenTitle => 'Collection'; @override String get savedScreenSubtitle => 'Your saved'; diff --git a/lib/features/auth/presentation/pages/profile_screen.dart b/lib/features/auth/presentation/pages/profile_screen.dart index d2605ea..11dd68e 100644 --- a/lib/features/auth/presentation/pages/profile_screen.dart +++ b/lib/features/auth/presentation/pages/profile_screen.dart @@ -1,6 +1,9 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:kenat/kenat.dart'; +import '../../../../core/api/api_client.dart'; import '../../../../core/auth/auth_state.dart'; import '../../../../core/auth/user_profile.dart'; import '../../../../core/constants/app_icons.dart'; @@ -23,14 +26,137 @@ final _bookmarkCountProvider = FutureProvider.autoDispose((ref) async { // ── Profile screen ───────────────────────────────────────────────────────── -class ProfileScreen extends ConsumerWidget { +class ProfileScreen extends ConsumerStatefulWidget { const ProfileScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _ProfileScreenState(); +} + +class _ProfileScreenState extends ConsumerState { + late final TextEditingController _firstNameCtrl; + late final TextEditingController _lastNameCtrl; + late final TextEditingController _emailCtrl; + + bool _isSaving = false; + bool _isUploadingAvatar = false; + String? _saveError; + String? _savedName; + String? _savedEmail; + + @override + void initState() { + super.initState(); + final user = ref.read(authStateProvider).user; + final parts = (user?.name ?? '').split(' '); + _savedName = user?.name; + _savedEmail = user?.email; + _firstNameCtrl = TextEditingController(text: parts.first); + _lastNameCtrl = TextEditingController(text: parts.skip(1).join(' ')); + _emailCtrl = TextEditingController(text: user?.email ?? ''); + } + + @override + void dispose() { + _firstNameCtrl.dispose(); + _lastNameCtrl.dispose(); + _emailCtrl.dispose(); + super.dispose(); + } + + bool get _hasChanges { + final user = ref.read(authStateProvider).user; + if (user == null) return false; + final parts = (_savedName ?? user.name).split(' '); + final origFirst = parts.first; + final origLast = parts.skip(1).join(' '); + final emailChanged = + !user.isGoogleUser && _emailCtrl.text.trim() != (_savedEmail ?? user.email); + return _firstNameCtrl.text.trim() != origFirst || + _lastNameCtrl.text.trim() != origLast || + emailChanged; + } + + Future _save() async { + final user = ref.read(authStateProvider).user; + if (user == null) return; + final firstName = _firstNameCtrl.text.trim(); + final lastName = _lastNameCtrl.text.trim(); + final name = '$firstName $lastName'.trim(); + final email = + user.isGoogleUser ? user.email : _emailCtrl.text.trim(); + if (name.isEmpty) return; + + setState(() { _isSaving = true; _saveError = null; }); + try { + await ref + .read(authStateProvider.notifier) + .updateProfile(name: name, email: email); + if (mounted) { + final updated = ref.read(authStateProvider).user!; + setState(() { + _savedName = updated.name; + _savedEmail = updated.email; + }); + ScaffoldMessenger.of(context).showSnackBar( + _snackBar(L10n.of(context).profileSaved, const Color(0xFF2E7D32)), + ); + } + } on ApiException catch (e) { + setState(() => _saveError = e.message); + } catch (_) { + setState(() => _saveError = L10n.of(context).authConnectionError); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + Future _pickAvatar() async { + final picker = ImagePicker(); + final picked = + await picker.pickImage(source: ImageSource.gallery, imageQuality: 85); + if (picked == null || !mounted) return; + setState(() => _isUploadingAvatar = true); + try { + await ref.read(authStateProvider.notifier).uploadAvatar(File(picked.path)); + } on ApiException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(_snackBar(e.message, context.colors.primary)); + } + } catch (_) { + // Network error — ignore silently + } finally { + if (mounted) setState(() => _isUploadingAvatar = false); + } + } + + void _showChangePassword() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: context.colors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => const _ChangePasswordSheet(), + ); + } + + SnackBar _snackBar(String msg, Color bg) => SnackBar( + content: Text(msg, + style: + AppTypography.amharicCaption.copyWith(color: Colors.white)), + backgroundColor: bg, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ); + + @override + Widget build(BuildContext context) { final c = context.colors; - final authState = ref.watch(authStateProvider); - final user = authState.user; + final s = L10n.of(context); + final user = ref.watch(authStateProvider).user; if (user == null) return const SizedBox.shrink(); return Scaffold( @@ -41,13 +167,109 @@ class ProfileScreen extends ConsumerWidget { slivers: [ SliverToBoxAdapter(child: _TopBar(user: user, colors: c)), SliverToBoxAdapter(child: const SizedBox(height: 24)), - SliverToBoxAdapter(child: _AvatarSection(user: user, colors: c)), + SliverToBoxAdapter( + child: _EditableAvatarSection( + user: user, + colors: c, + isUploading: _isUploadingAvatar, + onTap: user.isGoogleUser ? null : _pickAvatar, + ), + ), SliverToBoxAdapter(child: const SizedBox(height: 20)), SliverToBoxAdapter(child: _StatsRow(colors: c)), - SliverToBoxAdapter(child: const SizedBox(height: 20)), - SliverToBoxAdapter(child: _ActionButtons(colors: c)), + SliverToBoxAdapter(child: const SizedBox(height: 24)), + + // Account Info + SliverToBoxAdapter( + child: _FormSection( + label: s.profileSectionInfo, + enLabel: 'ACCOUNT', + children: [ + _FieldRow( + controller: _firstNameCtrl, + label: s.profileFirstName, + enabled: !_isSaving, + onChanged: (_) => setState(() {}), + ), + _FieldRow( + controller: _lastNameCtrl, + label: s.profileLastName, + enabled: !_isSaving, + onChanged: (_) => setState(() {}), + ), + _FieldRow( + controller: _emailCtrl, + label: s.authEmail, + enabled: !_isSaving && !user.isGoogleUser, + locked: user.isGoogleUser, + keyboardType: TextInputType.emailAddress, + onChanged: (_) => setState(() {}), + ), + if (user.isGoogleUser) _GoogleNotice(colors: c, s: s), + ], + ), + ), + + // Security — email users only + if (!user.isGoogleUser) ...[ + SliverToBoxAdapter(child: const SizedBox(height: 16)), + SliverToBoxAdapter( + child: _FormSection( + label: s.profileSectionSecurity, + enLabel: 'SECURITY', + children: [ + _ArrowTile( + icon: Icons.lock_outline_rounded, + label: s.profileChangePassword, + onTap: _showChangePassword, + ), + ], + ), + ), + ], + + SliverToBoxAdapter(child: const SizedBox(height: 16)), + + // Preferences + SliverToBoxAdapter( + child: _FormSection( + label: s.profileSectionPreferences, + enLabel: 'PREFERENCES', + children: [ + _LanguageRow(s: s), + _NightModeRow(), + ], + ), + ), + + if (_saveError != null) ...[ + SliverToBoxAdapter(child: const SizedBox(height: 12)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _ErrorBanner(message: _saveError!), + ), + ), + ], + + if (_hasChanges) ...[ + SliverToBoxAdapter(child: const SizedBox(height: 24)), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _SaveButton( + label: s.profileSaveChanges, + isLoading: _isSaving, + onTap: _isSaving ? null : _save, + ), + ), + ), + ], + SliverToBoxAdapter(child: const SizedBox(height: 28)), SliverToBoxAdapter(child: _AchievementsSection(colors: c)), + SliverToBoxAdapter(child: const SizedBox(height: 24)), + SliverToBoxAdapter(child: _LogoutTile(colors: c, s: s)), SliverToBoxAdapter(child: const SizedBox(height: 32)), ], ), @@ -132,88 +354,55 @@ class _MenuButton extends ConsumerWidget { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (_) => _ProfileMenuSheet(colors: c, ref: ref, context: context), - ); - } -} - -class _ProfileMenuSheet extends StatelessWidget { - const _ProfileMenuSheet({ - required this.colors, - required this.ref, - required this.context, - }); - final AppColorScheme colors; - final WidgetRef ref; - final BuildContext context; - - @override - Widget build(BuildContext ctx) { - final c = colors; - return SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 36, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: c.borderSubtle, - borderRadius: BorderRadius.circular(2), + builder: (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: c.borderSubtle, + borderRadius: BorderRadius.circular(2), + ), ), - ), - _SheetTile( - icon: Icons.logout_rounded, - label: L10n.of(context).profileLogout, - color: c.textBody, - onTap: () async { - Navigator.pop(ctx); - await ref.read(authStateProvider.notifier).logout(); - if (context.mounted) Navigator.of(context).pop(); - }, - ), - Divider(color: c.borderSubtle, height: 1, indent: 16), - _SheetTile( - icon: Icons.delete_outline_rounded, - label: L10n.of(context).profileDeleteAccount, - color: c.primary, - onTap: () { - Navigator.pop(ctx); - _confirmDelete(context, ref, c); - }, - ), - ], + _SheetTile( + icon: Icons.delete_outline_rounded, + label: L10n.of(context).profileDeleteAccount, + color: c.primary, + onTap: () { + Navigator.pop(ctx); + _confirmDelete(context, ref, c); + }, + ), + ], + ), ), ), ); } - void _confirmDelete(BuildContext ctx, WidgetRef ref, AppColorScheme c) { + void _confirmDelete( + BuildContext context, WidgetRef ref, AppColorScheme c) { showDialog( - context: ctx, + context: context, builder: (dlgCtx) => AlertDialog( backgroundColor: c.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: Text( - L10n.of(ctx).profileDeleteTitle, - style: AppTypography.amharicSubheading.copyWith( - color: c.textOnParchment, - ), - ), - content: Text( - L10n.of(ctx).profileDeleteMessage, - style: AppTypography.amharicBody.copyWith( - color: c.textMuted, - fontSize: 13, - ), - ), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Text(L10n.of(context).profileDeleteTitle, + style: AppTypography.amharicSubheading + .copyWith(color: c.textOnParchment)), + content: Text(L10n.of(context).profileDeleteMessage, + style: AppTypography.amharicBody + .copyWith(color: c.textMuted, fontSize: 13)), actions: [ TextButton( onPressed: () => Navigator.pop(dlgCtx), - child: Text(L10n.of(ctx).profileDeleteCancel, + child: Text(L10n.of(context).profileDeleteCancel, style: AppTypography.amharicLabel .copyWith(color: c.textMuted)), ), @@ -221,9 +410,9 @@ class _ProfileMenuSheet extends StatelessWidget { onPressed: () async { Navigator.pop(dlgCtx); await ref.read(authStateProvider.notifier).deleteAccount(); - if (ctx.mounted) Navigator.of(ctx).pop(); + if (context.mounted) Navigator.of(context).pop(); }, - child: Text(L10n.of(ctx).profileDeleteConfirm, + child: Text(L10n.of(context).profileDeleteConfirm, style: AppTypography.amharicLabel .copyWith(color: c.primary)), ), @@ -234,12 +423,11 @@ class _ProfileMenuSheet extends StatelessWidget { } class _SheetTile extends StatelessWidget { - const _SheetTile({ - required this.icon, - required this.label, - required this.color, - required this.onTap, - }); + const _SheetTile( + {required this.icon, + required this.label, + required this.color, + required this.onTap}); final IconData icon; final String label; final Color color; @@ -255,10 +443,8 @@ class _SheetTile extends StatelessWidget { children: [ Icon(icon, color: color, size: 20), const SizedBox(width: 14), - Text( - label, - style: AppTypography.amharicLabel.copyWith(color: color), - ), + Text(label, + style: AppTypography.amharicLabel.copyWith(color: color)), ], ), ), @@ -266,12 +452,20 @@ class _SheetTile extends StatelessWidget { } } -// ── Avatar section ───────────────────────────────────────────────────────── +// ── Editable avatar section ───────────────────────────────────────────────── + +class _EditableAvatarSection extends StatelessWidget { + const _EditableAvatarSection({ + required this.user, + required this.colors, + required this.isUploading, + required this.onTap, + }); -class _AvatarSection extends StatelessWidget { - const _AvatarSection({required this.user, required this.colors}); final UserProfile user; final AppColorScheme colors; + final bool isUploading; + final VoidCallback? onTap; String get _initial => user.name.isNotEmpty ? user.name.characters.first : '?'; @@ -306,34 +500,95 @@ class _AvatarSection extends StatelessWidget { @override Widget build(BuildContext context) { final c = colors; + final s = L10n.of(context); + + Widget avatar; + if (user.avatar != null) { + avatar = ClipOval( + child: Image.network( + user.avatar!, + width: 96, + height: 96, + fit: BoxFit.cover, + errorBuilder: (context, e, st) => _initialsCircle(c), + ), + ); + } else { + avatar = _initialsCircle(c); + } + return Column( children: [ - if (user.avatar != null) - ClipOval( - child: Image.network( - user.avatar!, - width: 96, - height: 96, - fit: BoxFit.cover, - errorBuilder: (context, e, s) => _initialsCircle(c), + Stack( + alignment: Alignment.center, + children: [ + GestureDetector( + onTap: isUploading ? null : onTap, + child: avatar, + ), + if (isUploading) + Container( + width: 96, + height: 96, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.35), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: const SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 2.5, color: Colors.white), + ), + ), + if (onTap != null && !isUploading) + Positioned( + bottom: 0, + right: 0, + child: GestureDetector( + onTap: onTap, + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: c.primary, + shape: BoxShape.circle, + border: Border.all(color: c.parchment, width: 2), + ), + alignment: Alignment.center, + child: Icon(Icons.camera_alt_rounded, + color: c.accent, size: 15), + ), + ), + ), + ], + ), + if (onTap != null) ...[ + const SizedBox(height: 8), + GestureDetector( + onTap: isUploading ? null : onTap, + child: Text( + s.profileChangePhoto, + style: AppTypography.amharicCaption.copyWith( + color: c.primary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), ), - ) - else - _initialsCircle(c), - const SizedBox(height: 14), + ), + ], + SizedBox(height: onTap != null ? 8.0 : 14.0), Text( user.name, - style: AppTypography.amharicSubheading.copyWith( - color: c.textOnParchment, - ), + style: AppTypography.amharicSubheading + .copyWith(color: c.textOnParchment), ), const SizedBox(height: 4), Text( user.email, - style: AppTypography.englishCaption.copyWith( - color: c.textMuted, - fontSize: 13, - ), + style: AppTypography.englishCaption + .copyWith(color: c.textMuted, fontSize: 13), ), const SizedBox(height: 10), Container( @@ -427,10 +682,7 @@ class _StatsRow extends ConsumerWidget { class _StatCell extends StatelessWidget { const _StatCell({ - required this.value, - required this.label, - required this.colors, - }); + required this.value, required this.label, required this.colors}); final String value; final String label; final AppColorScheme colors; @@ -441,19 +693,155 @@ class _StatCell extends StatelessWidget { return Expanded( child: Column( children: [ - Text( - value, - style: AppTypography.amharicSubheading.copyWith( - color: c.textOnParchment, - fontSize: 20, - ), - ), + Text(value, + style: AppTypography.amharicSubheading + .copyWith(color: c.textOnParchment, fontSize: 20)), const SizedBox(height: 4), + Text(label, + style: AppTypography.amharicCaption + .copyWith(color: c.textMuted, fontSize: 11)), + ], + ), + ); + } +} + +class _StatDivider extends StatelessWidget { + const _StatDivider({required this.colors}); + final AppColorScheme colors; + + @override + Widget build(BuildContext context) => + SizedBox(height: 36, child: VerticalDivider(color: colors.borderSubtle, width: 1)); +} + +// ── Form section & fields ────────────────────────────────────────────────── + +class _FormSection extends StatelessWidget { + const _FormSection({ + required this.label, + required this.enLabel, + required this.children, + }); + + final String label; + final String enLabel; + final List children; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Row( + children: [ + Text(label, + style: AppTypography.amharicLabel + .copyWith(color: c.textMuted)), + Text(' · ', + style: AppTypography.englishLabel + .copyWith(color: c.textMuted)), + Text( + enLabel, + style: AppTypography.englishLabel.copyWith( + color: c.textCaption, + letterSpacing: 1.4, + fontSize: 10, + ), + ), + ], + ), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: c.borderSubtle), + ), + clipBehavior: Clip.hardEdge, + child: Column(children: _separated(children, c.borderSubtle)), + ), + ], + ); + } + + static List _separated(List rows, Color divColor) { + final result = []; + for (var i = 0; i < rows.length; i++) { + result.add(rows[i]); + if (i < rows.length - 1) { + result.add(Divider(color: divColor, height: 1, indent: 16)); + } + } + return result; + } +} + +class _FieldRow extends StatelessWidget { + const _FieldRow({ + required this.controller, + required this.label, + required this.enabled, + this.locked = false, + this.keyboardType, + this.onChanged, + }); + + final TextEditingController controller; + final String label; + final bool enabled; + final bool locked; + final TextInputType? keyboardType; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( label, style: AppTypography.amharicCaption.copyWith( - color: c.textMuted, + color: c.textCaption, fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + TextField( + controller: controller, + enabled: enabled, + keyboardType: keyboardType, + onChanged: onChanged, + style: AppTypography.amharicBody + .copyWith(color: c.textOnParchment, fontSize: 15), + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.only(top: 6, bottom: 10), + border: InputBorder.none, + suffixIcon: locked + ? Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon(Icons.lock_outline_rounded, + color: c.textCaption, size: 16), + ) + : null, + suffixIconConstraints: + const BoxConstraints(maxWidth: 28, maxHeight: 28), + disabledBorder: InputBorder.none, + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: c.primary, width: 1.5), + ), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: c.borderSubtle.withValues(alpha: 0.5), width: 1), + ), ), ), ], @@ -462,72 +850,434 @@ class _StatCell extends StatelessWidget { } } -class _StatDivider extends StatelessWidget { - const _StatDivider({required this.colors}); +class _GoogleNotice extends StatelessWidget { + const _GoogleNotice({required this.colors, required this.s}); final AppColorScheme colors; + final AppStrings s; + + @override + Widget build(BuildContext context) { + final c = colors; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Row( + children: [ + Icon(Icons.info_outline_rounded, size: 13, color: c.textCaption), + const SizedBox(width: 6), + Expanded( + child: Text( + s.profileGoogleNote, + style: AppTypography.amharicCaption.copyWith( + color: c.textCaption, + fontSize: 11, + ), + ), + ), + ], + ), + ); + } +} + +class _ArrowTile extends StatelessWidget { + const _ArrowTile( + {required this.icon, required this.label, required this.onTap}); + final IconData icon; + final String label; + final VoidCallback onTap; @override Widget build(BuildContext context) { - return SizedBox( - height: 36, - child: VerticalDivider(color: colors.borderSubtle, width: 1), + final c = context.colors; + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Icon(icon, color: c.textMuted, size: 18), + const SizedBox(width: 12), + Expanded( + child: Text(label, + style: AppTypography.amharicLabel + .copyWith(color: c.textOnParchment)), + ), + Icon(Icons.chevron_right_rounded, + color: c.textCaption, size: 20), + ], + ), + ), ); } } -// ── Action buttons ───────────────────────────────────────────────────────── +// ── Preferences rows ─────────────────────────────────────────────────────── -class _ActionButtons extends StatelessWidget { - const _ActionButtons({required this.colors}); - final AppColorScheme colors; +class _LanguageRow extends StatelessWidget { + const _LanguageRow({required this.s}); + final AppStrings s; @override Widget build(BuildContext context) { - final c = colors; + final c = context.colors; + final isAmharic = s is AmStrings; return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Text(s.settingLanguage, + style: AppTypography.amharicLabel + .copyWith(color: c.textOnParchment)), + const Spacer(), + _LangChip( + label: s.langAmharic, + selected: isAmharic, + onTap: () => L10n.switchLanguage(context, AppLanguage.amharic), + ), + const SizedBox(width: 8), + _LangChip( + label: s.langEnglish, + selected: !isAmharic, + onTap: () => L10n.switchLanguage(context, AppLanguage.english), + ), + ], + ), + ); + } +} + +class _LangChip extends StatelessWidget { + const _LangChip( + {required this.label, required this.selected, required this.onTap}); + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: selected ? c.primary : Colors.transparent, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: selected ? c.primary : c.borderSubtle, + width: 1.2, + ), + ), + child: Text( + label, + style: AppTypography.amharicCaption.copyWith( + color: selected ? Colors.white : c.textMuted, + fontWeight: selected ? FontWeight.w700 : FontWeight.w400, + ), + ), + ), + ); + } +} + +class _NightModeRow extends StatelessWidget { + @override + Widget build(BuildContext context) { + final c = context.colors; + final s = L10n.of(context); + final settings = Settings.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 8, 10), child: Row( children: [ Expanded( - child: GestureDetector( - onTap: () {}, - child: Container( - height: 46, - decoration: BoxDecoration( - color: Colors.transparent, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: c.primary, width: 1.2), - ), - alignment: Alignment.center, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.edit_outlined, color: c.primary, size: 16), - const SizedBox(width: 8), - Text( - L10n.of(context).profileEditButton, - style: AppTypography.amharicCaption.copyWith( - color: c.primary, - fontWeight: FontWeight.w700, - fontSize: 12, - ), - ), - ], - ), - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(s.settingNightMode, + style: AppTypography.amharicLabel + .copyWith(color: c.textOnParchment)), + const SizedBox(height: 2), + Text(s.settingNightModeHint, + style: AppTypography.amharicCaption + .copyWith(color: c.textMuted)), + ], ), ), - const SizedBox(width: 10), - Container( - width: 46, - height: 46, - decoration: BoxDecoration( - color: c.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: c.borderSubtle), + Switch( + value: settings.isDarkReader, + onChanged: (v) => + Settings.update(context, settings.copyWith(isDarkReader: v)), + activeThumbColor: c.primary, + activeTrackColor: c.primaryLight.withValues(alpha: 0.4), + ), + ], + ), + ); + } +} + +// ── Save button ──────────────────────────────────────────────────────────── + +class _SaveButton extends StatelessWidget { + const _SaveButton( + {required this.label, required this.isLoading, required this.onTap}); + final String label; + final bool isLoading; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: 52, + decoration: BoxDecoration( + color: onTap == null + ? c.primary.withValues(alpha: 0.5) + : c.primary, + borderRadius: BorderRadius.circular(14), + boxShadow: onTap == null + ? null + : [ + BoxShadow( + color: c.primary.withValues(alpha: 0.3), + blurRadius: 14, + offset: const Offset(0, 4), + ), + ], + ), + alignment: Alignment.center, + child: isLoading + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, color: c.accent), + ) + : Text(label, + style: AppTypography.amharicLabel + .copyWith(color: c.accent, fontSize: 15)), + ), + ); + } +} + +// ── Error banner ─────────────────────────────────────────────────────────── + +class _ErrorBanner extends StatelessWidget { + const _ErrorBanner({required this.message}); + final String message; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: c.primary.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: c.primary.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + Icon(Icons.error_outline_rounded, color: c.primary, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text(message, + style: AppTypography.amharicCaption + .copyWith(color: c.primary, fontSize: 12)), + ), + ], + ), + ); + } +} + +// ── Logout tile ──────────────────────────────────────────────────────────── + +class _LogoutTile extends ConsumerWidget { + const _LogoutTile({required this.colors, required this.s}); + final AppColorScheme colors; + final AppStrings s; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = colors; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: GestureDetector( + onTap: () async { + await ref.read(authStateProvider.notifier).logout(); + if (context.mounted) Navigator.of(context).pop(); + }, + child: Container( + height: 52, + decoration: BoxDecoration( + color: c.primary.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: c.primary.withValues(alpha: 0.18)), + ), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.logout_rounded, color: c.primary, size: 18), + const SizedBox(width: 10), + Text(s.profileLogout, + style: AppTypography.amharicLabel + .copyWith(color: c.primary, fontSize: 14)), + ], + ), + ), + ), + ); + } +} + +// ── Change password sheet ────────────────────────────────────────────────── + +class _ChangePasswordSheet extends ConsumerStatefulWidget { + const _ChangePasswordSheet(); + + @override + ConsumerState<_ChangePasswordSheet> createState() => + _ChangePasswordSheetState(); +} + +class _ChangePasswordSheetState extends ConsumerState<_ChangePasswordSheet> { + final _currentCtrl = TextEditingController(); + final _newCtrl = TextEditingController(); + final _confirmCtrl = TextEditingController(); + + bool _obscureCurrent = true; + bool _obscureNew = true; + bool _obscureConfirm = true; + bool _isLoading = false; + String? _error; + + @override + void dispose() { + _currentCtrl.dispose(); + _newCtrl.dispose(); + _confirmCtrl.dispose(); + super.dispose(); + } + + bool get _canSubmit => + _currentCtrl.text.isNotEmpty && + _newCtrl.text.length >= 8 && + _newCtrl.text == _confirmCtrl.text; + + Future _submit() async { + if (!_canSubmit) return; + final s = L10n.of(context); + if (_newCtrl.text != _confirmCtrl.text) { + setState(() => _error = s.profilePasswordMismatch); + return; + } + setState(() { _isLoading = true; _error = null; }); + try { + await ref.read(authStateProvider.notifier).changePassword( + currentPassword: _currentCtrl.text, + newPassword: _newCtrl.text, + ); + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(s.profilePasswordChanged, + style: AppTypography.amharicCaption + .copyWith(color: Colors.white)), + backgroundColor: const Color(0xFF2E7D32), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ); + } + } on ApiException catch (e) { + setState(() => _error = e.message); + } catch (_) { + setState(() => _error = L10n.of(context).authConnectionError); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final c = context.colors; + final s = L10n.of(context); + + return Padding( + padding: EdgeInsets.only( + left: 24, + right: 24, + top: 20, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: c.borderSubtle, + borderRadius: BorderRadius.circular(2), + ), ), - alignment: Alignment.center, - child: Icon(Icons.ios_share_rounded, color: c.textMuted, size: 18), + ), + Text( + s.profileChangePassword, + style: AppTypography.amharicSubheading + .copyWith(color: c.textOnParchment), + ), + const SizedBox(height: 20), + _PwField( + controller: _currentCtrl, + label: s.profileCurrentPassword, + obscure: _obscureCurrent, + enabled: !_isLoading, + onToggle: () => + setState(() => _obscureCurrent = !_obscureCurrent), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 14), + _PwField( + controller: _newCtrl, + label: s.profileNewPassword, + obscure: _obscureNew, + enabled: !_isLoading, + onToggle: () => setState(() => _obscureNew = !_obscureNew), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 14), + _PwField( + controller: _confirmCtrl, + label: s.profileConfirmNewPassword, + obscure: _obscureConfirm, + enabled: !_isLoading, + onToggle: () => + setState(() => _obscureConfirm = !_obscureConfirm), + onChanged: (_) => setState(() {}), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + _ErrorBanner(message: _error!), + ], + const SizedBox(height: 20), + _SaveButton( + label: s.profileUpdatePassword, + isLoading: _isLoading, + onTap: (!_canSubmit || _isLoading) ? null : _submit, ), ], ), @@ -535,6 +1285,68 @@ class _ActionButtons extends StatelessWidget { } } +class _PwField extends StatelessWidget { + const _PwField({ + required this.controller, + required this.label, + required this.obscure, + required this.enabled, + required this.onToggle, + required this.onChanged, + }); + + final TextEditingController controller; + final String label; + final bool obscure; + final bool enabled; + final VoidCallback onToggle; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final c = context.colors; + return TextField( + controller: controller, + obscureText: obscure, + enabled: enabled, + onChanged: onChanged, + style: AppTypography.amharicBody + .copyWith(color: c.textOnParchment, fontSize: 15), + decoration: InputDecoration( + labelText: label, + labelStyle: AppTypography.amharicCaption + .copyWith(color: c.textCaption, fontSize: 12), + prefixIcon: + Icon(Icons.lock_outline_rounded, color: c.textCaption, size: 18), + suffixIcon: IconButton( + icon: Icon( + obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined, + color: c.textCaption, + size: 20, + ), + onPressed: onToggle, + ), + filled: true, + fillColor: c.parchment, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.borderSubtle), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.borderSubtle), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: c.primary, width: 1.5), + ), + ), + ); + } +} + // ── Achievements section ─────────────────────────────────────────────────── class _AchievementsSection extends ConsumerWidget { @@ -548,8 +1360,7 @@ class _AchievementsSection extends ConsumerWidget { final streak = streakAsync.value?.currentStreak ?? 0; final longest = streakAsync.value?.longestStreak ?? 0; final snapshotsAsync = ref.watch(continueReadingSnapshotsProvider); - final hasReadAnything = - (snapshotsAsync.value?.isNotEmpty ?? false); + final hasReadAnything = (snapshotsAsync.value?.isNotEmpty ?? false); final s = L10n.of(context); final achievements = [ @@ -584,20 +1395,13 @@ class _AchievementsSection extends ConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( children: [ - Text( - L10n.of(context).profileAchievements, - style: AppTypography.amharicLabel.copyWith( - color: c.textOnParchment, - ), - ), + Text(s.profileAchievements, + style: AppTypography.amharicLabel + .copyWith(color: c.textOnParchment)), const Spacer(), - Text( - '$unlockedCount/${achievements.length}', - style: AppTypography.amharicCaption.copyWith( - color: c.textMuted, - fontSize: 12, - ), - ), + Text('$unlockedCount/${achievements.length}', + style: AppTypography.amharicCaption + .copyWith(color: c.textMuted, fontSize: 12)), ], ), ), @@ -619,12 +1423,11 @@ class _AchievementsSection extends ConsumerWidget { } class _Achievement { - const _Achievement({ - required this.icon, - required this.title, - required this.subtitle, - required this.unlocked, - }); + const _Achievement( + {required this.icon, + required this.title, + required this.subtitle, + required this.unlocked}); final String icon; final String title; final String subtitle; @@ -632,10 +1435,8 @@ class _Achievement { } class _AchievementCard extends StatelessWidget { - const _AchievementCard({ - required this.achievement, - required this.colors, - }); + const _AchievementCard( + {required this.achievement, required this.colors}); final _Achievement achievement; final AppColorScheme colors; @@ -650,7 +1451,9 @@ class _AchievementCard extends StatelessWidget { color: c.surface, borderRadius: BorderRadius.circular(16), border: Border.all( - color: unlocked ? c.primary.withValues(alpha: 0.25) : c.borderSubtle, + color: unlocked + ? c.primary.withValues(alpha: 0.25) + : c.borderSubtle, ), ), child: Column( @@ -669,9 +1472,7 @@ class _AchievementCard extends StatelessWidget { child: unlocked ? Text(achievement.icon, style: TextStyle( - fontSize: 20, - color: c.primary, - height: 1)) + fontSize: 20, color: c.primary, height: 1)) : Icon(Icons.lock_outline_rounded, color: c.textCaption, size: 18), ), @@ -690,10 +1491,8 @@ class _AchievementCard extends StatelessWidget { const SizedBox(height: 2), Text( achievement.subtitle, - style: AppTypography.englishCaption.copyWith( - color: c.textCaption, - fontSize: 9, - ), + style: AppTypography.englishCaption + .copyWith(color: c.textCaption, fontSize: 9), textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/features/me/presentation/pages/me_screen.dart b/lib/features/me/presentation/pages/me_screen.dart index dddbabd..a0e3ffd 100644 --- a/lib/features/me/presentation/pages/me_screen.dart +++ b/lib/features/me/presentation/pages/me_screen.dart @@ -120,6 +120,8 @@ class _MeScreenState extends ConsumerState { ], ), ), + const SliverToBoxAdapter(child: SizedBox(height: 16)), + SliverToBoxAdapter(child: _LogoutSection(s: s)), const SliverToBoxAdapter(child: SizedBox(height: 40)), ], ), @@ -702,3 +704,47 @@ class _LangChip extends StatelessWidget { ); } } + +// ── Logout section ───────────────────────────────────────────────────────────── + +class _LogoutSection extends ConsumerWidget { + const _LogoutSection({required this.s}); + final AppStrings s; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final authState = ref.watch(authStateProvider); + if (!authState.isAuthenticated) return const SizedBox.shrink(); + + final c = context.colors; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: GestureDetector( + onTap: () async { + await ref.read(authStateProvider.notifier).logout(); + }, + child: Container( + height: 52, + decoration: BoxDecoration( + color: c.primary.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: c.primary.withValues(alpha: 0.18)), + ), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.logout_rounded, color: c.primary, size: 18), + const SizedBox(width: 10), + Text( + s.profileLogout, + style: AppTypography.amharicLabel + .copyWith(color: c.primary, fontSize: 14), + ), + ], + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 22029ed..7bea896 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -145,6 +145,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -166,6 +198,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_riverpod: dependency: "direct main" description: @@ -336,6 +376,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f + url: "https://pub.dev" + source: hosted + version: "0.8.13+17" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" jni: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3efbf4c..ce0dabd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: flutter_secure_storage: ^10.2.0 google_sign_in: ^6.0.0 flutter_svg: ^2.0.0 + image_picker: ^1.1.2 dev_dependencies: flutter_test: From 20b099b3dd3911ab4fc6455ee578f9530b143ba2 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Wed, 20 May 2026 22:25:48 +0300 Subject: [PATCH 24/33] feat: add file selector plugin support for Linux, macOS, and Windows --- linux/flutter/generated_plugin_registrant.cc | 4 ++++ linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 ++ windows/flutter/generated_plugin_registrant.cc | 3 +++ windows/flutter/generated_plugins.cmake | 1 + 5 files changed, 11 insertions(+) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 5a27a5d..ea3bde6 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,11 +6,15 @@ #include "generated_plugin_registrant.h" +#include #include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 015388d..1e154fe 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux flutter_secure_storage_linux gtk url_launcher_linux diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 744d5d9..5eaef88 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import app_links +import file_selector_macos import flutter_secure_storage_darwin import google_sign_in_ios import share_plus @@ -13,6 +14,7 @@ import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8e4d43e..053aac8 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -14,6 +15,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 3fa8ff7..43a8099 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + file_selector_windows flutter_secure_storage_windows share_plus url_launcher_windows From 2fad7fd65ff15860c5f0aad9832648f5335ec8d8 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 26 May 2026 14:08:19 +0300 Subject: [PATCH 25/33] fix: sync bookmarks, highlights, and notes with backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB v4 migration: mark synced items with title-case bookId as pendingUpdate so they are re-pushed in the correct kebab-case format - Rewrite upsertServer* to do three-tier upsert: match by remoteId, merge by verse ref, or insert as new — prevents silent drops when a local pendingCreate item occupies the same verse as a server item - Add _toApiBookId/_normalizeBookId helpers so push uses kebab-case ("genesis") and pull normalises server keys back to local bookNameEn - Fix createNote/updateNote to send flat fields (bookId, chapter, ...) that the server actually requires — nested verseRef format caused 400 - Add 404 fallback in syncBookmarks/Notes/Highlights: stale remoteId on PUT triggers a fresh POST instead of silently failing - Handle null remoteId on pendingUpdate by falling back to create - Surface HTTP status codes and exception types in sync error logs Co-Authored-By: Claude Sonnet 4.6 --- lib/core/storage/app_database.dart | 166 +++++++++++++++++++++++++---- lib/core/sync/sync_repository.dart | 142 ++++++++++++++++++------ lib/core/sync/sync_service.dart | 98 ++++++++++++++--- 3 files changed, 335 insertions(+), 71 deletions(-) diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index c839ff9..6a28cce 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -5,7 +5,7 @@ import '../annotations/annotation_models.dart'; class AppDatabase { static const _dbName = 'bibleapp.db'; - static const _version = 3; + static const _version = 4; Database? _db; @@ -36,6 +36,24 @@ class AppDatabase { if (oldVersion < 3) { await _migrateReadingPositionToMultiBook(db); } + if (oldVersion < 4) { + await _markLegacyBookIdsPendingUpdate(db); + } + } + + /// v3→v4: annotation items pushed with title-case bookId (e.g. "Genesis") + /// instead of the API's kebab-case ("genesis") need to be re-pushed so the + /// server stores the correct format the web frontend can find. + Future _markLegacyBookIdsPendingUpdate(Database db) async { + const sql = ''' + UPDATE {table} SET sync_status = 'pendingUpdate' + WHERE remote_id IS NOT NULL + AND sync_status = 'synced' + AND (book_id != lower(book_id) OR book_id LIKE '% %') + '''; + for (final table in ['bookmarks', 'highlights', 'notes']) { + await db.execute(sql.replaceFirst('{table}', table)); + } } /// v2→v3: replace single-row reading_position (id=1) with per-book rows. @@ -498,42 +516,146 @@ class AppDatabase { Future upsertServerBookmarks(List items) async { if (items.isEmpty) return; final db = await database; - final knownIds = (await db.query('bookmarks', columns: ['remote_id'])) - .map((r) => r['remote_id'] as String?) - .whereType() - .toSet(); for (final b in items) { - if (b.remoteId == null || knownIds.contains(b.remoteId)) continue; - await db.insert('bookmarks', b.toMap(), - conflictAlgorithm: ConflictAlgorithm.ignore); + if (b.remoteId == null) continue; + + // Already tracked by remoteId — fix book_id if it was stored in the + // wrong case from a previous pull + final byRemoteId = await db.query( + 'bookmarks', where: 'remote_id = ?', whereArgs: [b.remoteId], limit: 1, + ); + if (byRemoteId.isNotEmpty) { + if ((byRemoteId.first['book_id'] as String) != b.bookId) { + await db.update('bookmarks', {'book_id': b.bookId}, + where: 'remote_id = ?', whereArgs: [b.remoteId]); + } + continue; + } + + // Same verse exists locally (e.g. pendingCreate offline) — assign remoteId + final byVerse = await db.query( + 'bookmarks', + where: 'book_id = ? AND chapter = ? AND verse_start = ?', + whereArgs: [b.bookId, b.chapter, b.verseStart], + limit: 1, + ); + if (byVerse.isNotEmpty) { + await db.update( + 'bookmarks', + {'remote_id': b.remoteId, 'sync_status': SyncStatus.synced.name}, + where: 'id = ?', + whereArgs: [byVerse.first['id'] as int], + ); + } else { + await db.insert('bookmarks', b.toMap(), + conflictAlgorithm: ConflictAlgorithm.ignore); + } } } Future upsertServerHighlights(List items) async { if (items.isEmpty) return; final db = await database; - final knownIds = (await db.query('highlights', columns: ['remote_id'])) - .map((r) => r['remote_id'] as String?) - .whereType() - .toSet(); for (final h in items) { - if (h.remoteId == null || knownIds.contains(h.remoteId)) continue; - await db.insert('highlights', h.toMap(), - conflictAlgorithm: ConflictAlgorithm.ignore); + if (h.remoteId == null) continue; + + // Already tracked by remoteId — update color/note if not locally modified; + // also normalize book_id in case it was stored in wrong case + final byRemoteId = await db.query( + 'highlights', where: 'remote_id = ?', whereArgs: [h.remoteId], limit: 1, + ); + if (byRemoteId.isNotEmpty) { + final updates = {}; + if ((byRemoteId.first['book_id'] as String) != h.bookId) { + updates['book_id'] = h.bookId; + } + if ((byRemoteId.first['sync_status'] as String) == SyncStatus.synced.name) { + updates['color'] = h.color.toARGB32(); + updates['note'] = h.note; + } + if (updates.isNotEmpty) { + await db.update('highlights', updates, + where: 'remote_id = ?', whereArgs: [h.remoteId]); + } + continue; + } + + // Same verse exists locally — assign remoteId and sync server color + final byVerse = await db.query( + 'highlights', + where: 'book_id = ? AND chapter = ? AND verse_start = ?', + whereArgs: [h.bookId, h.chapter, h.verseStart], + limit: 1, + ); + if (byVerse.isNotEmpty) { + await db.update( + 'highlights', + { + 'remote_id': h.remoteId, + 'sync_status': SyncStatus.synced.name, + 'color': h.color.toARGB32(), + 'note': h.note, + }, + where: 'id = ?', + whereArgs: [byVerse.first['id'] as int], + ); + } else { + await db.insert('highlights', h.toMap(), + conflictAlgorithm: ConflictAlgorithm.ignore); + } } } Future upsertServerNotes(List items) async { if (items.isEmpty) return; final db = await database; - final knownIds = (await db.query('notes', columns: ['remote_id'])) - .map((r) => r['remote_id'] as String?) - .whereType() - .toSet(); for (final n in items) { - if (n.remoteId == null || knownIds.contains(n.remoteId)) continue; - await db.insert('notes', n.toMap(), - conflictAlgorithm: ConflictAlgorithm.ignore); + if (n.remoteId == null) continue; + + // Already tracked by remoteId — update content if not locally modified; + // also normalize book_id in case it was stored in wrong case + final byRemoteId = await db.query( + 'notes', where: 'remote_id = ?', whereArgs: [n.remoteId], limit: 1, + ); + if (byRemoteId.isNotEmpty) { + final updates = {}; + if ((byRemoteId.first['book_id'] as String) != n.bookId) { + updates['book_id'] = n.bookId; + } + if ((byRemoteId.first['sync_status'] as String) == SyncStatus.synced.name) { + updates['content'] = n.content; + updates['is_private'] = n.isPrivate ? 1 : 0; + } + if (updates.isNotEmpty) { + await db.update('notes', updates, + where: 'remote_id = ?', whereArgs: [n.remoteId]); + } + continue; + } + + // Same verse exists locally — assign remoteId and sync server content + final byVerse = await db.query( + 'notes', + where: 'book_id = ? AND chapter = ? AND verse_start = ?', + whereArgs: [n.bookId, n.chapter, n.verseStart], + limit: 1, + ); + if (byVerse.isNotEmpty) { + await db.update( + 'notes', + { + 'remote_id': n.remoteId, + 'sync_status': SyncStatus.synced.name, + 'content': n.content, + 'is_private': n.isPrivate ? 1 : 0, + }, + where: 'id = ?', + whereArgs: [byVerse.first['id'] as int], + ); + } else { + await db.insert('notes', n.toMap(), + conflictAlgorithm: ConflictAlgorithm.ignore); + } } } diff --git a/lib/core/sync/sync_repository.dart b/lib/core/sync/sync_repository.dart index 3e23a77..22f3e3e 100644 --- a/lib/core/sync/sync_repository.dart +++ b/lib/core/sync/sync_repository.dart @@ -11,11 +11,12 @@ class SyncRepository { // ── Bookmarks ────────────────────────────────────────────────────────────── Future createBookmark(Bookmark b) async { + final apiId = _toApiBookId(b.bookId); final res = await _client.post( '/bookmarks', body: { - 'book': b.bookId, - 'bookId': b.bookId, + 'book': apiId, + 'bookId': apiId, 'chapter': b.chapter, 'verseStart': b.verseStart, 'verseCount': b.verseCount, @@ -25,17 +26,20 @@ class SyncRepository { return _remoteId(res, 'bookmark'); } - Future updateBookmark(String remoteId, Bookmark b) => _client.put( - '/bookmarks/$remoteId', - body: { - 'book': b.bookId, - 'bookId': b.bookId, - 'chapter': b.chapter, - 'verseStart': b.verseStart, - 'verseCount': b.verseCount, - }, - token: _token, - ); + Future updateBookmark(String remoteId, Bookmark b) { + final apiId = _toApiBookId(b.bookId); + return _client.put( + '/bookmarks/$remoteId', + body: { + 'book': apiId, + 'bookId': apiId, + 'chapter': b.chapter, + 'verseStart': b.verseStart, + 'verseCount': b.verseCount, + }, + token: _token, + ); + } Future deleteBookmark(String remoteId) => _client.delete('/bookmarks/$remoteId', token: _token); @@ -46,7 +50,7 @@ class SyncRepository { final res = await _client.post( '/notes', body: { - 'bookId': n.bookId, + 'bookId': _toApiBookId(n.bookId), 'chapter': n.chapter, 'verseStart': n.verseStart, 'verseCount': n.verseCount, @@ -61,6 +65,10 @@ class SyncRepository { Future updateNote(String remoteId, Note n) => _client.put( '/notes/$remoteId', body: { + 'bookId': _toApiBookId(n.bookId), + 'chapter': n.chapter, + 'verseStart': n.verseStart, + 'verseCount': n.verseCount, 'content': n.content, 'visibility': n.isPrivate ? 'private' : 'public', }, @@ -73,11 +81,12 @@ class SyncRepository { // ── Highlights ───────────────────────────────────────────────────────────── Future createHighlight(Highlight h) async { + final apiId = _toApiBookId(h.bookId); final res = await _client.post( '/highlights', body: { - 'book': h.bookId, - 'bookId': h.bookId, + 'book': apiId, + 'bookId': apiId, 'chapter': h.chapter, 'verseStart': h.verseStart, 'verseCount': h.verseCount, @@ -109,7 +118,7 @@ class SyncRepository { }) => _client.post( '/progress/log-reading', - body: {'bookId': bookId, 'chapter': chapter}, + body: {'bookId': _toApiBookId(bookId), 'chapter': chapter}, token: _token, ); @@ -121,8 +130,11 @@ class SyncRepository { final obj = data[key]; if (obj is Map && obj['_id'] is String) return obj['_id'] as String; if (data['_id'] is String) return data['_id'] as String; + // Some endpoints wrap inside data.data + final inner = data['data']; + if (inner is Map && inner['_id'] is String) return inner['_id'] as String; } - throw Exception('Cannot parse remote id for $key'); + throw Exception('Cannot parse remote id for $key: ${res['data']}'); } // Maps local ARGB palette to the backend's accepted color names. @@ -150,15 +162,66 @@ class SyncRepository { static Color _colorFromName(String name) => _nameToColor[name] ?? const Color(0xFFFFE062); + // Reverse lookup: server kebab-case bookId → local bookNameEn. + // Web FE sends lowercase-hyphen (e.g. "1-samuel"); mobile stores title-case + // (e.g. "1 Samuel"). This map converts between the two directions. + static const _bookIdToName = { + 'genesis': 'Genesis', 'exodus': 'Exodus', 'leviticus': 'Leviticus', + 'numbers': 'Numbers', 'deuteronomy': 'Deuteronomy', 'joshua': 'Joshua', + 'judges': 'Judges', 'ruth': 'Ruth', '1-samuel': '1 Samuel', + '2-samuel': '2 Samuel', '1-kings': '1 Kings', '2-kings': '2 Kings', + '1-chronicles': '1 Chronicles', '2-chronicles': '2 Chronicles', + 'jubilees': 'Jubilees', 'enoch': 'Enoch', 'ezra': 'Ezra', + 'nehemiah': 'Nehemiah', '3-book-of-ezra': '3 Book of Ezra', + '2nd-book-of-ezra': '2nd Book of Ezra', 'book-of-tobit': 'Book of Tobit', + 'book-of-judith': 'Book of Judith', 'esther': 'Esther', + '1-maccabees': '1 Maccabees', '2-maccabees': '2 Maccabees', + '3-maccabees': '3 Maccabees', 'job': 'Job', 'psalms': 'Psalms', + 'proverbs': 'Proverbs', 'book-of-admonition': 'Book of Admonition', + 'wisdom-of-solomon': 'Wisdom of Solomon', 'ecclesiastes': 'Ecclesiastes', + 'song-of-solomon': 'Song of Solomon', 'book-of-sirach': 'book of sirach', + 'isaiah': 'Isaiah', 'jeremiah': 'Jeremiah', 'baruch': 'Baruch', + 'lamentations': 'Lamentations', + 'thr-letter-of-jeremiah': 'Thr letter of Jeremiah', + 'teref-baruch': 'Teref Baruch', 'ezekiel': 'Ezekiel', 'daniel': 'Daniel', + 'hosea': 'Hosea', 'amos': 'Amos', 'micah': 'Micah', 'joel': 'Joel', + 'obadiah': 'Obadiah', 'jonah': 'Jonah', 'nahum': 'Nahum', + 'habakkuk': 'Habakkuk', 'zephaniah': 'Zephaniah', 'haggai': 'Haggai', + 'zechariah': 'Zechariah', 'malachi': 'Malachi', 'matthew': 'Matthew', + 'mark': 'Mark', 'luke': 'Luke', 'john': 'John', 'acts': 'Acts', + 'romans': 'Romans', '1-corinthians': '1 Corinthians', + '2-corinthians': '2 Corinthians', 'galatians': 'Galatians', + 'ephesians': 'Ephesians', 'philippians': 'Philippians', + 'colossians': 'Colossians', '1-thessalonians': '1 Thessalonians', + '2-thessalonians': '2 Thessalonians', '1-timothy': '1 Timothy', + '2-timothy': '2 Timothy', 'titus': 'Titus', 'philemon': 'Philemon', + 'hebrews': 'Hebrews', '1-peter': '1 Peter', '2-peter': '2 Peter', + '1-john': '1 John', '2-john': '2 John', '3-john': '3 John', + 'james': 'James', 'jude': 'Jude', 'revelation': 'Revelation', + }; + + /// Convert server kebab-case bookId to local bookNameEn. + /// Handles both web format ("genesis") and mobile format ("Genesis"). + static String _normalizeBookId(String raw) { + if (raw.isEmpty) return raw; + final kebab = raw.toLowerCase().replaceAll(' ', '-'); + return _bookIdToName[kebab] ?? raw; + } + + /// Convert local bookNameEn to the API's kebab-case format. + static String _toApiBookId(String bookNameEn) => + bookNameEn.toLowerCase().replaceAll(' ', '-'); + // ── Fetch (pull from server) ──────────────────────────────────────────────── Future> fetchBookmarks() async { final res = await _client.get('/bookmarks', token: _token); final data = res['data']; - final list = (data is Map ? data['data'] : data) as List? ?? []; + final list = (data is Map ? (data['data'] ?? data) : data) as List? ?? []; final now = DateTime.now(); return list.whereType>().map((m) { - final bookId = (m['book'] ?? m['bookId'] ?? '') as String; + final raw = (m['book'] ?? m['bookId'] ?? '') as String; + final bookId = _normalizeBookId(raw); return Bookmark( bookId: bookId, bookNumber: 0, @@ -170,23 +233,28 @@ class SyncRepository { syncStatus: SyncStatus.synced, remoteId: m['_id'] as String?, ); - }).toList(); + }).where((b) => b.bookId.isNotEmpty).toList(); } Future> fetchHighlights() async { final res = await _client.get('/highlights', token: _token); final data = res['data']; - final list = (data is Map ? data['data'] : data) as List? ?? []; + final list = (data is Map ? (data['data'] ?? data) : data) as List? ?? []; final now = DateTime.now(); return list.whereType>().map((m) { - final bookId = (m['book'] ?? m['bookId'] ?? '') as String; + final verseRef = m['verseRef'] as Map?; + final raw = (verseRef?['book'] ?? m['book'] ?? m['bookId'] ?? '') as String; + final bookId = _normalizeBookId(raw); + final chapter = ((verseRef?['chapter'] ?? m['chapter']) as num?)?.toInt() ?? 0; + final verseStart = ((verseRef?['verseStart'] ?? verseRef?['verse'] ?? m['verseStart']) as num?)?.toInt() ?? 1; + final verseCount = ((verseRef?['verseCount'] ?? m['verseCount']) as num?)?.toInt() ?? 1; final colorName = (m['color'] as String?) ?? 'yellow'; return Highlight( bookId: bookId, bookNumber: 0, - chapter: (m['chapter'] as num).toInt(), - verseStart: (m['verseStart'] as num).toInt(), - verseCount: (m['verseCount'] as num?)?.toInt() ?? 1, + chapter: chapter, + verseStart: verseStart, + verseCount: verseCount, color: _colorFromName(colorName), note: m['note'] as String?, createdAt: _parseDate(m['createdAt']) ?? now, @@ -194,22 +262,30 @@ class SyncRepository { syncStatus: SyncStatus.synced, remoteId: m['_id'] as String?, ); - }).toList(); + }).where((h) => h.bookId.isNotEmpty && h.chapter > 0).toList(); } Future> fetchNotes() async { final res = await _client.get('/notes', token: _token); final data = res['data']; - final list = (data is Map ? data['notes'] : data) as List? ?? []; + final list = (data is Map + ? (data['notes'] ?? data['data'] ?? data) + : data) as List? ?? []; final now = DateTime.now(); return list.whereType>().map((m) { - final bookId = (m['bookId'] ?? m['book'] ?? '') as String; + // Handle both flat and nested verseRef structures + final verseRef = m['verseRef'] as Map?; + final raw = (verseRef?['book'] ?? m['bookId'] ?? m['book'] ?? '') as String; + final bookId = _normalizeBookId(raw); + final chapter = ((verseRef?['chapter'] ?? m['chapter']) as num?)?.toInt() ?? 0; + final verseStart = ((verseRef?['verseStart'] ?? verseRef?['verse'] ?? m['verseStart']) as num?)?.toInt() ?? 1; + final verseCount = ((verseRef?['verseCount'] ?? m['verseCount']) as num?)?.toInt() ?? 1; return Note( bookId: bookId, bookNumber: 0, - chapter: (m['chapter'] as num).toInt(), - verseStart: (m['verseStart'] as num).toInt(), - verseCount: (m['verseCount'] as num?)?.toInt() ?? 1, + chapter: chapter, + verseStart: verseStart, + verseCount: verseCount, content: (m['content'] as String?) ?? '', isPrivate: (m['visibility'] as String?) != 'public', createdAt: _parseDate(m['createdAt']) ?? now, @@ -217,7 +293,7 @@ class SyncRepository { syncStatus: SyncStatus.synced, remoteId: m['_id'] as String?, ); - }).where((n) => n.content.isNotEmpty).toList(); + }).where((n) => n.content.isNotEmpty && n.bookId.isNotEmpty && n.chapter > 0).toList(); } static DateTime? _parseDate(dynamic v) { diff --git a/lib/core/sync/sync_service.dart b/lib/core/sync/sync_service.dart index ec1d898..d31ec1e 100644 --- a/lib/core/sync/sync_service.dart +++ b/lib/core/sync/sync_service.dart @@ -1,4 +1,6 @@ +import 'package:flutter/foundation.dart'; import '../annotations/annotation_models.dart'; +import '../api/api_client.dart'; import '../storage/app_database.dart'; import 'sync_repository.dart'; @@ -47,29 +49,48 @@ class SyncService { Future syncBookmarks() async { final pending = await _db.getPendingBookmarks(); + debugPrint('[Sync] pending bookmarks: ${pending.length}'); for (final b in pending) { + debugPrint('[Sync] bookmark id=${b.id} status=${b.syncStatus.name} remoteId=${b.remoteId} bookId=${b.bookId}'); try { switch (b.syncStatus) { case SyncStatus.pendingCreate: + debugPrint('[Sync] creating bookmark bookId=${b.bookId} ch=${b.chapter} v=${b.verseStart}'); final remoteId = await _repo.createBookmark(b); + debugPrint('[Sync] bookmark created remoteId=$remoteId'); await _db.updateAnnotationSync( 'bookmarks', b.id!, SyncStatus.synced, remoteId: remoteId, ); case SyncStatus.pendingUpdate: if (b.remoteId != null) { - await _repo.updateBookmark(b.remoteId!, b); - await _db.updateAnnotationSync( - 'bookmarks', b.id!, SyncStatus.synced); + try { + await _repo.updateBookmark(b.remoteId!, b); + await _db.updateAnnotationSync('bookmarks', b.id!, SyncStatus.synced); + } on ApiException catch (e) { + if (e.statusCode == 404) { + final remoteId = await _repo.createBookmark(b); + await _db.updateAnnotationSync('bookmarks', b.id!, SyncStatus.synced, remoteId: remoteId); + } else { + debugPrint('[Sync] bookmark update error ${e.statusCode}: $e'); + } + } + } else { + debugPrint('[Sync] bookmark pendingUpdate but remoteId=null, treating as create'); + final remoteId = await _repo.createBookmark(b); + await _db.updateAnnotationSync('bookmarks', b.id!, SyncStatus.synced, remoteId: remoteId); } case SyncStatus.pendingDelete: - if (b.remoteId != null) await _repo.deleteBookmark(b.remoteId!); + if (b.remoteId != null) { + try { await _repo.deleteBookmark(b.remoteId!); } on ApiException catch (_) {} + } await _db.deleteBookmark(b.id!); case SyncStatus.synced: break; } - } catch (_) { - // Silent — will retry on next sync + } catch (e) { + final code = e is ApiException ? ' (HTTP ${e.statusCode})' : ''; + debugPrint('[Sync] bookmark error$code (${e.runtimeType}): $e'); } } } @@ -78,28 +99,52 @@ class SyncService { Future syncNotes() async { final pending = await _db.getPendingNotes(); + debugPrint('[Sync] pending notes: ${pending.length}'); for (final n in pending) { + debugPrint('[Sync] note id=${n.id} status=${n.syncStatus.name} remoteId=${n.remoteId} bookId=${n.bookId}'); try { switch (n.syncStatus) { case SyncStatus.pendingCreate: + debugPrint('[Sync] creating note bookId=${n.bookId} ch=${n.chapter} v=${n.verseStart}'); final remoteId = await _repo.createNote(n); + debugPrint('[Sync] note created remoteId=$remoteId'); await _db.updateAnnotationSync( 'notes', n.id!, SyncStatus.synced, remoteId: remoteId, ); case SyncStatus.pendingUpdate: if (n.remoteId != null) { - await _repo.updateNote(n.remoteId!, n); - await _db.updateAnnotationSync( - 'notes', n.id!, SyncStatus.synced); + try { + await _repo.updateNote(n.remoteId!, n); + await _db.updateAnnotationSync('notes', n.id!, SyncStatus.synced); + } on ApiException catch (e) { + if (e.statusCode == 404) { + debugPrint('[Sync] note 404 on update, creating fresh for ${n.bookId} ch=${n.chapter} v=${n.verseStart}'); + final remoteId = await _repo.createNote(n); + await _db.updateAnnotationSync('notes', n.id!, SyncStatus.synced, remoteId: remoteId); + debugPrint('[Sync] note re-created remoteId=$remoteId'); + } else { + debugPrint('[Sync] note update error ${e.statusCode}: $e'); + } + } + } else { + debugPrint('[Sync] note pendingUpdate but remoteId=null, treating as create'); + final remoteId = await _repo.createNote(n); + await _db.updateAnnotationSync('notes', n.id!, SyncStatus.synced, remoteId: remoteId); + debugPrint('[Sync] note created remoteId=$remoteId'); } case SyncStatus.pendingDelete: - if (n.remoteId != null) await _repo.deleteNote(n.remoteId!); + if (n.remoteId != null) { + try { await _repo.deleteNote(n.remoteId!); } on ApiException catch (_) {} + } await _db.deleteNote(n.id!); case SyncStatus.synced: break; } - } catch (_) {} + } catch (e) { + final code = e is ApiException ? ' (HTTP ${e.statusCode})' : ''; + debugPrint('[Sync] note error$code (${e.runtimeType}): $e'); + } } } @@ -107,28 +152,49 @@ class SyncService { Future syncHighlights() async { final pending = await _db.getPendingHighlights(); + debugPrint('[Sync] pending highlights: ${pending.length}'); for (final h in pending) { + debugPrint('[Sync] highlight id=${h.id} status=${h.syncStatus.name} remoteId=${h.remoteId} bookId=${h.bookId}'); try { switch (h.syncStatus) { case SyncStatus.pendingCreate: + debugPrint('[Sync] creating highlight bookId=${h.bookId} ch=${h.chapter} v=${h.verseStart}'); final remoteId = await _repo.createHighlight(h); + debugPrint('[Sync] highlight created remoteId=$remoteId'); await _db.updateAnnotationSync( 'highlights', h.id!, SyncStatus.synced, remoteId: remoteId, ); case SyncStatus.pendingUpdate: if (h.remoteId != null) { - await _repo.updateHighlight(h.remoteId!, h); - await _db.updateAnnotationSync( - 'highlights', h.id!, SyncStatus.synced); + try { + await _repo.updateHighlight(h.remoteId!, h); + await _db.updateAnnotationSync('highlights', h.id!, SyncStatus.synced); + } on ApiException catch (e) { + if (e.statusCode == 404) { + final remoteId = await _repo.createHighlight(h); + await _db.updateAnnotationSync('highlights', h.id!, SyncStatus.synced, remoteId: remoteId); + } else { + debugPrint('[Sync] highlight update error ${e.statusCode}: $e'); + } + } + } else { + debugPrint('[Sync] highlight pendingUpdate but remoteId=null, treating as create'); + final remoteId = await _repo.createHighlight(h); + await _db.updateAnnotationSync('highlights', h.id!, SyncStatus.synced, remoteId: remoteId); } case SyncStatus.pendingDelete: - if (h.remoteId != null) await _repo.deleteHighlight(h.remoteId!); + if (h.remoteId != null) { + try { await _repo.deleteHighlight(h.remoteId!); } on ApiException catch (_) {} + } await _db.deleteHighlight(h.id!); case SyncStatus.synced: break; } - } catch (_) {} + } catch (e) { + final code = e is ApiException ? ' (HTTP ${e.statusCode})' : ''; + debugPrint('[Sync] highlight error$code (${e.runtimeType}): $e'); + } } } From de57113cb117d94cfe435ea8fc1f6479c88b11a3 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 26 May 2026 14:30:45 +0300 Subject: [PATCH 26/33] feat: enhance annotation handling with verse count support --- lib/core/annotations/annotation_models.dart | 17 +-- .../providers/annotation_providers.dart | 6 ++ .../presentation/pages/reader_screen.dart | 102 +++++++++++++++--- .../presentation/pages/saved_common.dart | 6 +- .../presentation/pages/saved_screen.dart | 20 +++- 5 files changed, 123 insertions(+), 28 deletions(-) diff --git a/lib/core/annotations/annotation_models.dart b/lib/core/annotations/annotation_models.dart index 01b137a..5b19cd2 100644 --- a/lib/core/annotations/annotation_models.dart +++ b/lib/core/annotations/annotation_models.dart @@ -254,12 +254,17 @@ class ChapterAnnotations { static const empty = ChapterAnnotations(); - bool isBookmarked(int verseStart) => - bookmarks.any((b) => b.verseStart == verseStart); + bool isBookmarked(int verseNum) => bookmarks.any( + (b) => verseNum >= b.verseStart && verseNum < b.verseStart + b.verseCount); - Color? highlightColor(int verseStart) => - highlights.where((h) => h.verseStart == verseStart).firstOrNull?.color; + Color? highlightColor(int verseNum) => highlights + .where((h) => + verseNum >= h.verseStart && verseNum < h.verseStart + h.verseCount) + .firstOrNull + ?.color; - Note? noteFor(int verseStart) => - notes.where((n) => n.verseStart == verseStart).firstOrNull; + Note? noteFor(int verseNum) => notes + .where((n) => + verseNum >= n.verseStart && verseNum < n.verseStart + n.verseCount) + .firstOrNull; } diff --git a/lib/features/annotations/providers/annotation_providers.dart b/lib/features/annotations/providers/annotation_providers.dart index 0601553..9ddb222 100644 --- a/lib/features/annotations/providers/annotation_providers.dart +++ b/lib/features/annotations/providers/annotation_providers.dart @@ -49,6 +49,7 @@ class ChapterAnnotationsNotifier Future toggleBookmark({ required int verseStart, required int bookNumber, + int verseCount = 1, }) async { final annotations = state.value; if (annotations == null) return; @@ -64,6 +65,7 @@ class ChapterAnnotationsNotifier bookNumber: bookNumber, chapter: _key.chapter, verseStart: verseStart, + verseCount: verseCount, createdAt: now, updatedAt: now, )); @@ -76,6 +78,7 @@ class ChapterAnnotationsNotifier required int verseStart, required int bookNumber, required Color color, + int verseCount = 1, }) async { final annotations = state.value; if (annotations == null) return; @@ -96,6 +99,7 @@ class ChapterAnnotationsNotifier bookNumber: bookNumber, chapter: _key.chapter, verseStart: verseStart, + verseCount: verseCount, color: color, createdAt: now, updatedAt: now, @@ -121,6 +125,7 @@ class ChapterAnnotationsNotifier required int verseStart, required int bookNumber, required String content, + int verseCount = 1, }) async { final annotations = state.value; if (annotations == null) return; @@ -146,6 +151,7 @@ class ChapterAnnotationsNotifier bookNumber: bookNumber, chapter: _key.chapter, verseStart: verseStart, + verseCount: verseCount, content: content.trim(), createdAt: now, updatedAt: now, diff --git a/lib/features/books/presentation/pages/reader_screen.dart b/lib/features/books/presentation/pages/reader_screen.dart index eb591d2..fbfa3e4 100644 --- a/lib/features/books/presentation/pages/reader_screen.dart +++ b/lib/features/books/presentation/pages/reader_screen.dart @@ -64,6 +64,7 @@ class _ReaderScreenState extends ConsumerState int _currentChapter = 0; String? _selectedKey; + String? _selectionEndKey; final GlobalKey _spotlightKey = GlobalKey(); Timer? _dwellTimer; @@ -178,7 +179,7 @@ class _ReaderScreenState extends ConsumerState } on Object catch (_) { return; } - final verse = _selectedVerseNum; + final verse = _selectionVerseStart; unawaited( container .read(readingProgressRepositoryProvider) @@ -302,17 +303,39 @@ class _ReaderScreenState extends ConsumerState } void _selectVerse(String key) { - setState(() => _selectedKey = _selectedKey == key ? null : key); + setState(() { + if (_selectedKey == null) { + _selectedKey = key; + _selectionEndKey = null; + } else if (key == _selectedKey && _selectionEndKey == null) { + _selectedKey = null; + } else { + _selectionEndKey = key == _selectedKey ? null : key; + } + }); _persistReadingPosition(); } - void _deselect() => setState(() => _selectedKey = null); + void _deselect() => setState(() { + _selectedKey = null; + _selectionEndKey = null; + }); String _verseKey(int chNum, int secIdx, int verseNum) => '$chNum:$secIdx:$verseNum'; - bool _isSelected(int chNum, int secIdx, int verseNum) => - _selectedKey == _verseKey(chNum, secIdx, verseNum); + bool _isSelected(int chNum, int secIdx, int verseNum) { + if (_selectedKey == null) return false; + final ap = _selectedKey!.split(':'); + if (ap.length != 3 || int.tryParse(ap[0]) != chNum) return false; + final anchor = int.tryParse(ap[2]); + if (anchor == null) return false; + final end = _selectionEndVerseNum; + if (end == null) return verseNum == anchor; + final lo = anchor < end ? anchor : end; + final hi = anchor < end ? end : anchor; + return verseNum >= lo && verseNum <= hi; + } int? get _selectedVerseNum { if (_selectedKey == null) return null; @@ -320,6 +343,30 @@ class _ReaderScreenState extends ConsumerState return parts.length == 3 ? int.tryParse(parts[2]) : null; } + int? get _selectionEndVerseNum { + if (_selectionEndKey == null) return null; + final parts = _selectionEndKey!.split(':'); + return parts.length == 3 ? int.tryParse(parts[2]) : null; + } + + /// The smaller verse number of the selected range (anchor when single verse). + int? get _selectionVerseStart { + final anchor = _selectedVerseNum; + if (anchor == null) return null; + final end = _selectionEndVerseNum; + if (end == null) return anchor; + return anchor < end ? anchor : end; + } + + /// Number of verses in the selection (1 for single verse). + int get _selectionVerseCount { + final anchor = _selectedVerseNum; + if (anchor == null) return 0; + final end = _selectionEndVerseNum; + if (end == null) return 1; + return (end - anchor).abs() + 1; + } + int get _currentChapterNumber { final book = _book; if (book == null || _currentChapter >= book.chapters.length) return 1; @@ -334,17 +381,31 @@ class _ReaderScreenState extends ConsumerState final parts = _selectedKey!.split(':'); if (parts.length != 3) return null; final chNum = int.tryParse(parts[0]); - final secIdx = int.tryParse(parts[1]); - final vNum = int.tryParse(parts[2]); - if (chNum == null || secIdx == null || vNum == null) return null; + if (chNum == null) return null; + final start = _selectionVerseStart; + if (start == null) return null; + final count = _selectionVerseCount; try { final chapter = _book!.chapters.firstWhere( (c) => c.chapterNumber == chNum, ); - final section = chapter.sections[secIdx]; - final verse = section.verses.firstWhere((v) => v.verseNumber == vNum); - final deepLink = verseDeepLinkUri(widget.entry, chNum, verse.verseNumber); - return '${verse.text}\n${widget.entry.bookNameAm} $chNum:${verse.verseNumber}\n$deepLink'; + final verseMap = { + for (final sec in chapter.sections) + for (final v in sec.verses) v.verseNumber: v.text, + }; + final texts = [ + for (var v = start; v < start + count; v++) + if (verseMap[v] != null) verseMap[v]!, + ]; + if (texts.isEmpty) return null; + final useGeez = settings.useGeezNumbers; + final startStr = useGeez ? toGeez(start) : '$start'; + final refEnd = count > 1 + ? '-${useGeez ? toGeez(start + count - 1) : '${start + count - 1}'}' + : ''; + final ref = '${widget.entry.bookNameAm} $chNum:$startStr$refEnd'; + final deepLink = verseDeepLinkUri(widget.entry, chNum, start); + return '${texts.join('\n')}\n$ref\n$deepLink'; } catch (_) { return null; } @@ -380,8 +441,9 @@ class _ReaderScreenState extends ConsumerState AppSettings settings, ChapterAnnotations annotations, ) { - final verseNum = _selectedVerseNum; + final verseNum = _selectionVerseStart; if (verseNum == null) return; + final count = _selectionVerseCount; final isDark = settings.isDarkReader; final surfaceColor = isDark ? readerDarkSurface : Colors.white; @@ -405,6 +467,7 @@ class _ReaderScreenState extends ConsumerState verseStart: verseNum, bookNumber: widget.entry.bookNumber, color: color, + verseCount: count, ); if (mounted) _deselect(); }, @@ -428,8 +491,9 @@ class _ReaderScreenState extends ConsumerState AppSettings settings, ChapterAnnotations annotations, ) { - final verseNum = _selectedVerseNum; + final verseNum = _selectionVerseStart; if (verseNum == null) return; + final count = _selectionVerseCount; final isDark = settings.isDarkReader; final surfaceColor = isDark ? readerDarkSurface : Colors.white; @@ -455,6 +519,7 @@ class _ReaderScreenState extends ConsumerState verseStart: verseNum, bookNumber: widget.entry.bookNumber, content: content, + verseCount: count, ); if (mounted) _deselect(); }, @@ -522,7 +587,10 @@ class _ReaderScreenState extends ConsumerState onEdit: () { Navigator.pop(context); if (!mounted) return; - setState(() => _selectedKey = verseKey); + setState(() { + _selectedKey = verseKey; + _selectionEndKey = null; + }); final chKey = (bookId: widget.entry.bookNameEn, chapter: chNum); final liveAnnotations = _riverpodContainer @@ -569,7 +637,7 @@ class _ReaderScreenState extends ConsumerState final annotations = annotationsAsync.value ?? ChapterAnnotations.empty; - final verseNum = _selectedVerseNum; + final verseNum = _selectionVerseStart; final isBookmarked = verseNum != null && annotations.isBookmarked(verseNum); final highlightColor = verseNum != null @@ -731,6 +799,8 @@ class _ReaderScreenState extends ConsumerState verseStart: verseNum, bookNumber: widget.entry.bookNumber, + verseCount: + _selectionVerseCount, ); _deselect(); }, diff --git a/lib/features/saved/presentation/pages/saved_common.dart b/lib/features/saved/presentation/pages/saved_common.dart index 794ea5e..ca97370 100644 --- a/lib/features/saved/presentation/pages/saved_common.dart +++ b/lib/features/saved/presentation/pages/saved_common.dart @@ -12,6 +12,7 @@ class AnnotationItem { required this.bookEntry, required this.chapter, required this.verseStart, + this.verseCount = 1, required this.verseText, required this.createdAt, this.highlightColor, @@ -22,6 +23,7 @@ class AnnotationItem { final BookIndexEntry bookEntry; final int chapter; final int verseStart; + final int verseCount; final String verseText; final DateTime createdAt; final Color? highlightColor; @@ -69,7 +71,9 @@ class AnnotationCard extends StatelessWidget { : tab == 1 ? c.primary : c.accentDeep; - final chRef = '${item.bookShortName(s)} ${item.chapter}:${item.verseStart}'; + final chRef = item.verseCount > 1 + ? '${item.bookShortName(s)} ${item.chapter}:${item.verseStart}–${item.verseStart + item.verseCount - 1}' + : '${item.bookShortName(s)} ${item.chapter}:${item.verseStart}'; return GestureDetector( onTap: onTap, diff --git a/lib/features/saved/presentation/pages/saved_screen.dart b/lib/features/saved/presentation/pages/saved_screen.dart index 78af4b2..2cb6199 100644 --- a/lib/features/saved/presentation/pages/saved_screen.dart +++ b/lib/features/saved/presentation/pages/saved_screen.dart @@ -111,8 +111,15 @@ class _SavedScreenState extends ConsumerState { } } - String getText(String bookId, int ch, int v) => - textMap[bookId]?[ch]?[v] ?? ''; + String getText(String bookId, int ch, int verseStart, [int count = 1]) { + final chMap = textMap[bookId]?[ch]; + if (chMap == null) return ''; + if (count <= 1) return chMap[verseStart] ?? ''; + return [ + for (var v = verseStart; v < verseStart + count; v++) + if (chMap[v] != null) chMap[v]!, + ].join('\n'); + } if (!mounted) return; setState(() { @@ -126,7 +133,8 @@ class _SavedScreenState extends ConsumerState { bookEntry: e, chapter: h.chapter, verseStart: h.verseStart, - verseText: getText(h.bookId, h.chapter, h.verseStart), + verseCount: h.verseCount, + verseText: getText(h.bookId, h.chapter, h.verseStart, h.verseCount), createdAt: h.createdAt, highlightColor: h.color, ); @@ -144,7 +152,8 @@ class _SavedScreenState extends ConsumerState { bookEntry: e, chapter: b.chapter, verseStart: b.verseStart, - verseText: getText(b.bookId, b.chapter, b.verseStart), + verseCount: b.verseCount, + verseText: getText(b.bookId, b.chapter, b.verseStart, b.verseCount), createdAt: b.createdAt, ); }) @@ -161,7 +170,8 @@ class _SavedScreenState extends ConsumerState { bookEntry: e, chapter: n.chapter, verseStart: n.verseStart, - verseText: getText(n.bookId, n.chapter, n.verseStart), + verseCount: n.verseCount, + verseText: getText(n.bookId, n.chapter, n.verseStart, n.verseCount), createdAt: n.createdAt, noteContent: n.content, ); From c9a7fd48c7191b688768ff168be0149ab9e1ba9f Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 26 May 2026 15:32:53 +0300 Subject: [PATCH 27/33] feat: wire reading plans section to backend API - Add ReadingPlan/DailyReading models and ReadingPlanRepository (GET /reading-plans, PATCH day complete) - Add readingPlansProvider (AsyncNotifierProvider) that fetches when authenticated, returns [] otherwise - Add patch() method to ApiClient - Replace hardcoded plan stubs with live data from backend; show auth prompt with login/dismiss when unauthenticated - Add readingPlansSyncPrompt and continueWithoutAccount l10n strings (AM + EN) Co-Authored-By: Claude Sonnet 4.6 --- lib/core/api/api_client.dart | 13 ++ lib/core/l10n/am_strings.dart | 5 + lib/core/l10n/app_strings.dart | 2 + lib/core/l10n/en_strings.dart | 5 + lib/features/home/data/reading_plan.dart | 43 ++++ .../home/data/reading_plan_repository.dart | 27 +++ .../widgets/reading_plans_section.dart | 209 +++++++++++++----- .../providers/reading_plan_providers.dart | 30 +++ 8 files changed, 281 insertions(+), 53 deletions(-) create mode 100644 lib/features/home/data/reading_plan.dart create mode 100644 lib/features/home/data/reading_plan_repository.dart create mode 100644 lib/features/home/providers/reading_plan_providers.dart diff --git a/lib/core/api/api_client.dart b/lib/core/api/api_client.dart index 39d1534..bc45e1a 100644 --- a/lib/core/api/api_client.dart +++ b/lib/core/api/api_client.dart @@ -72,6 +72,19 @@ class ApiClient { return _parse(response); } + Future> patch( + String path, { + Map? body, + String? token, + }) async { + final res = await http.patch( + Uri.parse('$_baseUrl$path'), + headers: _headers(token: token), + body: body != null ? jsonEncode(body) : null, + ); + return _parse(res); + } + Future> delete(String path, {String? token}) async { final res = await http.delete( Uri.parse('$_baseUrl$path'), diff --git a/lib/core/l10n/am_strings.dart b/lib/core/l10n/am_strings.dart index 2ddc3d2..ea8b25b 100644 --- a/lib/core/l10n/am_strings.dart +++ b/lib/core/l10n/am_strings.dart @@ -45,6 +45,11 @@ class AmStrings extends AppStrings { String get viewAll => 'ሁሉንም ይመልከቱ'; @override String daysCount(int n) => '$n ቀናት'; + @override + String get readingPlansSyncPrompt => + 'የንባብ ዕቅዶዎን በሁሉም መሣሪያዎች ለማስተባበር ይግቡ'; + @override + String get continueWithoutAccount => 'ያለ መለያ ቀጥል'; // ── Me / Settings ───────────────────────────────────────────────────────── @override diff --git a/lib/core/l10n/app_strings.dart b/lib/core/l10n/app_strings.dart index f1d9268..9391fda 100644 --- a/lib/core/l10n/app_strings.dart +++ b/lib/core/l10n/app_strings.dart @@ -31,6 +31,8 @@ abstract class AppStrings { String get readingPlansTitle; String get viewAll; String daysCount(int n); + String get readingPlansSyncPrompt; + String get continueWithoutAccount; // ── Me / Settings screen ────────────────────────────────────────────────── String get meTitle; diff --git a/lib/core/l10n/en_strings.dart b/lib/core/l10n/en_strings.dart index 7c9dc33..8cbaa50 100644 --- a/lib/core/l10n/en_strings.dart +++ b/lib/core/l10n/en_strings.dart @@ -45,6 +45,11 @@ class EnStrings extends AppStrings { String get viewAll => 'View all'; @override String daysCount(int n) => '$n days'; + @override + String get readingPlansSyncPrompt => + 'Log in to sync your reading plans across devices'; + @override + String get continueWithoutAccount => 'Continue without account'; // ── Me / Settings ───────────────────────────────────────────────────────── @override diff --git a/lib/features/home/data/reading_plan.dart b/lib/features/home/data/reading_plan.dart new file mode 100644 index 0000000..c241a6e --- /dev/null +++ b/lib/features/home/data/reading_plan.dart @@ -0,0 +1,43 @@ +class DailyReading { + const DailyReading({ + required this.dayNumber, + required this.isCompleted, + }); + + final int dayNumber; + final bool isCompleted; + + factory DailyReading.fromJson(Map m) => DailyReading( + dayNumber: (m['dayNumber'] as num).toInt(), + isCompleted: (m['isCompleted'] as bool?) ?? false, + ); +} + +class ReadingPlan { + const ReadingPlan({ + required this.id, + required this.name, + required this.durationInDays, + required this.dailyReadings, + this.status, + }); + + final String id; + final String name; + final int durationInDays; + final List dailyReadings; + final String? status; + + int get completedDays => dailyReadings.where((d) => d.isCompleted).length; + + factory ReadingPlan.fromJson(Map m) => ReadingPlan( + id: (m['_id'] ?? m['id'] ?? '') as String, + name: (m['name'] as String?) ?? '', + durationInDays: (m['durationInDays'] as num?)?.toInt() ?? 0, + dailyReadings: ((m['dailyReadings'] as List?) ?? []) + .whereType>() + .map(DailyReading.fromJson) + .toList(), + status: m['status'] as String?, + ); +} diff --git a/lib/features/home/data/reading_plan_repository.dart b/lib/features/home/data/reading_plan_repository.dart new file mode 100644 index 0000000..115c146 --- /dev/null +++ b/lib/features/home/data/reading_plan_repository.dart @@ -0,0 +1,27 @@ +import '../../../core/api/api_client.dart'; +import 'reading_plan.dart'; + +class ReadingPlanRepository { + const ReadingPlanRepository(this._api, this._token); + + final ApiClient _api; + final String _token; + + Future> fetchPlans() async { + final res = await _api.get('/reading-plans', token: _token); + final data = res['data']; + final list = (data is Map + ? (data['readingPlans'] ?? data['data'] ?? data) + : data) as List? ?? + []; + return list + .whereType>() + .map(ReadingPlan.fromJson) + .where((p) => p.id.isNotEmpty && p.name.isNotEmpty) + .toList(); + } + + Future markDayComplete(String planId, int dayNumber) => + _api.patch('/reading-plans/$planId/days/$dayNumber/complete', + token: _token); +} diff --git a/lib/features/home/presentation/widgets/reading_plans_section.dart b/lib/features/home/presentation/widgets/reading_plans_section.dart index bf5c94b..463abbe 100644 --- a/lib/features/home/presentation/widgets/reading_plans_section.dart +++ b/lib/features/home/presentation/widgets/reading_plans_section.dart @@ -1,42 +1,40 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/auth/auth_state.dart'; import '../../../../core/constants/app_icons.dart'; import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_typography.dart'; +import '../../../auth/presentation/pages/login_screen.dart'; +import '../../data/reading_plan.dart'; +import '../../providers/reading_plan_providers.dart'; -class ReadingPlansSection extends StatelessWidget { +// Cycles through these colors for plan cards when more than one plan exists. +const _cardColors = [ + AppColors.primary, + AppColors.newTestament, + Color(0xFF3E5C3A), + Color(0xFF7A4E2D), +]; + +class ReadingPlansSection extends ConsumerStatefulWidget { const ReadingPlansSection({super.key}); - // Titles are data — will come from the data layer once wired. - // Using Amharic names directly here since they're proper nouns. - static const _plans = [ - _PlanData( - titleAm: 'መጽሐፈ ዘፍጥረት', - titleEn: 'Genesis', - totalDays: 30, - completedDays: 12, - color: AppColors.primary, - ), - _PlanData( - titleAm: 'ወንጌለ ዮሐንስ', - titleEn: 'Gospel of John', - totalDays: 21, - completedDays: 5, - color: AppColors.newTestament, - ), - _PlanData( - titleAm: 'መዝሙረ ዳዊት', - titleEn: 'Psalms', - totalDays: 45, - completedDays: 0, - color: Color(0xFF3E5C3A), - ), - ]; + @override + ConsumerState createState() => + _ReadingPlansSectionState(); +} + +class _ReadingPlansSectionState extends ConsumerState { + bool _dismissed = false; @override Widget build(BuildContext context) { final s = L10n.of(context); final c = context.colors; + final isAuthenticated = + ref.watch(authStateProvider).status == AuthStatus.authenticated; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -67,59 +65,164 @@ class ReadingPlansSection extends StatelessWidget { ), ), const SizedBox(height: 12), - SizedBox( + if (!isAuthenticated && !_dismissed) + _AuthPrompt( + onLogin: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const LoginScreen()), + ), + onDismiss: () => setState(() => _dismissed = true), + ) + else + _PlansList(dismissed: _dismissed), + ], + ); + } +} + +class _PlansList extends ConsumerWidget { + const _PlansList({required this.dismissed}); + + final bool dismissed; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final plansAsync = ref.watch(readingPlansProvider); + + return plansAsync.when( + loading: () => const SizedBox( + height: 148, + child: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => const SizedBox(height: 148), + data: (plans) { + if (plans.isEmpty) return const SizedBox(height: 148); + return SizedBox( height: 148, child: ListView.builder( scrollDirection: Axis.horizontal, physics: const BouncingScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: _plans.length, + itemCount: plans.length, itemBuilder: (ctx, i) => Padding( padding: const EdgeInsets.only(right: 12), - child: _ReadingPlanCard(plan: _plans[i]), + child: _ReadingPlanCard( + plan: plans[i], + color: _cardColors[i % _cardColors.length], + ), ), ), - ), - ], + ); + }, ); } } -class _PlanData { - final String titleAm; - final String titleEn; - final int totalDays; - final int completedDays; - final Color color; +class _AuthPrompt extends StatelessWidget { + const _AuthPrompt({required this.onLogin, required this.onDismiss}); + + final VoidCallback onLogin; + final VoidCallback onDismiss; - const _PlanData({ - required this.titleAm, - required this.titleEn, - required this.totalDays, - required this.completedDays, - required this.color, - }); + @override + Widget build(BuildContext context) { + final s = L10n.of(context); + final c = context.colors; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: c.primary.withValues(alpha: 0.25)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + s.readingPlansSyncPrompt, + style: AppTypography.amharicBody.copyWith( + color: c.textOnParchment, + fontSize: 13, + ), + ), + const SizedBox(height: 10), + Row( + children: [ + FilledButton( + onPressed: onLogin, + style: FilledButton.styleFrom( + backgroundColor: c.primary, + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: Text( + s.loginButton, + style: AppTypography.amharicLabel.copyWith( + color: Colors.white, + fontSize: 12, + ), + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: onDismiss, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + s.continueWithoutAccount, + style: AppTypography.amharicCaption.copyWith( + color: c.textOnParchment.withValues(alpha: 0.6), + fontSize: 11, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } } class _ReadingPlanCard extends StatelessWidget { - const _ReadingPlanCard({required this.plan}); - final _PlanData plan; + const _ReadingPlanCard({required this.plan, required this.color}); + + final ReadingPlan plan; + final Color color; @override Widget build(BuildContext context) { final s = L10n.of(context); - final isAmharic = s is AmStrings; - final progress = plan.completedDays / plan.totalDays; + final progress = plan.durationInDays > 0 + ? plan.completedDays / plan.durationInDays + : 0.0; return Container( width: 158, padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: plan.color, + color: color, borderRadius: BorderRadius.circular(18), boxShadow: [ BoxShadow( - color: plan.color.withValues(alpha: 0.3), + color: color.withValues(alpha: 0.3), blurRadius: 10, offset: const Offset(0, 4), ), @@ -143,7 +246,7 @@ class _ReadingPlanCard extends StatelessWidget { ), const Spacer(), Text( - isAmharic ? plan.titleAm : plan.titleEn, + plan.name, style: AppTypography.amharicLabel.copyWith( color: Colors.white, fontSize: 13, @@ -154,7 +257,7 @@ class _ReadingPlanCard extends StatelessWidget { ), const SizedBox(height: 4), Text( - s.daysCount(plan.totalDays), + s.daysCount(plan.durationInDays), style: AppTypography.amharicCaption.copyWith( color: Colors.white.withValues(alpha: 0.7), fontSize: 11, @@ -164,7 +267,7 @@ class _ReadingPlanCard extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( - value: progress, + value: progress.clamp(0.0, 1.0), minHeight: 4, backgroundColor: Colors.white.withValues(alpha: 0.25), valueColor: AlwaysStoppedAnimation(context.colors.accent), diff --git a/lib/features/home/providers/reading_plan_providers.dart b/lib/features/home/providers/reading_plan_providers.dart new file mode 100644 index 0000000..2636a10 --- /dev/null +++ b/lib/features/home/providers/reading_plan_providers.dart @@ -0,0 +1,30 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/auth/auth_state.dart'; +import '../data/reading_plan.dart'; +import '../data/reading_plan_repository.dart'; + +final readingPlansProvider = + AsyncNotifierProvider>( + ReadingPlansNotifier.new, +); + +class ReadingPlansNotifier extends AsyncNotifier> { + @override + Future> build() async { + final authState = ref.watch(authStateProvider); + if (!authState.isAuthenticated) return []; + final repo = ReadingPlanRepository( + ref.read(apiClientProvider), + authState.token!, + ); + return repo.fetchPlans(); + } + + Future markDayComplete(String planId, int dayNumber) async { + final token = ref.read(authStateProvider).token; + if (token == null) return; + final repo = ReadingPlanRepository(ref.read(apiClientProvider), token); + await repo.markDayComplete(planId, dayNumber); + ref.invalidateSelf(); + } +} From 99037044af87bc7a495c297e3279973cb559bea7 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 26 May 2026 15:44:05 +0300 Subject: [PATCH 28/33] feat: book-style reading plan cards and View All screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract BookCover to lib/core/widgets/book_cover.dart with coverColor/size params; remove duplicate from continue_reading_section - Add testamentColor() helper — OT burgundy, NT navy, deuterocanonical olive - Add startBook to ReadingPlan model for testament detection - Redesign carousel cards: upright book cover + name + progress, colour from testament - Add ReadingPlansScreen (full list, same book cover + horizontal card layout) - Wire View All button to ReadingPlansScreen Co-Authored-By: Claude Sonnet 4.6 --- lib/core/widgets/book_cover.dart | 214 ++++++++++++++++++ lib/features/home/data/reading_plan.dart | 3 + .../pages/reading_plans_screen.dart | 170 ++++++++++++++ .../widgets/continue_reading_section.dart | 170 +------------- .../widgets/reading_plans_section.dart | 109 ++++----- 5 files changed, 432 insertions(+), 234 deletions(-) create mode 100644 lib/core/widgets/book_cover.dart create mode 100644 lib/features/home/presentation/pages/reading_plans_screen.dart diff --git a/lib/core/widgets/book_cover.dart b/lib/core/widgets/book_cover.dart new file mode 100644 index 0000000..2e176e7 --- /dev/null +++ b/lib/core/widgets/book_cover.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import '../theme/app_colors.dart'; + +/// A closed Bible book cover widget. +/// +/// [coverColor] is the main face/back colour. +/// [coverDarkColor] is the spine shadow colour (defaults to a darker shade of [coverColor]). +/// [width] / [height] control the cover face size (defaults match the continue-reading card). +class BookCover extends StatelessWidget { + const BookCover({ + super.key, + this.coverColor, + this.coverDarkColor, + this.width = 56, + this.height = 84, + }); + + final Color? coverColor; + final Color? coverDarkColor; + final double width; + final double height; + + @override + Widget build(BuildContext context) { + final c = context.colors; + final face = coverColor ?? c.primary; + final dark = coverDarkColor ?? _darken(face); + final accent = c.accent; + final pageEdge1 = c.surface; + const pageEdge2 = Color(0xFFE6DFD3); + + return SizedBox( + width: width + 10, + height: height + 10, + child: Align( + alignment: Alignment.topLeft, + child: Container( + width: width, + height: height, + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(4), + topRight: Radius.circular(8), + bottomRight: Radius.circular(8), + bottomLeft: Radius.circular(4), + ), + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [dark, face, face], + stops: const [0, 0.12, 1], + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.5), + offset: Offset(width * 0.125, height * 0.12), + blurRadius: 12, + ), + BoxShadow(color: face, offset: const Offset(4, 4)), + BoxShadow(color: pageEdge2, offset: const Offset(3, 3)), + BoxShadow(color: pageEdge1, offset: const Offset(2, 2)), + BoxShadow(color: pageEdge2, offset: const Offset(1.5, 1.5)), + BoxShadow(color: pageEdge1, offset: const Offset(1, 1)), + ], + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + // Spine shadow + Positioned( + left: 0, + top: 0, + bottom: 0, + width: width * 0.285, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(4), + bottomLeft: Radius.circular(4), + ), + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Colors.black.withValues(alpha: 0.28), + Colors.black.withValues(alpha: 0.05), + Colors.transparent, + ], + ), + ), + ), + ), + // Spine groove + Positioned( + left: width * 0.09, + top: 0, + bottom: 0, + width: 3, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.25), + boxShadow: const [ + BoxShadow( + color: Color(0x0DFFFFFF), + offset: Offset(1, 0), + blurRadius: 2, + ), + ], + ), + ), + ), + // Gold inlay border + Positioned( + left: width * 0.16, + top: 4, + right: 4, + bottom: 4, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(2), + topRight: Radius.circular(5), + bottomRight: Radius.circular(5), + bottomLeft: Radius.circular(2), + ), + border: Border.all( + color: accent.withValues(alpha: 0.55), + width: 1.2, + ), + ), + ), + ), + // Cross + Center( + child: Padding( + padding: EdgeInsets.only(left: width * 0.07), + child: CustomPaint( + size: Size(width * 0.285, height * 0.262), + painter: _CrossPainter(color: accent), + ), + ), + ), + ], + ), + ), + ), + ); + } + + static Color _darken(Color c) { + final hsl = HSLColor.fromColor(c); + return hsl.withLightness((hsl.lightness - 0.15).clamp(0.0, 1.0)).toColor(); + } +} + +class _CrossPainter extends CustomPainter { + _CrossPainter({required this.color}); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final w = size.width; + final h = size.height; + final sx = w / 24; + final sy = h / 32; + final stroke = 3 * ((sx + sy) / 2).clamp(1.8, 3.2); + + final paint = Paint() + ..color = color.withValues(alpha: 0.9) + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + + canvas.drawLine(Offset(12 * sx, 2 * sy), Offset(12 * sx, 30 * sy), paint); + canvas.drawLine(Offset(2 * sx, 10 * sy), Offset(22 * sx, 10 * sy), paint); + } + + @override + bool shouldRepaint(covariant _CrossPainter old) => old.color != color; +} + +/// Infers the testament colour from a book name (kebab or title-case). +Color testamentColor(String bookName) { + final key = bookName.toLowerCase().replaceAll(' ', '-').replaceAll('_', '-'); + if (_ntKeys.contains(key)) return AppColors.newTestament; + if (_deutKeys.contains(key)) return const Color(0xFF3E5C3A); // dark olive + return AppColors.oldTestament; +} + +Color testamentDarkColor(String bookName) { + final c = testamentColor(bookName); + return HSLColor.fromColor(c) + .withLightness( + (HSLColor.fromColor(c).lightness - 0.15).clamp(0.0, 1.0)) + .toColor(); +} + +const _ntKeys = { + 'matthew', 'mark', 'luke', 'john', 'acts', 'romans', + '1-corinthians', '2-corinthians', 'galatians', 'ephesians', + 'philippians', 'colossians', '1-thessalonians', '2-thessalonians', + '1-timothy', '2-timothy', 'titus', 'philemon', 'hebrews', + 'james', '1-peter', '2-peter', '1-john', '2-john', '3-john', + 'jude', 'revelation', +}; + +const _deutKeys = { + 'jubilees', 'enoch', 'book-of-tobit', 'book-of-judith', + '1-maccabees', '2-maccabees', '3-maccabees', + 'wisdom-of-solomon', 'book-of-sirach', 'baruch', + 'thr-letter-of-jeremiah', 'teref-baruch', + '3-book-of-ezra', '2nd-book-of-ezra', +}; diff --git a/lib/features/home/data/reading_plan.dart b/lib/features/home/data/reading_plan.dart index c241a6e..411cb39 100644 --- a/lib/features/home/data/reading_plan.dart +++ b/lib/features/home/data/reading_plan.dart @@ -19,6 +19,7 @@ class ReadingPlan { required this.name, required this.durationInDays, required this.dailyReadings, + this.startBook = '', this.status, }); @@ -26,6 +27,7 @@ class ReadingPlan { final String name; final int durationInDays; final List dailyReadings; + final String startBook; final String? status; int get completedDays => dailyReadings.where((d) => d.isCompleted).length; @@ -38,6 +40,7 @@ class ReadingPlan { .whereType>() .map(DailyReading.fromJson) .toList(), + startBook: (m['startBook'] as String?) ?? '', status: m['status'] as String?, ); } diff --git a/lib/features/home/presentation/pages/reading_plans_screen.dart b/lib/features/home/presentation/pages/reading_plans_screen.dart new file mode 100644 index 0000000..9c25680 --- /dev/null +++ b/lib/features/home/presentation/pages/reading_plans_screen.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/l10n/l10n.dart'; +import '../../../../core/theme/app_color_scheme.dart'; +import '../../../../core/theme/app_typography.dart'; +import '../../../../core/widgets/book_cover.dart'; +import '../../data/reading_plan.dart'; +import '../../providers/reading_plan_providers.dart'; + +class ReadingPlansScreen extends ConsumerWidget { + const ReadingPlansScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final s = L10n.of(context); + final c = context.colors; + final plansAsync = ref.watch(readingPlansProvider); + + return Scaffold( + backgroundColor: c.parchment, + appBar: AppBar( + backgroundColor: c.parchment, + elevation: 0, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: Icon(Icons.arrow_back_ios_new_rounded, color: c.textOnParchment, size: 20), + onPressed: () => Navigator.pop(context), + ), + title: Text( + s.readingPlansTitle, + style: AppTypography.amharicSubheading.copyWith(color: c.textOnParchment), + ), + ), + body: plansAsync.when( + loading: () => Center( + child: CircularProgressIndicator(color: c.primary), + ), + error: (e, s) => Center( + child: Text( + e.toString(), + style: AppTypography.amharicBody.copyWith(color: c.textMuted), + ), + ), + data: (plans) { + if (plans.isEmpty) { + return Center( + child: Text( + s.readingPlansTitle, + style: AppTypography.amharicBody.copyWith(color: c.textMuted), + ), + ); + } + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + itemCount: plans.length, + separatorBuilder: (_, i) => const SizedBox(height: 12), + itemBuilder: (ctx, i) => _PlanListCard(plan: plans[i]), + ); + }, + ), + ); + } +} + +class _PlanListCard extends StatelessWidget { + const _PlanListCard({required this.plan}); + + final ReadingPlan plan; + + @override + Widget build(BuildContext context) { + final s = L10n.of(context); + final c = context.colors; + final coverColor = testamentColor(plan.startBook); + final pct = plan.durationInDays > 0 + ? (plan.completedDays / plan.durationInDays).clamp(0.0, 1.0) + : 0.0; + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: () {}, + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: c.borderSubtle), + boxShadow: const [ + BoxShadow( + color: Color(0x08000000), + blurRadius: 12, + offset: Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + BookCover(coverColor: coverColor), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + plan.name, + style: AppTypography.amharicLabel.copyWith( + color: c.textOnParchment, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + s.daysCount(plan.durationInDays), + style: AppTypography.englishCaption.copyWith( + color: c.textMuted, + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: pct, + minHeight: 6, + backgroundColor: c.parchmentDark, + valueColor: AlwaysStoppedAnimation(coverColor), + ), + ), + ), + const SizedBox(width: 8), + Text( + '${(pct * 100).round()}%', + style: AppTypography.englishCaption.copyWith( + color: coverColor, + fontWeight: FontWeight.w700, + fontSize: 11, + letterSpacing: 0, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 10), + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: coverColor.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: Icon( + Icons.chevron_right_rounded, + color: coverColor, + size: 22, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/continue_reading_section.dart b/lib/features/home/presentation/widgets/continue_reading_section.dart index 9ab0fe2..3e04975 100644 --- a/lib/features/home/presentation/widgets/continue_reading_section.dart +++ b/lib/features/home/presentation/widgets/continue_reading_section.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/app_color_scheme.dart'; import '../../../../core/theme/app_typography.dart'; +import '../../../../core/widgets/book_cover.dart'; import '../../../books/presentation/pages/reader_screen.dart'; import '../../../books/providers/reading_progress_providers.dart'; @@ -171,7 +172,7 @@ class _ContinueReadingCard extends StatelessWidget { ), child: Row( children: [ - const _ClosedBookCover(), + const BookCover(), const SizedBox(width: 14), Expanded( child: Column( @@ -279,7 +280,7 @@ class _EmptyContinueCard extends StatelessWidget { ), child: Row( children: [ - const _ClosedBookCover(), + const BookCover(), const SizedBox(width: 14), Expanded( child: Column( @@ -333,167 +334,4 @@ class _ContinueCardSkeleton extends StatelessWidget { } } -/// Closed Bible cover: spine gradient, stacked “page” shadows, gold inlay, simple cross (swap later). -class _ClosedBookCover extends StatelessWidget { - const _ClosedBookCover(); - - static const double _w = 56; - static const double _h = 84; - - @override - Widget build(BuildContext context) { - final c = context.colors; - final pageEdge1 = c.surface; - const pageEdge2 = Color(0xFFE6DFD3); - - return SizedBox( - width: _w + 10, - height: _h + 10, - child: Align( - alignment: Alignment.topLeft, - child: Container( - width: _w, - height: _h, - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(4), - topRight: Radius.circular(8), - bottomRight: Radius.circular(8), - bottomLeft: Radius.circular(4), - ), - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [c.primaryDark, c.primary, c.primary], - stops: const [0, 0.12, 1], - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.5), - offset: const Offset(7, 10), - blurRadius: 12, - spreadRadius: 0, - ), - BoxShadow(color: c.primary, offset: const Offset(4, 4), blurRadius: 0), - BoxShadow(color: pageEdge2, offset: const Offset(3, 3), blurRadius: 0), - BoxShadow(color: pageEdge1, offset: const Offset(2, 2), blurRadius: 0), - BoxShadow(color: pageEdge2, offset: const Offset(1.5, 1.5), blurRadius: 0), - BoxShadow(color: pageEdge1, offset: const Offset(1, 1), blurRadius: 0), - ], - ), - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned( - left: 0, - top: 0, - bottom: 0, - width: 16, - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(4), - bottomLeft: Radius.circular(4), - ), - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - Colors.black.withValues(alpha: 0.28), - Colors.black.withValues(alpha: 0.05), - Colors.transparent, - ], - ), - ), - ), - ), - Positioned( - left: 5, - top: 0, - bottom: 0, - width: 3, - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.25), - boxShadow: const [ - BoxShadow( - color: Color(0x0DFFFFFF), - offset: Offset(1, 0), - blurRadius: 2, - ), - ], - ), - ), - ), - Positioned( - left: 9, - top: 4, - right: 4, - bottom: 4, - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(2), - topRight: Radius.circular(5), - bottomRight: Radius.circular(5), - bottomLeft: Radius.circular(2), - ), - border: Border.all( - color: c.accent.withValues(alpha: 0.55), - width: 1.2, - ), - ), - ), - ), - Center( - child: Padding( - padding: const EdgeInsets.only(left: 4), - child: CustomPaint( - size: const Size(16, 22), - painter: _SimpleGoldCrossPainter(color: c.accent), - ), - ), - ), - ], - ), - ), - ), - ); - } -} - -/// Matches the HTML SVG: vertical + horizontal bar, round caps (viewBox 24×32, stroke 3). -class _SimpleGoldCrossPainter extends CustomPainter { - _SimpleGoldCrossPainter({required this.color}); - - final Color color; - - @override - void paint(Canvas canvas, Size size) { - final w = size.width; - final h = size.height; - final sx = w / 24; - final sy = h / 32; - final stroke = 3 * ((sx + sy) / 2).clamp(1.8, 3.2); - - final paint = Paint() - ..color = color.withValues(alpha: 0.9) - ..strokeWidth = stroke - ..strokeCap = StrokeCap.round - ..style = PaintingStyle.stroke; - - final cx = 12 * sx; - final top = 2 * sy; - final bottom = 30 * sy; - final left = 2 * sx; - final right = 22 * sx; - final midY = 10 * sy; - - canvas.drawLine(Offset(cx, top), Offset(cx, bottom), paint); - canvas.drawLine(Offset(left, midY), Offset(right, midY), paint); - } - - @override - bool shouldRepaint(covariant _SimpleGoldCrossPainter oldDelegate) => - oldDelegate.color != color; -} +// Book cover is now the shared BookCover widget from core/widgets/book_cover.dart. diff --git a/lib/features/home/presentation/widgets/reading_plans_section.dart b/lib/features/home/presentation/widgets/reading_plans_section.dart index 463abbe..e27b62e 100644 --- a/lib/features/home/presentation/widgets/reading_plans_section.dart +++ b/lib/features/home/presentation/widgets/reading_plans_section.dart @@ -1,21 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/auth/auth_state.dart'; -import '../../../../core/constants/app_icons.dart'; import '../../../../core/l10n/l10n.dart'; -import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/app_color_scheme.dart'; import '../../../../core/theme/app_typography.dart'; +import '../../../../core/widgets/book_cover.dart'; import '../../../auth/presentation/pages/login_screen.dart'; import '../../data/reading_plan.dart'; import '../../providers/reading_plan_providers.dart'; - -// Cycles through these colors for plan cards when more than one plan exists. -const _cardColors = [ - AppColors.primary, - AppColors.newTestament, - Color(0xFF3E5C3A), - Color(0xFF7A4E2D), -]; +import '../pages/reading_plans_screen.dart'; class ReadingPlansSection extends ConsumerStatefulWidget { const ReadingPlansSection({super.key}); @@ -52,7 +45,11 @@ class _ReadingPlansSectionState extends ConsumerState { ), const Spacer(), GestureDetector( - onTap: () {}, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const ReadingPlansScreen()), + ), child: Text( s.viewAll, style: AppTypography.amharicCaption.copyWith( @@ -74,42 +71,40 @@ class _ReadingPlansSectionState extends ConsumerState { onDismiss: () => setState(() => _dismissed = true), ) else - _PlansList(dismissed: _dismissed), + _PlansList(), ], ); } } class _PlansList extends ConsumerWidget { - const _PlansList({required this.dismissed}); - - final bool dismissed; + const _PlansList(); @override Widget build(BuildContext context, WidgetRef ref) { + final c = context.colors; final plansAsync = ref.watch(readingPlansProvider); return plansAsync.when( - loading: () => const SizedBox( - height: 148, - child: Center(child: CircularProgressIndicator()), + loading: () => SizedBox( + height: 172, + child: Center( + child: CircularProgressIndicator(color: c.primary, strokeWidth: 2), + ), ), - error: (e, _) => const SizedBox(height: 148), + error: (e, _) => const SizedBox(height: 172), data: (plans) { - if (plans.isEmpty) return const SizedBox(height: 148); + if (plans.isEmpty) return const SizedBox(height: 172); return SizedBox( - height: 148, + height: 172, child: ListView.builder( scrollDirection: Axis.horizontal, physics: const BouncingScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: plans.length, itemBuilder: (ctx, i) => Padding( - padding: const EdgeInsets.only(right: 12), - child: _ReadingPlanCard( - plan: plans[i], - color: _cardColors[i % _cardColors.length], - ), + padding: const EdgeInsets.only(right: 14), + child: _ReadingPlanCard(plan: plans[i]), ), ), ); @@ -201,55 +196,33 @@ class _AuthPrompt extends StatelessWidget { } } +/// Carousel card: book cover on top, plan name + progress below. class _ReadingPlanCard extends StatelessWidget { - const _ReadingPlanCard({required this.plan, required this.color}); + const _ReadingPlanCard({required this.plan}); final ReadingPlan plan; - final Color color; @override Widget build(BuildContext context) { final s = L10n.of(context); - final progress = plan.durationInDays > 0 - ? plan.completedDays / plan.durationInDays + final c = context.colors; + final coverColor = testamentColor(plan.startBook); + final pct = plan.durationInDays > 0 + ? (plan.completedDays / plan.durationInDays).clamp(0.0, 1.0) : 0.0; - return Container( - width: 158, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(18), - boxShadow: [ - BoxShadow( - color: color.withValues(alpha: 0.3), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), + return SizedBox( + width: 110, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(8), - ), - alignment: Alignment.center, - child: Text( - AppIcons.ethiopianCross, - style: TextStyle(color: context.colors.accent, fontSize: 16), - ), - ), - const Spacer(), + BookCover(coverColor: coverColor, width: 66, height: 98), + const SizedBox(height: 6), Text( plan.name, style: AppTypography.amharicLabel.copyWith( - color: Colors.white, - fontSize: 13, + color: c.textOnParchment, + fontSize: 12, height: 1.3, ), maxLines: 2, @@ -258,19 +231,19 @@ class _ReadingPlanCard extends StatelessWidget { const SizedBox(height: 4), Text( s.daysCount(plan.durationInDays), - style: AppTypography.amharicCaption.copyWith( - color: Colors.white.withValues(alpha: 0.7), - fontSize: 11, + style: AppTypography.englishCaption.copyWith( + color: c.textMuted, + fontSize: 10, ), ), - const SizedBox(height: 10), + const SizedBox(height: 6), ClipRRect( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(3), child: LinearProgressIndicator( - value: progress.clamp(0.0, 1.0), + value: pct, minHeight: 4, - backgroundColor: Colors.white.withValues(alpha: 0.25), - valueColor: AlwaysStoppedAnimation(context.colors.accent), + backgroundColor: c.parchmentDark, + valueColor: AlwaysStoppedAnimation(coverColor), ), ), ], From 0fe383a5f5f29fb4fb926d094b705a910f052abe Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 26 May 2026 15:47:32 +0300 Subject: [PATCH 29/33] fix: increase reading plan carousel height to prevent 2px overflow Co-Authored-By: Claude Sonnet 4.6 --- .../home/presentation/widgets/reading_plans_section.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/features/home/presentation/widgets/reading_plans_section.dart b/lib/features/home/presentation/widgets/reading_plans_section.dart index e27b62e..58063ed 100644 --- a/lib/features/home/presentation/widgets/reading_plans_section.dart +++ b/lib/features/home/presentation/widgets/reading_plans_section.dart @@ -87,16 +87,16 @@ class _PlansList extends ConsumerWidget { return plansAsync.when( loading: () => SizedBox( - height: 172, + height: 176, child: Center( child: CircularProgressIndicator(color: c.primary, strokeWidth: 2), ), ), - error: (e, _) => const SizedBox(height: 172), + error: (e, _) => const SizedBox(height: 176), data: (plans) { - if (plans.isEmpty) return const SizedBox(height: 172); + if (plans.isEmpty) return const SizedBox(height: 176); return SizedBox( - height: 172, + height: 176, child: ListView.builder( scrollDirection: Axis.horizontal, physics: const BouncingScrollPhysics(), From a7c395e400cb007d71c79d0ecd2ca5a3e095158e Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 26 May 2026 15:49:32 +0300 Subject: [PATCH 30/33] fix: constrain plan name to fixed height so all cards are uniform Co-Authored-By: Claude Sonnet 4.6 --- .../widgets/reading_plans_section.dart | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/features/home/presentation/widgets/reading_plans_section.dart b/lib/features/home/presentation/widgets/reading_plans_section.dart index 58063ed..deef078 100644 --- a/lib/features/home/presentation/widgets/reading_plans_section.dart +++ b/lib/features/home/presentation/widgets/reading_plans_section.dart @@ -218,15 +218,18 @@ class _ReadingPlanCard extends StatelessWidget { children: [ BookCover(coverColor: coverColor, width: 66, height: 98), const SizedBox(height: 6), - Text( - plan.name, - style: AppTypography.amharicLabel.copyWith( - color: c.textOnParchment, - fontSize: 12, - height: 1.3, + SizedBox( + height: 32, + child: Text( + plan.name, + style: AppTypography.amharicLabel.copyWith( + color: c.textOnParchment, + fontSize: 12, + height: 1.3, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Text( From fb69c2e218f19c0a1df35d049395b2657da5b79d Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 26 May 2026 15:56:40 +0300 Subject: [PATCH 31/33] feat: tap reading plan card to open reader and resume position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parse DailyReadingItem (book, startChapter, endChapter) from each day's readings - Add plan_position table to AppDatabase (v5 migration) with get/save methods - openReadingPlan() resolves saved position → first incomplete day → first day, maps kebab book name to BookIndexEntry, saves position, navigates to ReaderScreen - Wire tap on carousel card and View All list card to openReadingPlan() Co-Authored-By: Claude Sonnet 4.6 --- lib/core/storage/app_database.dart | 49 +++++++++++++- lib/features/home/data/reading_plan.dart | 25 ++++++++ .../pages/reading_plans_screen.dart | 6 +- .../widgets/reading_plans_section.dart | 9 ++- .../providers/reading_plan_providers.dart | 64 +++++++++++++++++++ 5 files changed, 146 insertions(+), 7 deletions(-) diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 6a28cce..13cb728 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -5,7 +5,7 @@ import '../annotations/annotation_models.dart'; class AppDatabase { static const _dbName = 'bibleapp.db'; - static const _version = 4; + static const _version = 5; Database? _db; @@ -39,6 +39,9 @@ class AppDatabase { if (oldVersion < 4) { await _markLegacyBookIdsPendingUpdate(db); } + if (oldVersion < 5) { + await _createPlanPositionTable(db); + } } /// v3→v4: annotation items pushed with title-case bookId (e.g. "Genesis") @@ -185,8 +188,19 @@ class AppDatabase { ) '''); await _createReadingTables(db); + await _createPlanPositionTable(db); } + Future _createPlanPositionTable(Database db) => db.execute(''' + CREATE TABLE IF NOT EXISTS plan_position ( + plan_id TEXT NOT NULL PRIMARY KEY, + day_number INTEGER NOT NULL, + book_id TEXT NOT NULL, + chapter INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + '''); + // ── Reading position / progress / streak ─────────────────────────────────── Future?> getReadingPositionRow() async { @@ -659,6 +673,39 @@ class AppDatabase { } } + // ── Plan position ────────────────────────────────────────────────────────── + + Future?> getPlanPosition(String planId) async { + final db = await database; + final rows = await db.query( + 'plan_position', + where: 'plan_id = ?', + whereArgs: [planId], + limit: 1, + ); + return rows.isEmpty ? null : rows.first; + } + + Future savePlanPosition({ + required String planId, + required int dayNumber, + required String bookId, + required int chapter, + }) async { + final db = await database; + await db.insert( + 'plan_position', + { + 'plan_id': planId, + 'day_number': dayNumber, + 'book_id': bookId, + 'chapter': chapter, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + Future close() async { await _db?.close(); _db = null; diff --git a/lib/features/home/data/reading_plan.dart b/lib/features/home/data/reading_plan.dart index 411cb39..c03dee7 100644 --- a/lib/features/home/data/reading_plan.dart +++ b/lib/features/home/data/reading_plan.dart @@ -1,15 +1,40 @@ +class DailyReadingItem { + const DailyReadingItem({ + required this.book, + required this.startChapter, + required this.endChapter, + }); + + final String book; // kebab-case from API, e.g. "wisdom-of-solomon" + final int startChapter; + final int endChapter; + + factory DailyReadingItem.fromJson(Map m) => DailyReadingItem( + book: (m['book'] as String?) ?? '', + startChapter: (m['startChapter'] as num?)?.toInt() ?? 1, + endChapter: (m['endChapter'] as num?)?.toInt() ?? 1, + ); +} + class DailyReading { const DailyReading({ required this.dayNumber, required this.isCompleted, + this.readings = const [], }); final int dayNumber; final bool isCompleted; + final List readings; factory DailyReading.fromJson(Map m) => DailyReading( dayNumber: (m['dayNumber'] as num).toInt(), isCompleted: (m['isCompleted'] as bool?) ?? false, + readings: ((m['readings'] as List?) ?? []) + .whereType>() + .map(DailyReadingItem.fromJson) + .where((r) => r.book.isNotEmpty) + .toList(), ); } diff --git a/lib/features/home/presentation/pages/reading_plans_screen.dart b/lib/features/home/presentation/pages/reading_plans_screen.dart index 9c25680..bda2906 100644 --- a/lib/features/home/presentation/pages/reading_plans_screen.dart +++ b/lib/features/home/presentation/pages/reading_plans_screen.dart @@ -62,13 +62,13 @@ class ReadingPlansScreen extends ConsumerWidget { } } -class _PlanListCard extends StatelessWidget { +class _PlanListCard extends ConsumerWidget { const _PlanListCard({required this.plan}); final ReadingPlan plan; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final s = L10n.of(context); final c = context.colors; final coverColor = testamentColor(plan.startBook); @@ -80,7 +80,7 @@ class _PlanListCard extends StatelessWidget { color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(18), - onTap: () {}, + onTap: () => openReadingPlan(context, ref, plan), child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( diff --git a/lib/features/home/presentation/widgets/reading_plans_section.dart b/lib/features/home/presentation/widgets/reading_plans_section.dart index deef078..7f8a6bf 100644 --- a/lib/features/home/presentation/widgets/reading_plans_section.dart +++ b/lib/features/home/presentation/widgets/reading_plans_section.dart @@ -197,13 +197,13 @@ class _AuthPrompt extends StatelessWidget { } /// Carousel card: book cover on top, plan name + progress below. -class _ReadingPlanCard extends StatelessWidget { +class _ReadingPlanCard extends ConsumerWidget { const _ReadingPlanCard({required this.plan}); final ReadingPlan plan; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final s = L10n.of(context); final c = context.colors; final coverColor = testamentColor(plan.startBook); @@ -211,7 +211,9 @@ class _ReadingPlanCard extends StatelessWidget { ? (plan.completedDays / plan.durationInDays).clamp(0.0, 1.0) : 0.0; - return SizedBox( + return GestureDetector( + onTap: () => openReadingPlan(context, ref, plan), + child: SizedBox( width: 110, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -251,6 +253,7 @@ class _ReadingPlanCard extends StatelessWidget { ), ], ), + ), ); } } diff --git a/lib/features/home/providers/reading_plan_providers.dart b/lib/features/home/providers/reading_plan_providers.dart index 2636a10..bcc4a1a 100644 --- a/lib/features/home/providers/reading_plan_providers.dart +++ b/lib/features/home/providers/reading_plan_providers.dart @@ -1,5 +1,10 @@ +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/auth/auth_state.dart'; +import '../../../core/storage/app_database_provider.dart'; +import '../../../features/books/data/models/book_index_entry.dart'; +import '../../../features/books/presentation/pages/reader_screen.dart'; +import '../../../core/services/repository_provider.dart'; import '../data/reading_plan.dart'; import '../data/reading_plan_repository.dart'; @@ -28,3 +33,62 @@ class ReadingPlansNotifier extends AsyncNotifier> { ref.invalidateSelf(); } } + +/// Resolves which day/chapter to open for [plan] and navigates to the reader. +/// Saves the position so subsequent taps resume from the same spot. +Future openReadingPlan( + BuildContext context, + WidgetRef ref, + ReadingPlan plan, +) async { + final db = ref.read(appDatabaseProvider); + final bibleRepo = BibleRepositoryProvider.of(context); + + // 1. Find the day to open: saved position → first incomplete → first day. + DailyReading? day; + final saved = await db.getPlanPosition(plan.id); + if (saved != null) { + final savedDay = saved['day_number'] as int; + day = plan.dailyReadings.cast().firstWhere( + (d) => d!.dayNumber == savedDay, + orElse: () => null, + ); + } + day ??= plan.dailyReadings.cast().firstWhere( + (d) => !d!.isCompleted, + orElse: () => null, + ); + day ??= plan.dailyReadings.isNotEmpty ? plan.dailyReadings.first : null; + + if (day == null || day.readings.isEmpty) return; + + // 2. Look up the BookIndexEntry from the first reading's book name. + final reading = day.readings.first; + final index = await bibleRepo.loadIndex(); + final bookKey = reading.book.toLowerCase(); + final BookIndexEntry? entry = index.cast().firstWhere( + (e) => e!.bookNameEn.toLowerCase().replaceAll(' ', '-') == bookKey, + orElse: () => null, + ); + if (entry == null || !context.mounted) return; + + // 3. Save position so next tap resumes here. + await db.savePlanPosition( + planId: plan.id, + dayNumber: day.dayNumber, + bookId: entry.bookNameEn, + chapter: reading.startChapter, + ); + + if (!context.mounted) return; + + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ReaderScreen( + entry: entry, + initialChapterNumber: reading.startChapter, + ), + ), + ); +} From 49d5c82b82513e406e3c132e22f97423c29dd92c Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 26 May 2026 16:37:13 +0300 Subject: [PATCH 32/33] feat: settings sync (#8) and streak sync (#9) after login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings sync: - Lift ValueNotifier out of Settings widget into a Riverpod settingsNotifierProvider so AuthNotifier can read/write it without BuildContext - Parse RemoteSettings (theme, fontSize) from UserProfile.fromJson - On login, apply remote values only for fields still at their default (local wins) - Start a ValueNotifier listener after login to push settings to PUT /auth/profile on every change; stop listener on logout/delete Streak sync: - Parse RemoteStreak (current, longest) from UserProfile.fromJson - After login, merge with local streak — keep whichever value is higher per field - Invalidate readingStreakStateProvider so the UI reflects the merged count immediately Co-Authored-By: Claude Sonnet 4.6 --- lib/core/auth/auth_repository.dart | 13 +++ lib/core/auth/auth_state.dart | 119 ++++++++++++++++++++++- lib/core/auth/user_profile.dart | 44 ++++++++- lib/core/settings/app_settings.dart | 7 +- lib/core/settings/settings_provider.dart | 9 ++ lib/main.dart | 8 +- 6 files changed, 190 insertions(+), 10 deletions(-) create mode 100644 lib/core/settings/settings_provider.dart diff --git a/lib/core/auth/auth_repository.dart b/lib/core/auth/auth_repository.dart index fd0d455..f271b43 100644 --- a/lib/core/auth/auth_repository.dart +++ b/lib/core/auth/auth_repository.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:google_sign_in/google_sign_in.dart'; import '../api/api_client.dart'; +import '../settings/app_settings.dart'; import 'user_profile.dart'; const _webClientId = @@ -66,6 +67,18 @@ class AuthRepository { await _api.delete('/auth/account', token: token); } + Future updateProfileSettings( + String token, { + required AppSettings settings, + }) => + _api.put('/auth/profile', body: { + 'settings': { + 'theme': settings.isDarkReader ? 'dark' : 'light', + 'fontSize': settings.fontSize.round(), + 'notificationsEnabled': true, + }, + }, token: token); + Future updateProfile( String token, { required String name, diff --git a/lib/core/auth/auth_state.dart b/lib/core/auth/auth_state.dart index ef9cd27..6d5a2cb 100644 --- a/lib/core/auth/auth_state.dart +++ b/lib/core/auth/auth_state.dart @@ -1,10 +1,15 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../api/api_client.dart'; +import '../settings/app_settings.dart'; +import '../settings/settings_provider.dart'; import '../storage/app_database_provider.dart'; import '../sync/sync_repository.dart'; import '../sync/sync_service.dart'; +import '../../features/books/data/reading_models.dart'; +import '../../features/books/providers/reading_progress_providers.dart'; import 'auth_repository.dart'; import 'auth_storage.dart'; import 'user_profile.dart'; @@ -61,6 +66,19 @@ class AuthNotifier extends StateNotifier { final AuthStorage _storage; final Ref _ref; + VoidCallback? _settingsListener; + AppSettings? _lastPushedSettings; + + // ── Lifecycle ──────────────────────────────────────────────────────────────── + + @override + void dispose() { + _stopSettingsPush(); + super.dispose(); + } + + // ── Auth flow ───────────────────────────────────────────────────────────────── + Future _init() async { final token = await _storage.readToken(); if (token == null) { @@ -70,7 +88,7 @@ class AuthNotifier extends StateNotifier { try { final profile = await _repo.fetchProfile(token); state = AuthState.authenticated(profile, token); - _syncAfterAuth(token); + _syncAfterAuth(token, profile); } on ApiException catch (e) { if (e.isUnauthorized) await _storage.clearToken(); state = const AuthState.unauthenticated(); @@ -85,7 +103,7 @@ class AuthNotifier extends StateNotifier { await _storage.saveToken(token); final profile = await _repo.fetchProfile(token); state = AuthState.authenticated(profile, token); - _syncAfterAuth(token); + _syncAfterAuth(token, profile); } Future register(String name, String email, String password) async { @@ -97,7 +115,7 @@ class AuthNotifier extends StateNotifier { await _storage.saveToken(token); final profile = await _repo.fetchProfile(token); state = AuthState.authenticated(profile, token); - _syncAfterAuth(token); + _syncAfterAuth(token, profile); } Future signInWithGoogle() async { @@ -108,10 +126,13 @@ class AuthNotifier extends StateNotifier { profile = profile.copyWith(avatar: photoUrl); } state = AuthState.authenticated(profile, token); - _syncAfterAuth(token); + _syncAfterAuth(token, profile); } - void _syncAfterAuth(String token) { + void _syncAfterAuth(String token, UserProfile profile) { + _applyRemoteSettings(profile); + _applyRemoteStreak(profile); + _startSettingsPush(token); final svc = SyncService( db: _ref.read(appDatabaseProvider), repo: SyncRepository(_ref.read(apiClientProvider), token), @@ -121,6 +142,7 @@ class AuthNotifier extends StateNotifier { Future logout() async { final token = state.token; + _stopSettingsPush(); if (token != null) { try { await _repo.logout(token); @@ -134,6 +156,7 @@ class AuthNotifier extends StateNotifier { Future deleteAccount() async { final token = state.token; + _stopSettingsPush(); if (token != null) await _repo.deleteAccount(token); await _storage.clearToken(); state = const AuthState.unauthenticated(); @@ -168,4 +191,90 @@ class AuthNotifier extends StateNotifier { : state.user!.copyWith(avatar: null); state = AuthState.authenticated(withAvatar, token); } + + // ── Settings sync (#8) ──────────────────────────────────────────────────────── + + /// On login, apply remote settings for any field still at its default value. + /// Local changes take precedence — we never overwrite a user's explicit choice. + void _applyRemoteSettings(UserProfile profile) { + final remote = profile.remoteSettings; + if (remote == null) return; + final notifier = _ref.read(settingsNotifierProvider); + final local = notifier.value; + const defaults = AppSettings(); + + final merged = local.copyWith( + isDarkReader: local.isDarkReader == defaults.isDarkReader + ? remote.isDark + : local.isDarkReader, + fontSize: local.fontSize == defaults.fontSize + ? remote.fontSize + : local.fontSize, + ); + + if (merged == local) return; + _lastPushedSettings = merged; // prevent immediate push-back + notifier.value = merged; + } + + /// Listen for settings changes and push them to the backend (fire-and-forget). + void _startSettingsPush(String token) { + _stopSettingsPush(); + final notifier = _ref.read(settingsNotifierProvider); + _settingsListener = () { + final current = notifier.value; + if (current == _lastPushedSettings) return; + _lastPushedSettings = current; + _repo + .updateProfileSettings(token, settings: current) + .catchError((_) {}); + }; + notifier.addListener(_settingsListener!); + } + + void _stopSettingsPush() { + if (_settingsListener == null) return; + try { + _ref.read(settingsNotifierProvider).removeListener(_settingsListener!); + } catch (_) {} + _settingsListener = null; + } + + // ── Streak sync (#9) ───────────────────────────────────────────────────────── + + /// After login, keep whichever streak value is higher — local or backend. + void _applyRemoteStreak(UserProfile profile) { + final remote = profile.remoteStreak; + if (remote == null || (remote.current == 0 && remote.longest == 0)) return; + _mergeStreak(remote); + } + + Future _mergeStreak(RemoteStreak remote) async { + try { + final db = _ref.read(appDatabaseProvider); + final row = await db.getReadingStreakRow(); + final local = ReadingStreakState.fromRow(row); + + final mergedCurrent = + remote.current > local.currentStreak ? remote.current : local.currentStreak; + final mergedLongest = + remote.longest > local.longestStreak ? remote.longest : local.longestStreak; + + if (mergedCurrent == local.currentStreak && + mergedLongest == local.longestStreak) { + return; + } + + await db.updateReadingStreakRow( + lastQualifiedDate: local.lastQualifiedDate, + currentStreak: mergedCurrent, + currentStreakStart: local.currentStreakStart, + longestStreak: mergedLongest, + longestStreakStart: local.longestStreakStart, + longestStreakEnd: local.longestStreakEnd, + ); + + _ref.invalidate(readingStreakStateProvider); + } catch (_) {} + } } diff --git a/lib/core/auth/user_profile.dart b/lib/core/auth/user_profile.dart index 81c30d0..d541221 100644 --- a/lib/core/auth/user_profile.dart +++ b/lib/core/auth/user_profile.dart @@ -1,3 +1,27 @@ +class RemoteSettings { + const RemoteSettings({this.isDark = false, this.fontSize = 17.0}); + + final bool isDark; + final double fontSize; + + factory RemoteSettings.fromJson(Map m) => RemoteSettings( + isDark: (m['theme'] as String?) == 'dark', + fontSize: (m['fontSize'] as num?)?.toDouble() ?? 17.0, + ); +} + +class RemoteStreak { + const RemoteStreak({this.current = 0, this.longest = 0}); + + final int current; + final int longest; + + factory RemoteStreak.fromJson(Map m) => RemoteStreak( + current: (m['current'] as num?)?.toInt() ?? 0, + longest: (m['longest'] as num?)?.toInt() ?? 0, + ); +} + class UserProfile { const UserProfile({ required this.id, @@ -5,6 +29,8 @@ class UserProfile { required this.email, this.avatar, this.provider = 'email', + this.remoteSettings, + this.remoteStreak, }); final String id; @@ -12,26 +38,42 @@ class UserProfile { final String email; final String? avatar; final String provider; + final RemoteSettings? remoteSettings; + final RemoteStreak? remoteStreak; bool get isGoogleUser => provider == 'google'; factory UserProfile.fromJson(Map json) { + final settingsMap = json['settings'] as Map?; + final streakMap = json['streak'] as Map?; return UserProfile( id: json['_id'] as String? ?? json['id'] as String, name: json['name'] as String, email: json['email'] as String, avatar: json['avatar'] as String?, provider: json['provider'] as String? ?? 'email', + remoteSettings: + settingsMap != null ? RemoteSettings.fromJson(settingsMap) : null, + remoteStreak: + streakMap != null ? RemoteStreak.fromJson(streakMap) : null, ); } - UserProfile copyWith({String? name, String? email, String? avatar}) { + UserProfile copyWith({ + String? name, + String? email, + String? avatar, + RemoteSettings? remoteSettings, + RemoteStreak? remoteStreak, + }) { return UserProfile( id: id, name: name ?? this.name, email: email ?? this.email, avatar: avatar ?? this.avatar, provider: provider, + remoteSettings: remoteSettings ?? this.remoteSettings, + remoteStreak: remoteStreak ?? this.remoteStreak, ); } } diff --git a/lib/core/settings/app_settings.dart b/lib/core/settings/app_settings.dart index 41611f5..49eb667 100644 --- a/lib/core/settings/app_settings.dart +++ b/lib/core/settings/app_settings.dart @@ -66,8 +66,11 @@ class AppSettings { } class Settings extends InheritedNotifier> { - Settings({super.key, AppSettings? initial, required super.child}) - : super(notifier: ValueNotifier(initial ?? const AppSettings())); + const Settings({ + super.key, + required ValueNotifier notifier, + required super.child, + }) : super(notifier: notifier); static AppSettings of(BuildContext context) { final s = context.dependOnInheritedWidgetOfExactType(); diff --git a/lib/core/settings/settings_provider.dart b/lib/core/settings/settings_provider.dart new file mode 100644 index 0000000..53fe586 --- /dev/null +++ b/lib/core/settings/settings_provider.dart @@ -0,0 +1,9 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'app_settings.dart'; + +/// Singleton ValueNotifier shared by [Settings] InheritedWidget and Riverpod. +/// Must be overridden in [ProviderScope] before the widget tree builds. +final settingsNotifierProvider = Provider>( + (ref) => throw StateError('settingsNotifierProvider not initialised'), +); diff --git a/lib/main.dart b/lib/main.dart index 49edf4f..4d4cac8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'core/l10n/l10n.dart'; import 'core/services/bible_repository_provider.dart'; import 'core/services/repository_provider.dart'; import 'core/settings/app_settings.dart'; +import 'core/settings/settings_provider.dart'; import 'core/theme/app_theme.dart'; import 'features/books/data/repositories/bible_repository.dart'; import 'features/books/presentation/pages/reader_screen.dart'; @@ -16,10 +17,12 @@ import 'features/home/presentation/pages/home_screen.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); final bibleRepository = BibleRepository(); + final settingsNotifier = ValueNotifier(const AppSettings()); runApp( ProviderScope( overrides: [ bibleRepositoryProvider.overrideWithValue(bibleRepository), + settingsNotifierProvider.overrideWithValue(settingsNotifier), ], child: BibleRepositoryProvider( repository: bibleRepository, @@ -29,14 +32,15 @@ void main() async { ); } -class BibleApp extends StatelessWidget { +class BibleApp extends ConsumerWidget { const BibleApp({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { // BibleRepositoryProvider is already provided by main() with the same // instance that's registered in ProviderScope — don't create a second one. return Settings( + notifier: ref.read(settingsNotifierProvider), child: L10n( initialLanguage: AppLanguage.amharic, child: const _BibleMaterialApp(), From 616e1ba78dcf431f0b895b88c5cacbd1d36c1ff6 Mon Sep 17 00:00:00 2001 From: MelakuDemeke Date: Tue, 26 May 2026 16:48:38 +0300 Subject: [PATCH 33/33] docs: add PR_CHANGES.md for issue #6 --- PR_CHANGES.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 PR_CHANGES.md diff --git a/PR_CHANGES.md b/PR_CHANGES.md new file mode 100644 index 0000000..e423ef5 --- /dev/null +++ b/PR_CHANGES.md @@ -0,0 +1,116 @@ +# PR #6 — User Authentication & Reading Plans + +## Overview + +Full authentication flow connected to the EOTCbibleBE backend, plus reading plans wired to real data with book-style UI, testament-based colours, and cross-device sync of settings and streaks. + +--- + +## What was built + +### 1. Auth foundation +- **`ApiClient`** (`lib/core/api/`) — HTTP wrapper with Bearer token support, `GET/POST/PUT/PATCH/DELETE`, file upload, and typed `ApiException` (including `isAccountLocked` for HTTP 423/429) +- **`AuthStorage`** (`lib/core/auth/`) — secure token persistence via `flutter_secure_storage` +- **`AuthRepository`** — all backend endpoints: register, verify-OTP, login, forgot/reset password, fetch/update profile, change password, upload avatar, logout, delete account, Google OAuth +- **`AuthState` / `AuthNotifier`** — Riverpod `StateNotifierProvider`; validates stored token on startup via `GET /profile`, clears on 401 + +### 2. Auth screens +| Screen | Notes | +|---|---| +| Login | Email/password, Google sign-in, forgot-password link, register link. Shows human-readable "Too many failed attempts" for locked accounts | +| Register | Name / email / password | +| OTP verification | 6-digit input + Resend button | +| Forgot password | Sends reset email, shows confirmation | +| Reset password | Token + new password | +| Profile | Avatar (upload/initials fallback), name/email edit, change password, logout, delete account | + +All screens match the Figma design with full AM/EN localisation. + +### 3. Me tab & home header +- Me tab shows login prompt when unauthenticated, switches to profile summary when logged in +- Home header avatar and greeting wired to `authStateProvider` + +### 4. Backend sync (bookmarks, highlights, notes, progress) +- `SyncRepository` + `SyncService` handle push/pull for all annotation types +- Sync runs automatically after every login (`pullAll` then `syncAll`) +- Annotation models carry `remoteId` and `syncStatus` for conflict-free upserts + +### 5. Reading plans +- **API**: `GET /reading-plans`, `PATCH /reading-plans/:id/days/:dayNumber/complete` +- **`ReadingPlanRepository`** + `readingPlansProvider` (auto-fetches when authenticated, returns `[]` otherwise) +- **Auth guard**: unauthenticated users see an inline "Log in to sync" prompt with dismiss option instead of the plan cards +- **Book-style cards**: each plan renders as an upright book cover (`BookCover` widget) with testament colour — burgundy (OT), navy (NT), olive (deuterocanonical) +- **View All screen** (`ReadingPlansScreen`): full scrollable list, same book cover + horizontal card layout matching Continue Reading +- **Tap to read**: tapping a card opens the reader at the first incomplete day; position is saved to a local `plan_position` SQLite table so subsequent taps resume from the same spot + +### 6. Shared `BookCover` widget +`lib/core/widgets/book_cover.dart` — parameterised cover colour, size, and testament colour helper (`testamentColor(bookName)`). Replaces the inline implementation previously duplicated in `continue_reading_section.dart`. + +### 7. Settings sync (stretch goal #8) +- `settingsNotifierProvider` (`lib/core/settings/settings_provider.dart`) — lifts `ValueNotifier` into Riverpod so `AuthNotifier` can read/write it without a `BuildContext` +- On login: remote `theme`/`fontSize` are applied only for fields still at their default value (local always wins over remote for fields the user has explicitly changed) +- On every settings change: `PUT /auth/profile` is fired (fire-and-forget) to keep the backend in sync +- Listener is removed on logout / account deletion + +### 8. Streak sync (stretch goal #9) +- `RemoteStreak` parsed from `GET /profile` response (`streak.current`, `streak.longest`) +- After login: each field is set to `max(local, remote)` — the streak never goes backwards +- `readingStreakStateProvider` is invalidated immediately so the home screen reflects the merged count + +--- + +## New dependencies +```yaml +flutter_secure_storage: ^9.0.0 +google_sign_in: ^6.0.0 +http: ^1.2.0 +sqflite_common_ffi: (already present — version bump) +``` + +--- + +## Database migrations +| Version | Change | +|---|---| +| v2 | Added `reading_position`, `chapter_read`, `reading_streak` tables | +| v3 | Migrated `reading_position` to per-book rows | +| v4 | Marked legacy kebab-case `book_id` annotations for re-sync | +| **v5** | Added `plan_position` table (`plan_id`, `day_number`, `book_id`, `chapter`) | + +--- + +## What is intentionally deferred +- **Facebook OAuth** — button shows "Coming soon" snackbar. Requires registering Android/iOS platforms in the Facebook Developer Console and adding key hashes. The backend endpoint (`POST /auth/social/facebook`) is already implemented; only the Flutter-side native integration is pending. + +--- + +## File map +``` +lib/ + core/ + api/api_client.dart — HTTP client + ApiException + auth/ + auth_repository.dart — all backend auth calls + auth_state.dart — AuthNotifier + settings/streak sync + auth_storage.dart — secure token storage + user_profile.dart — UserProfile + RemoteSettings + RemoteStreak + settings/ + app_settings.dart — Settings accepts external ValueNotifier + settings_provider.dart — settingsNotifierProvider (Riverpod) + storage/app_database.dart — v5 migration + plan_position methods + sync/ + sync_repository.dart — fetch/push bookmarks, highlights, notes + sync_service.dart — orchestrates pull + push + widgets/book_cover.dart — shared BookCover widget + features/ + auth/presentation/pages/ — login, register, otp, forgot, reset, profile + home/ + data/ + reading_plan.dart — ReadingPlan + DailyReading + DailyReadingItem + reading_plan_repository.dart — GET /reading-plans, PATCH day complete + presentation/ + pages/reading_plans_screen.dart + widgets/reading_plans_section.dart + providers/reading_plan_providers.dart + me/presentation/pages/me_screen.dart +```