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 +``` 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/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/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 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 @@ + + + + + + + + + + + + + + + diff --git a/lib/core/annotations/annotation_models.dart b/lib/core/annotations/annotation_models.dart index af9a232..5b19cd2 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 ────────────────────────────────────────────────────────────────── @@ -253,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/core/api/api_client.dart b/lib/core/api/api_client.dart new file mode 100644 index 0000000..bc45e1a --- /dev/null +++ b/lib/core/api/api_client.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; +import 'dart:io'; +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> 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> 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'), + 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_repository.dart b/lib/core/auth/auth_repository.dart new file mode 100644 index 0000000..f271b43 --- /dev/null +++ b/lib/core/auth/auth_repository.dart @@ -0,0 +1,129 @@ +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 = + '633243120991-2fj8v1g9ree4dkgklqe7613sdm6e6idc.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); + } + + 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, + 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 { + final googleSignIn = GoogleSignIn(serverClientId: _webClientId); + await googleSignIn.signOut(); + 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, account.photoUrl); + } +} diff --git a/lib/core/auth/auth_state.dart b/lib/core/auth/auth_state.dart new file mode 100644 index 0000000..6d5a2cb --- /dev/null +++ b/lib/core/auth/auth_state.dart @@ -0,0 +1,280 @@ +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'; + +// ── 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), + ref, + ), +); + +// ── 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, this._ref) + : super(const AuthState.unknown()) { + _init(); + } + + final AuthRepository _repo; + 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) { + state = const AuthState.unauthenticated(); + return; + } + try { + final profile = await _repo.fetchProfile(token); + state = AuthState.authenticated(profile, token); + _syncAfterAuth(token, profile); + } 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); + _syncAfterAuth(token, profile); + } + + 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); + _syncAfterAuth(token, profile); + } + + Future signInWithGoogle() async { + final (token, photoUrl) = await _repo.signInWithGoogle(); + await _storage.saveToken(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, profile); + } + + 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), + ); + svc.pullAll().then((_) => svc.syncAll()); + } + + Future logout() async { + final token = state.token; + _stopSettingsPush(); + 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; + _stopSettingsPush(); + if (token != null) await _repo.deleteAccount(token); + 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); + } + + // ── 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/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..d541221 --- /dev/null +++ b/lib/core/auth/user_profile.dart @@ -0,0 +1,79 @@ +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, + required this.name, + required this.email, + this.avatar, + this.provider = 'email', + this.remoteSettings, + this.remoteStreak, + }); + + final String id; + final String name; + 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, + 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/l10n/am_strings.dart b/lib/core/l10n/am_strings.dart index ebfd8a8..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 @@ -285,4 +290,123 @@ 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 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 => 'ኮድ መላክ አልተሳካም። ድጋሚ ይሞክሩ።'; + + // ── 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'; + + // ── 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 => 'ማዘምን አልተሳካም። እንደገና ይሞክሩ።'; + } diff --git a/lib/core/l10n/app_strings.dart b/lib/core/l10n/app_strings.dart index 0570901..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; @@ -165,4 +167,123 @@ abstract class AppStrings { 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; + + // ── 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; + + // ── 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; + } diff --git a/lib/core/l10n/en_strings.dart b/lib/core/l10n/en_strings.dart index 742038b..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 @@ -285,4 +290,122 @@ class EnStrings extends AppStrings { 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 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.'; + + // ── 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'; + + // ── 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.'; } 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/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 41c66d5..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 = 3; + static const _version = 5; Database? _db; @@ -36,6 +36,27 @@ class AppDatabase { if (oldVersion < 3) { await _migrateReadingPositionToMultiBook(db); } + if (oldVersion < 4) { + await _markLegacyBookIdsPendingUpdate(db); + } + if (oldVersion < 5) { + await _createPlanPositionTable(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. @@ -167,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 { @@ -395,6 +427,285 @@ 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(); + } + + // ── Server pull upserts ──────────────────────────────────────────────────── + + Future upsertServerBookmarks(List items) async { + if (items.isEmpty) return; + final db = await database; + for (final b in items) { + 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; + for (final h in items) { + 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; + for (final n in items) { + 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); + } + } + } + + // ── 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/core/sync/sync_repository.dart b/lib/core/sync/sync_repository.dart new file mode 100644 index 0000000..22f3e3e --- /dev/null +++ b/lib/core/sync/sync_repository.dart @@ -0,0 +1,304 @@ +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 apiId = _toApiBookId(b.bookId); + final res = await _client.post( + '/bookmarks', + body: { + 'book': apiId, + 'bookId': apiId, + 'chapter': b.chapter, + 'verseStart': b.verseStart, + 'verseCount': b.verseCount, + }, + token: _token, + ); + return _remoteId(res, 'bookmark'); + } + + 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); + + // ── Notes ────────────────────────────────────────────────────────────────── + + Future createNote(Note n) async { + final res = await _client.post( + '/notes', + body: { + 'bookId': _toApiBookId(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: { + 'bookId': _toApiBookId(n.bookId), + 'chapter': n.chapter, + 'verseStart': n.verseStart, + 'verseCount': n.verseCount, + '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 apiId = _toApiBookId(h.bookId); + final res = await _client.post( + '/highlights', + body: { + 'book': apiId, + 'bookId': apiId, + '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': _toApiBookId(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; + // 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: ${res['data']}'); + } + + // Maps local ARGB palette to the backend's accepted color names. + static const _colorNames = { + 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); + + // 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) : data) as List? ?? []; + final now = DateTime.now(); + return list.whereType>().map((m) { + final raw = (m['book'] ?? m['bookId'] ?? '') as String; + final bookId = _normalizeBookId(raw); + 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?, + ); + }).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) : data) as List? ?? []; + final now = DateTime.now(); + return list.whereType>().map((m) { + 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: chapter, + verseStart: verseStart, + verseCount: verseCount, + 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?, + ); + }).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['data'] ?? data) + : data) as List? ?? []; + final now = DateTime.now(); + return list.whereType>().map((m) { + // 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: chapter, + verseStart: verseStart, + verseCount: verseCount, + 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 && n.bookId.isNotEmpty && n.chapter > 0).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 new file mode 100644 index 0000000..d31ec1e --- /dev/null +++ b/lib/core/sync/sync_service.dart @@ -0,0 +1,211 @@ +import 'package:flutter/foundation.dart'; +import '../annotations/annotation_models.dart'; +import '../api/api_client.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(), + ]); + + 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 { + 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) { + 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) { + try { await _repo.deleteBookmark(b.remoteId!); } on ApiException catch (_) {} + } + await _db.deleteBookmark(b.id!); + case SyncStatus.synced: + break; + } + } catch (e) { + final code = e is ApiException ? ' (HTTP ${e.statusCode})' : ''; + debugPrint('[Sync] bookmark error$code (${e.runtimeType}): $e'); + } + } + } + + // ── Notes ────────────────────────────────────────────────────────────────── + + 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) { + 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) { + try { await _repo.deleteNote(n.remoteId!); } on ApiException catch (_) {} + } + await _db.deleteNote(n.id!); + case SyncStatus.synced: + break; + } + } catch (e) { + final code = e is ApiException ? ' (HTTP ${e.statusCode})' : ''; + debugPrint('[Sync] note error$code (${e.runtimeType}): $e'); + } + } + } + + // ── Highlights ───────────────────────────────────────────────────────────── + + 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) { + 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) { + try { await _repo.deleteHighlight(h.remoteId!); } on ApiException catch (_) {} + } + await _db.deleteHighlight(h.id!); + case SyncStatus.synced: + break; + } + } catch (e) { + final code = e is ApiException ? ' (HTTP ${e.statusCode})' : ''; + debugPrint('[Sync] highlight error$code (${e.runtimeType}): $e'); + } + } + } + + // ── Progress ─────────────────────────────────────────────────────────────── + + Future logReadingProgress({ + required String bookId, + required int chapter, + }) async { + try { + await _repo.logReadingProgress(bookId: bookId, chapter: chapter); + } catch (_) {} + } +} 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/annotations/providers/annotation_providers.dart b/lib/features/annotations/providers/annotation_providers.dart index 040035d..9ddb222 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 { @@ -45,13 +49,15 @@ class ChapterAnnotationsNotifier Future toggleBookmark({ required int verseStart, required int bookNumber, + int verseCount = 1, }) async { final annotations = state.value; if (annotations == null) return; 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( @@ -59,17 +65,20 @@ class ChapterAnnotationsNotifier bookNumber: bookNumber, chapter: _key.chapter, verseStart: verseStart, + verseCount: verseCount, createdAt: now, updatedAt: now, )); } await _load(); + _triggerSync(); } Future setHighlight({ required int verseStart, required int bookNumber, required Color color, + int verseCount = 1, }) async { final annotations = state.value; if (annotations == null) return; @@ -90,12 +99,14 @@ class ChapterAnnotationsNotifier bookNumber: bookNumber, chapter: _key.chapter, verseStart: verseStart, + verseCount: verseCount, color: color, createdAt: now, updatedAt: now, )); } await _load(); + _triggerSync(); } Future removeHighlight(int verseStart) async { @@ -103,8 +114,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(); } } @@ -112,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; @@ -120,7 +134,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(), @@ -136,12 +151,23 @@ class ChapterAnnotationsNotifier bookNumber: bookNumber, chapter: _key.chapter, verseStart: verseStart, + verseCount: verseCount, content: content.trim(), createdAt: now, updatedAt: now, )); } 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 +177,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/auth/presentation/pages/forgot_password_screen.dart b/lib/features/auth/presentation/pages/forgot_password_screen.dart new file mode 100644 index 0000000..66c106c --- /dev/null +++ b/lib/features/auth/presentation/pages/forgot_password_screen.dart @@ -0,0 +1,526 @@ +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'; +import 'reset_password_screen.dart'; + +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 { + 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, + behavior: SnackBarBehavior.floating, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + return; + } + + final email = _emailCtrl.text.trim(); + if (email.isEmpty || !email.contains('@')) { + setState(() => _errorMessage = s.authEmailInvalid); + 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 = 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 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( + s.forgotTitle, + style: AppTypography.amharicDisplay.copyWith( + color: c.textOnParchment, + fontSize: 30, + ), + ), + const SizedBox(height: 6), + Text( + s.forgotSubtitle, + 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: s.forgotEmailLabel, + 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( + s.forgotEmailHelper, + style: AppTypography.amharicCaption.copyWith( + color: c.textCaption, + fontSize: 11, + ), + ), + ] else ...[ + _FieldLabel( + label: s.forgotPhoneLabel, + 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( + s.forgotPhoneHelper, + 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: s.forgotSendButton, + isLoading: _isLoading, + onTap: _isLoading ? null : _submit, + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + s.forgotRememberPassword, + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, fontSize: 12), + ), + GestureDetector( + onTap: _isLoading + ? null + : () => Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const LoginScreen()), + ), + child: Text( + s.loginButton, + 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: L10n.of(context).authEmail, + active: useEmail, + onTap: () => onChanged(true), + colors: c, + ), + _Tab( + icon: Icons.phone_outlined, + label: L10n.of(context).forgotTabPhone, + 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, + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── 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/login_screen.dart b/lib/features/auth/presentation/pages/login_screen.dart new file mode 100644 index 0000000..46603c4 --- /dev/null +++ b/lib/features/auth/presentation/pages/login_screen.dart @@ -0,0 +1,770 @@ +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'; +import '../../../../core/l10n/l10n.dart'; +import '../../../../core/theme/app_color_scheme.dart'; +import '../../../../core/theme/app_typography.dart'; +import 'forgot_password_screen.dart'; +import 'register_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 _rememberMe = 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 + ? L10n.of(context).loginAccountLocked + : e.message; + }); + } catch (_) { + setState(() => _errorMessage = L10n.of(context).authConnectionError); + } 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 = L10n.of(context).loginGoogleFailed); + } finally { + if (mounted) setState(() => _isGoogleLoading = false); + } + } + + void _facebookSignIn() { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + L10n.of(context).loginFacebookComingSoon, + 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( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _AppHeader(s: s), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 28), + _TitleSection(colors: c), + const SizedBox(height: 28), + Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _InputField( + controller: _emailCtrl, + 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 s.authEmailRequired; + if (!v.contains('@')) return s.authEmailInvalid; + return null; + }, + ), + const SizedBox(height: 16), + _InputField( + controller: _passwordCtrl, + label: s.authPassword, + 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 s.authPasswordRequired; + 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: s.loginButton, + isLoading: _isLoading, + onTap: isLoading ? null : _login, + ), + const SizedBox(height: 24), + _OrDivider(colors: c), + const SizedBox(height: 20), + _SocialRow( + isGoogleLoading: _isGoogleLoading, + allDisabled: isLoading, + onGoogle: _googleSignIn, + onFacebook: _facebookSignIn, + ), + const SizedBox(height: 28), + _RegisterPrompt(enabled: !isLoading), + const SizedBox(height: 24), + _VerseQuote(colors: c), + const SizedBox(height: 24), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── 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; + + 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 ? s.langAmharic : s.langEnglish, + 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 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, + ), + ), + const SizedBox(height: 6), + Text( + s.loginSubtitle, + style: AppTypography.amharicBody.copyWith( + color: c.textMuted, + fontSize: 14, + height: 1.6, + ), + ), + ], + ); + } +} + +// ── 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.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 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, + 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), + ), + ), + ), + ], + ); + } +} + +// ── 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( + L10n.of(context).loginRememberMe, + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, + fontSize: 12, + ), + ), + ], + ), + ), + const Spacer(), + GestureDetector( + onTap: enabled ? onForgotTap : null, + child: Text( + L10n.of(context).loginForgotPassword, + style: AppTypography.amharicCaption.copyWith( + color: c.primary, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ), + ], + ); + } +} + +// ── 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), + ], + ), + ), + ); + } +} + +// ── Or divider ──────────────────────────────────────────────────────────────── + +class _OrDivider extends StatelessWidget { + const _OrDivider({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: 14), + child: Text( + L10n.of(context).loginOrDivider, + style: AppTypography.amharicCaption.copyWith( + color: c.textCaption, + fontSize: 11, + ), + ), + ), + Expanded(child: Divider(color: c.borderSubtle, thickness: 1)), + ], + ); + } +} + +// ── Social buttons row ──────────────────────────────────────────────────────── + +class _SocialRow extends StatelessWidget { + const _SocialRow({ + required this.isGoogleLoading, + required this.allDisabled, + required this.onGoogle, + required this.onFacebook, + }); + + final bool isGoogleLoading; + final bool allDisabled; + final VoidCallback onGoogle; + final VoidCallback onFacebook; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Row( + children: [ + Expanded( + child: _SocialButton( + onTap: allDisabled ? null : onGoogle, + isLoading: isGoogleLoading, + label: 'Google', + dark: false, + 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), + Expanded( + child: _SocialButton( + onTap: allDisabled ? null : onFacebook, + isLoading: false, + label: 'Facebook', + dark: true, + backgroundColor: const Color(0xFF1877F2), + icon: const Icon(Icons.facebook_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, + this.backgroundColor, + }); + + final VoidCallback? onTap; + final bool isLoading; + 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: bgColor, + borderRadius: BorderRadius.circular(12), + border: dark ? null : Border.all(color: c.borderSubtle, width: 1.2), + ), + alignment: Alignment.center, + child: isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: dark ? Colors.white : c.primary, + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + icon, + const SizedBox(width: 8), + Text( + label, + style: AppTypography.englishLabel.copyWith( + color: dark ? Colors.white : c.textBody, + fontSize: 13, + ), + ), + ], + ), + ), + ); + } +} + +// ── 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( + L10n.of(context).loginNoAccount, + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, fontSize: 12), + ), + GestureDetector( + onTap: enabled + ? () => Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const RegisterScreen()), + ) + : null, + child: Text( + L10n.of(context).loginRegisterLink, + style: AppTypography.amharicCaption.copyWith( + color: c.primary, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ), + ], + ); + } +} + +// ── 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( + L10n.of(context).loginVerseQuote, + 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)), + ], + ); + } +} 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..38c7af2 --- /dev/null +++ b/lib/features/auth/presentation/pages/otp_screen.dart @@ -0,0 +1,529 @@ +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/l10n/l10n.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 = L10n.of(context).otpDigitsRequired(_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 = L10n.of(context).authConnectionError); + } 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 = L10n.of(context).otpResendFailed); + } 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 s = L10n.of(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( + s.otpTitle, + 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: [ + TextSpan(text: s.otpSentPrefix), + TextSpan( + text: _maskedContact, + style: TextStyle( + color: c.textOnParchment, + fontWeight: FontWeight.w700), + ), + TextSpan(text: s.otpSentSuffix), + ], + ), + ), + 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: s.otpVerifyButton, + 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 + ? s.otpChangePhone + : s.otpChangeEmail, + 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( + L10n.of(context).otpNotReceived, + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, fontSize: 12), + ), + if (countdown > 0) + Text( + L10n.of(context).otpResendIn(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( + L10n.of(context).otpResend, + 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/profile_screen.dart b/lib/features/auth/presentation/pages/profile_screen.dart new file mode 100644 index 0000000..11dd68e --- /dev/null +++ b/lib/features/auth/presentation/pages/profile_screen.dart @@ -0,0 +1,1504 @@ +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'; +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'; +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 ConsumerStatefulWidget { + const ProfileScreen({super.key}); + + @override + 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 s = L10n.of(context); + final user = ref.watch(authStateProvider).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: _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: 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)), + ], + ), + ), + ); + } +} + +// ── 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( + L10n.of(context).profileTitle, + 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: (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.delete_outline_rounded, + label: L10n.of(context).profileDeleteAccount, + color: c.primary, + onTap: () { + Navigator.pop(ctx); + _confirmDelete(context, ref, c); + }, + ), + ], + ), + ), + ), + ); + } + + void _confirmDelete( + BuildContext context, WidgetRef ref, AppColorScheme c) { + showDialog( + context: context, + builder: (dlgCtx) => AlertDialog( + backgroundColor: c.surface, + 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(context).profileDeleteCancel, + style: AppTypography.amharicLabel + .copyWith(color: c.textMuted)), + ), + TextButton( + onPressed: () async { + Navigator.pop(dlgCtx); + await ref.read(authStateProvider.notifier).deleteAccount(); + if (context.mounted) Navigator.of(context).pop(); + }, + child: Text(L10n.of(context).profileDeleteConfirm, + 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)), + ], + ), + ), + ); + } +} + +// ── Editable avatar section ───────────────────────────────────────────────── + +class _EditableAvatarSection extends StatelessWidget { + const _EditableAvatarSection({ + required this.user, + required this.colors, + required this.isUploading, + required this.onTap, + }); + + final UserProfile user; + final AppColorScheme colors; + final bool isUploading; + final VoidCallback? onTap; + + 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; + 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: [ + 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, + ), + ), + ), + ], + SizedBox(height: onTap != null ? 8.0 : 14.0), + 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( + L10n.of(context).profileMemberBadge, + 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: L10n.of(context).profileStatStreak, + colors: c), + _StatDivider(colors: c), + _StatCell( + value: fmt(bookmarks), + label: L10n.of(context).profileStatBookmarks, + colors: c), + _StatDivider(colors: c), + _StatCell( + value: '${fmt(overallPct)}%', + label: L10n.of(context).profileStatPlan, + 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) => + 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.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), + ), + ), + ), + ], + ), + ); + } +} + +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) { + 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), + ], + ), + ), + ); + } +} + +// ── Preferences rows ─────────────────────────────────────────────────────── + +class _LanguageRow extends StatelessWidget { + const _LanguageRow({required this.s}); + final AppStrings s; + + @override + Widget build(BuildContext context) { + final c = context.colors; + final isAmharic = s is AmStrings; + return Padding( + 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: 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)), + ], + ), + ), + 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), + ), + ), + ), + 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, + ), + ], + ), + ); + } +} + +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 { + 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 s = L10n.of(context); + final achievements = [ + _Achievement( + icon: AppIcons.ethiopianCross, + title: s.achievementFirstDayTitle, + subtitle: s.achievementFirstDaySub, + unlocked: hasReadAnything || streak > 0, + ), + _Achievement( + icon: '🔗', + title: s.achievement7DayTitle, + subtitle: s.achievement7DaySub, + unlocked: streak >= 7 || longest >= 7, + ), + _Achievement( + icon: '✦', + title: s.achievementPsalmTitle, + subtitle: s.achievementPsalmSub, + unlocked: snapshotsAsync.value + ?.any((snap) => snap.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(s.profileAchievements, + 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/auth/presentation/pages/register_screen.dart b/lib/features/auth/presentation/pages/register_screen.dart new file mode 100644 index 0000000..ee0853f --- /dev/null +++ b/lib/features/auth/presentation/pages/register_screen.dart @@ -0,0 +1,617 @@ +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'; +import 'otp_screen.dart'; + +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 _passwordCtrl = TextEditingController(); + + bool _obscurePassword = true; + bool _acceptedTerms = false; + bool _isLoading = false; + String? _errorMessage; + int _passwordStrength = 0; + + @override + void dispose() { + _nameCtrl.dispose(); + _emailCtrl.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, AppStrings strings) { + switch (s) { + case 1: return strings.passwordWeak; + case 2: return strings.passwordFair; + case 3: return strings.passwordGood; + case 4: return strings.passwordStrong; + 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 = L10n.of(context).registerAcceptTerms); + 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()), + ), + ); + } + } on ApiException catch (e) { + setState(() => _errorMessage = e.message); + } catch (_) { + setState(() => _errorMessage = L10n.of(context).authConnectionError); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final s = L10n.of(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( + s.registerTitle, + style: AppTypography.amharicDisplay.copyWith( + color: c.textOnParchment, + fontSize: 30, + ), + ), + const SizedBox(height: 6), + Text( + s.registerSubtitle, + 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: s.registerFullName, + hint: 'ነህምያ ተስፋዬ', + prefixIcon: Icons.person_outline_rounded, + enabled: !_isLoading, + validator: (v) { + 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: s.authEmail, + hint: 'nehmia@bible.app', + prefixIcon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + enabled: !_isLoading, + validator: (v) { + 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: s.authPassword, + 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 s.authPasswordRequired; + if (v.length < 8) return s.registerPasswordTooShort; + return null; + }, + ), + if (_passwordStrength > 0) ...[ + const SizedBox(height: 8), + _PasswordStrengthBar( + strength: _passwordStrength, + label: _strengthLabel(_passwordStrength, s), + 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: s.registerButton, + isLoading: _isLoading, + onTap: _isLoading ? null : _register, + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + s.registerHaveAccount, + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, fontSize: 12), + ), + GestureDetector( + onTap: _isLoading + ? null + : () => Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const LoginScreen()), + ), + child: Text( + s.registerLoginLink, + 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), + ), + ); + } +} + +// ── 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( + L10n.of(context).registerTermsText, + 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), + ], + ), + ), + ); + } +} 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..223aacf --- /dev/null +++ b/lib/features/auth/presentation/pages/reset_password_screen.dart @@ -0,0 +1,528 @@ +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'; + +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( + L10n.of(context).resetSuccessMessage, + 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 = 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 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( + s.resetTitle, + style: AppTypography.amharicDisplay.copyWith( + color: c.textOnParchment, + fontSize: 30, + ), + ), + const SizedBox(height: 6), + Text( + s.resetSubtitle, + 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: s.resetTokenLabel, 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: s.resetNewPasswordLabel, 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: s.resetConfirmLabel, 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: s.resetSaveButton, + 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( + L10n.of(context).resetRequirementsTitle, + style: AppTypography.amharicCaption.copyWith( + color: c.textMuted, + fontWeight: FontWeight.w700, + fontSize: 11, + ), + ), + const SizedBox(height: 10), + _Requirement(met: hasLength, label: L10n.of(context).resetReqLength, colors: c), + const SizedBox(height: 6), + _Requirement(met: hasUpper, label: L10n.of(context).resetReqUpper, colors: c), + const SizedBox(height: 6), + _Requirement(met: hasNumber, label: L10n.of(context).resetReqNumber, colors: c), + const SizedBox(height: 6), + _Requirement(met: hasSpecial, label: L10n.of(context).resetReqSpecial, 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), + ], + ), + ), + ); + } +} diff --git a/lib/features/books/presentation/pages/reader_screen.dart b/lib/features/books/presentation/pages/reader_screen.dart index 83fa7bd..fbfa3e4 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'; @@ -61,6 +64,7 @@ class _ReaderScreenState extends ConsumerState int _currentChapter = 0; String? _selectedKey; + String? _selectionEndKey; final GlobalKey _spotlightKey = GlobalKey(); Timer? _dwellTimer; @@ -175,7 +179,7 @@ class _ReaderScreenState extends ConsumerState } on Object catch (_) { return; } - final verse = _selectedVerseNum; + final verse = _selectionVerseStart; unawaited( container .read(readingProgressRepositoryProvider) @@ -210,6 +214,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); }, @@ -292,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; @@ -310,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; @@ -324,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; } @@ -370,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; @@ -395,6 +467,7 @@ class _ReaderScreenState extends ConsumerState verseStart: verseNum, bookNumber: widget.entry.bookNumber, color: color, + verseCount: count, ); if (mounted) _deselect(); }, @@ -418,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; @@ -445,6 +519,7 @@ class _ReaderScreenState extends ConsumerState verseStart: verseNum, bookNumber: widget.entry.bookNumber, content: content, + verseCount: count, ); if (mounted) _deselect(); }, @@ -512,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 @@ -559,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 @@ -721,6 +799,8 @@ class _ReaderScreenState extends ConsumerState verseStart: verseNum, bookNumber: widget.entry.bookNumber, + verseCount: + _selectionVerseCount, ); _deselect(); }, diff --git a/lib/features/home/data/reading_plan.dart b/lib/features/home/data/reading_plan.dart new file mode 100644 index 0000000..c03dee7 --- /dev/null +++ b/lib/features/home/data/reading_plan.dart @@ -0,0 +1,71 @@ +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(), + ); +} + +class ReadingPlan { + const ReadingPlan({ + required this.id, + required this.name, + required this.durationInDays, + required this.dailyReadings, + this.startBook = '', + this.status, + }); + + final String id; + final String name; + final int durationInDays; + final List dailyReadings; + final String startBook; + 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(), + startBook: (m['startBook'] as String?) ?? '', + 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/pages/reading_plans_screen.dart b/lib/features/home/presentation/pages/reading_plans_screen.dart new file mode 100644 index 0000000..bda2906 --- /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 ConsumerWidget { + const _PlanListCard({required this.plan}); + + final ReadingPlan plan; + + @override + Widget build(BuildContext context, WidgetRef ref) { + 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: () => openReadingPlan(context, ref, plan), + 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/home_header.dart b/lib/features/home/presentation/widgets/home_header.dart index 16b527b..31dfa00 100644 --- a/lib/features/home/presentation/widgets/home_header.dart +++ b/lib/features/home/presentation/widgets/home_header.dart @@ -1,17 +1,27 @@ 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'; +import '../../../auth/presentation/pages/login_screen.dart'; +import '../../../auth/presentation/pages/profile_screen.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 +40,7 @@ class HomeHeader extends StatelessWidget { ), const SizedBox(height: 6), Text( - s.welcomeGreeting, + greeting, style: AppTypography.amharicHeading.copyWith( color: c.textOnParchment, ), @@ -41,20 +51,102 @@ class HomeHeader extends StatelessWidget { ), ), const SizedBox(width: 12), - const _Avatar(letter: 'ን'), + if (user == null) + _SignInButton(colors: c) + else + GestureDetector( + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ProfileScreen()), + ), + child: _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 _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.letter}); + const _Avatar({required this.user, required this.colors}); + + final UserProfile user; + final AppColorScheme colors; + + @override + Widget build(BuildContext context) { + final c = colors; + + 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, + ), + ), + ), + ); + } + + return _InitialsCircle(letter: _initial(user.name), 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, diff --git a/lib/features/home/presentation/widgets/reading_plans_section.dart b/lib/features/home/presentation/widgets/reading_plans_section.dart index bf5c94b..7f8a6bf 100644 --- a/lib/features/home/presentation/widgets/reading_plans_section.dart +++ b/lib/features/home/presentation/widgets/reading_plans_section.dart @@ -1,42 +1,33 @@ import 'package:flutter/material.dart'; -import '../../../../core/constants/app_icons.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/auth/auth_state.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'; +import '../pages/reading_plans_screen.dart'; -class ReadingPlansSection extends StatelessWidget { +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: [ @@ -54,7 +45,11 @@ class ReadingPlansSection extends StatelessWidget { ), const Spacer(), GestureDetector( - onTap: () {}, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const ReadingPlansScreen()), + ), child: Text( s.viewAll, style: AppTypography.amharicCaption.copyWith( @@ -67,111 +62,198 @@ class ReadingPlansSection extends StatelessWidget { ), ), const SizedBox(height: 12), - SizedBox( - height: 148, + if (!isAuthenticated && !_dismissed) + _AuthPrompt( + onLogin: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const LoginScreen()), + ), + onDismiss: () => setState(() => _dismissed = true), + ) + else + _PlansList(), + ], + ); + } +} + +class _PlansList extends ConsumerWidget { + const _PlansList(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = context.colors; + final plansAsync = ref.watch(readingPlansProvider); + + return plansAsync.when( + loading: () => SizedBox( + height: 176, + child: Center( + child: CircularProgressIndicator(color: c.primary, strokeWidth: 2), + ), + ), + error: (e, _) => const SizedBox(height: 176), + data: (plans) { + if (plans.isEmpty) return const SizedBox(height: 176); + return SizedBox( + height: 176, 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]), + padding: const EdgeInsets.only(right: 14), + child: _ReadingPlanCard(plan: plans[i]), ), ), - ), - ], + ); + }, ); } } -class _PlanData { - final String titleAm; - final String titleEn; - final int totalDays; - final int completedDays; - final Color color; - - const _PlanData({ - required this.titleAm, - required this.titleEn, - required this.totalDays, - required this.completedDays, - required this.color, - }); -} +class _AuthPrompt extends StatelessWidget { + const _AuthPrompt({required this.onLogin, required this.onDismiss}); -class _ReadingPlanCard extends StatelessWidget { - const _ReadingPlanCard({required this.plan}); - final _PlanData plan; + final VoidCallback onLogin; + final VoidCallback onDismiss; @override Widget build(BuildContext context) { final s = L10n.of(context); - final isAmharic = s is AmStrings; - final progress = plan.completedDays / plan.totalDays; + final c = context.colors; return Container( - width: 158, - padding: const EdgeInsets.all(14), + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( - color: plan.color, - borderRadius: BorderRadius.circular(18), - boxShadow: [ - BoxShadow( - color: plan.color.withValues(alpha: 0.3), - blurRadius: 10, - offset: const Offset(0, 4), + 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, + ), + ), + ), + ], + ), + ], + ), ), ], ), + ); + } +} + +/// Carousel card: book cover on top, plan name + progress below. +class _ReadingPlanCard extends ConsumerWidget { + const _ReadingPlanCard({required this.plan}); + + final ReadingPlan plan; + + @override + Widget build(BuildContext context, WidgetRef ref) { + 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 GestureDetector( + onTap: () => openReadingPlan(context, ref, plan), + child: 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, + BookCover(coverColor: coverColor, width: 66, height: 98), + const SizedBox(height: 6), + SizedBox( + height: 32, child: Text( - AppIcons.ethiopianCross, - style: TextStyle(color: context.colors.accent, fontSize: 16), - ), - ), - const Spacer(), - Text( - isAmharic ? plan.titleAm : plan.titleEn, - style: AppTypography.amharicLabel.copyWith( - color: Colors.white, - fontSize: 13, - height: 1.3, + 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( - s.daysCount(plan.totalDays), - style: AppTypography.amharicCaption.copyWith( - color: Colors.white.withValues(alpha: 0.7), - fontSize: 11, + s.daysCount(plan.durationInDays), + 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, + value: pct, minHeight: 4, - backgroundColor: Colors.white.withValues(alpha: 0.25), - valueColor: AlwaysStoppedAnimation(context.colors.accent), + backgroundColor: c.parchmentDark, + valueColor: AlwaysStoppedAnimation(coverColor), ), ), ], ), + ), ); } } 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..bcc4a1a --- /dev/null +++ b/lib/features/home/providers/reading_plan_providers.dart @@ -0,0 +1,94 @@ +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'; + +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(); + } +} + +/// 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, + ), + ), + ); +} diff --git a/lib/features/me/presentation/pages/me_screen.dart b/lib/features/me/presentation/pages/me_screen.dart index da819ca..a0e3ffd 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 ────────────────────────────────────────────────────── @@ -112,6 +120,8 @@ class _MeScreenState extends State { ], ), ), + const SliverToBoxAdapter(child: SizedBox(height: 16)), + SliverToBoxAdapter(child: _LogoutSection(s: s)), const SliverToBoxAdapter(child: SizedBox(height: 40)), ], ), @@ -154,18 +164,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,34 +197,34 @@ class _ProfileCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - // Avatar - Container( - width: 52, - height: 52, - decoration: BoxDecoration( - color: c.accent, - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Text( - 'ን', - 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), - // 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 +234,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 +242,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 +268,120 @@ 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; + 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, + ), + ], + ), + ), ), ); } @@ -575,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/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 4f595fe..2cb6199 100644 --- a/lib/features/saved/presentation/pages/saved_screen.dart +++ b/lib/features/saved/presentation/pages/saved_screen.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/l10n/l10n.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'; @@ -51,6 +54,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(), @@ -100,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(() { @@ -115,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, ); @@ -133,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, ); }) @@ -150,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, ); 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(), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 3792af4..ea3bde6 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,18 @@ #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); 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..1e154fe 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # 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 00aa41f..5eaef88 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,11 +6,17 @@ import FlutterMacOS import Foundation import app_links +import file_selector_macos +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")) + 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")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) } diff --git a/pubspec.lock b/pubspec.lock index ad494d2..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: @@ -174,6 +214,62 @@ 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_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 @@ -200,6 +296,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 +361,7 @@ packages: source: hosted version: "1.0.3" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -232,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: @@ -360,6 +568,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: @@ -408,6 +624,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: @@ -637,6 +861,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: @@ -677,6 +925,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 eb392a5..ce0dabd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,11 @@ 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 + flutter_svg: ^2.0.0 + image_picker: ^1.1.2 dev_dependencies: flutter_test: @@ -67,6 +72,7 @@ flutter: assets: - assets/bibledata/ + - assets/signin/ fonts: - family: Shiromeda diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index c71aa3c..053aac8 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,12 +7,18 @@ #include "generated_plugin_registrant.h" #include +#include +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + 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..43a8099 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,8 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + file_selector_windows + flutter_secure_storage_windows share_plus url_launcher_windows )