Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
9fc0a94
fix: update namespace and applicationId to match new package structur…
MelakuDemeke May 19, 2026
548d263
feat: add auth foundation — ApiClient, AuthStorage, UserProfile, and …
MelakuDemeke May 19, 2026
f65ce58
feat: add AuthRepository, AuthState, and Riverpod auth providers
MelakuDemeke May 19, 2026
ebd31a0
feat: add login screen with email/password and Google sign-in
MelakuDemeke May 19, 2026
2094788
redesign: login screen matches Figma — app header, social buttons, ve…
MelakuDemeke May 19, 2026
26ea139
feat: add register and OTP screens matching Figma design
MelakuDemeke May 19, 2026
1c9c7e6
feat: add forgot password and reset password screens matching Figma
MelakuDemeke May 19, 2026
4f5cb36
feat: add profile screen and wire Me tab to auth state
MelakuDemeke May 19, 2026
ab1554e
feat: add backend sync for bookmarks, notes, highlights, and progress
MelakuDemeke May 19, 2026
bf9c84f
feat: wire home header avatar and greeting to auth state
MelakuDemeke May 19, 2026
2bc3ab2
feat: replace Apple sign-in with Facebook (coming soon)
MelakuDemeke May 19, 2026
b75fa04
fix: correct highlight colors and add book field to bookmark/highligh…
MelakuDemeke May 19, 2026
c7365b6
feat: implement user authentication strings and update login/register…
MelakuDemeke May 19, 2026
06f1dd1
fix: remove phone number field from registration strings in AmStrings…
MelakuDemeke May 19, 2026
61452cb
fix: correct Google OAuth client ID and add server pull on login
MelakuDemeke May 20, 2026
7c72e0b
feat: update Google sign-in to return photo URL and enhance profile h…
MelakuDemeke May 20, 2026
e293d17
feat: enhance avatar display with initials fallback and improved layout
MelakuDemeke May 20, 2026
9be17f3
feat: move all hardcoded strings to l10n files
MelakuDemeke May 20, 2026
18f2e56
feat: replace unauthenticated avatar placeholder with sign-in button
MelakuDemeke May 20, 2026
8eede9c
feat: use official Google SVG assets and Facebook blue for social but…
MelakuDemeke May 20, 2026
df7537f
feat: add SVG assets for dark and light sign-in buttons
MelakuDemeke May 20, 2026
7f45ee4
fix: always show Google account picker on sign-in
MelakuDemeke May 20, 2026
b8581ec
feat: full profile editing — avatar upload, name/email, password chan…
MelakuDemeke May 20, 2026
20b099b
feat: add file selector plugin support for Linux, macOS, and Windows
MelakuDemeke May 20, 2026
82c6a2b
merge: bring main into pr-6-user-auth, resolve saved-screen conflicts
MelakuDemeke May 26, 2026
2fad7fd
fix: sync bookmarks, highlights, and notes with backend
MelakuDemeke May 26, 2026
de57113
feat: enhance annotation handling with verse count support
MelakuDemeke May 26, 2026
c9a7fd4
feat: wire reading plans section to backend API
MelakuDemeke May 26, 2026
9903704
feat: book-style reading plan cards and View All screen
MelakuDemeke May 26, 2026
0fe383a
fix: increase reading plan carousel height to prevent 2px overflow
MelakuDemeke May 26, 2026
a7c395e
fix: constrain plan name to fixed height so all cards are uniform
MelakuDemeke May 26, 2026
fb69c2e
feat: tap reading plan card to open reader and resume position
MelakuDemeke May 26, 2026
49d5c82
feat: settings sync (#8) and streak sync (#9) after login
MelakuDemeke May 26, 2026
616e1ba
docs: add PR_CHANGES.md for issue #6
MelakuDemeke May 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions PR_CHANGES.md
Original file line number Diff line number Diff line change
@@ -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<AppSettings>` 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
```
4 changes: 2 additions & 2 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ plugins {
}

android {
namespace = "org.nehemiah_osc.bibleflutter"
namespace = "org.nehemiah_osc.bible"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions android/app/google-services.json
Original file line number Diff line number Diff line change
@@ -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"}}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package org.nehemiah_osc.bibleflutter
package org.nehemiah_osc.bible

import io.flutter.embedding.android.FlutterActivity

Expand Down
15 changes: 15 additions & 0 deletions assets/signin/web_dark_sq_na.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions assets/signin/web_light_sq_na.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 17 additions & 11 deletions lib/core/annotations/annotation_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import 'package:flutter/material.dart';
enum SyncStatus { pendingCreate, pendingUpdate, pendingDelete, synced }

const highlightPalette = <Color>[
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 ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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;
}
112 changes: 112 additions & 0 deletions lib/core/api/api_client.dart
Original file line number Diff line number Diff line change
@@ -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<String, String> _headers({String? token}) => {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
};

Future<Map<String, dynamic>> get(String path, {String? token}) async {
final res = await http.get(
Uri.parse('$_baseUrl$path'),
headers: _headers(token: token),
);
return _parse(res);
}

Future<Map<String, dynamic>> post(
String path, {
Map<String, dynamic>? 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<Map<String, dynamic>> put(
String path, {
Map<String, dynamic>? 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<Map<String, dynamic>> 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<Map<String, dynamic>> patch(
String path, {
Map<String, dynamic>? 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<Map<String, dynamic>> delete(String path, {String? token}) async {
final res = await http.delete(
Uri.parse('$_baseUrl$path'),
headers: _headers(token: token),
);
return _parse(res);
}

Map<String, dynamic> _parse(http.Response res) {
late Map<String, dynamic> body;
try {
body = jsonDecode(res.body) as Map<String, dynamic>;
} 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);
}
}
Loading
Loading