A modern Flutter app for learning, sharing, and live collaboration.
WeBuddhist App is designed to help users learn, live, and share knowledge interactively. It features a beautiful UI, supports both light and dark themes, and is built with best Flutter practices.
- Light and Dark theme support with toggle
- Modern Flutter architecture
- Easy to customize and extend
git clone https://github.com/your-username/flutter_pecha.git
cd flutter_pechaflutter pub getCreate environment files from the template:
cp .env.example .env.dev
cp .env.example .env.staging
cp .env.example .env.prodEdit each file with the appropriate values for that environment.
Each flavor should use its own PostHog project token:
| Variable | Description |
|---|---|
POSTHOG_API_KEY |
PostHog project API key for this flavor |
POSTHOG_HOST |
PostHog ingest host (default https://us.i.posthog.com) |
POSTHOG_ENABLED |
Set to false to disable analytics locally |
Android
flutter run --flavor dev -t lib/main_dev.dart
flutter run --flavor staging -t lib/main_staging.dart
flutter run --flavor prod -t lib/main_prod.dartBuild APK:
flutter build apk --flavor dev -t lib/main_dev.dart
flutter build apk --flavor staging -t lib/main_staging.dart
flutter build apk --flavor prod -t lib/main_prod.dartBuild App Bundle:
flutter build appbundle --flavor prod -t lib/main_prod.dartiOS
flutter run --flavor dev -t lib/main_dev.dart
flutter run --flavor staging -t lib/main_staging.dart
flutter run --flavor prod -t lib/main_prod.dartmacOs
flutter run -d macos --flavor dev --target lib/main_dev.dart
flutter run -d macos --flavor staging --target lib/main_staging.dart
flutter run -d macos --flavor prod --target lib/main_prod.dart Build IPA:
flutter build ios --flavor prod -t lib/main_prod.dartNote:
- For iOS, ensure you have Xcode installed and have granted the necessary permissions (see Flutter macOS setup).
- For Android, ensure Android Studio and an emulator/device are set up.
The app uses Flutter's built-in internationalization (l10n) with ARB files for translations.
Localization files location:
lib/core/l10n/
To add a new translation string:
- Add the key to all ARB files (
app_en.arb,app_bo.arb,app_zh.arb):
// app_en.arb
"my_new_key": "My English text",
// app_bo.arb
"my_new_key": "My Tibetan text",
// app_zh.arb
"my_new_key": "My Chinese text",- Generate localization files:
flutter gen-l10n
dart run tool/generate_tolgee_bridge.dartThe second command regenerates the Tolgee bridge so the new key can also be updated over the air. CI fails if it is skipped — see Over-the-air translations.
- Use in your widget:
import 'package:flutter_pecha/core/extensions/context_ext.dart';
// Access translation via context.l10n
Text(context.l10n.my_new_key)For translations with parameters:
// app_en.arb
"greeting": "Hello {name}",
"@greeting": {
"placeholders": {
"name": {"type": "String"}
}
}Usage:
Text(context.l10n.greeting('John'))Translation text can be corrected in Tolgee and picked up
by installed apps without shipping a new release. Nothing changes at call
sites: context.l10n.my_key keeps working exactly as before.
tool/generate_tolgee_bridge.dart reads the gen-l10n output and emits
TolgeeAppLocalizations, a subclass of AppLocalizations that overrides every
key. Each override asks Tolgee first and falls back to the bundled ARB value.
It is registered in main.dart in place of AppLocalizations.delegate.
The ARB files stay the source of truth in git and are the offline fallback; Tolgee is an override layer on top of them.
context.l10n.my_key
-> TolgeeAppLocalizations.my_key (generated override)
-> Tolgee CDN value (if loaded and for the right language)
-> bundled ARB value (otherwise)
Because the bundled ARB is always the fallback, the app behaves exactly as it did before Tolgee whenever the SDK is disabled, offline, still fetching, or missing a key.
Set these in .env.dev / .env.staging / .env.prod:
| Variable | Purpose |
|---|---|
TOLGEE_API_URL |
Tolgee API base, defaults to https://app.tolgee.io/v2 |
TOLGEE_API_KEY |
Read-only scoped project key |
TOLGEE_CDN_URL |
Content Delivery base URL |
TOLGEE_ENABLED |
Optional override; defaults to on when key and CDN URL are set |
Leaving TOLGEE_API_KEY empty disables the integration and the app uses the
bundled ARB only. This is also the kill switch if a bad translation ships.
The API key ships inside the app.
.envfiles are bundled as assets, so anything in them can be extracted from a release build. Use a project key scoped totranslations.viewandlanguages.viewonly — the SDK also contains a write API, so a leaked write-capable key would let anyone rewrite the app's copy.
- App UI locales use bare codes (
en,bo,zh,hi,mn,ne). Tolgee Content Delivery language tags (and CDN filenames) must been,bo-IN,zh-Hant-TW,hi,mn,ne— the app mapsbo/zhto those CDN tags viaTolgeeLocaleMap. Publishingbo.jsonorzh.jsonwill 404. - Enable ICU placeholder support so plural strings are served as ICU source.
- Content Delivery must export flat JSON (nesting and arrays disabled),
one file per language at
<TOLGEE_CDN_URL>/<tag>.json. The SDK's CDN parser expects{"key": "value"}and cannot read nested objects.
Translations are fetched on app start and on language change. An edit in Tolgee reaches users on their next app launch; there is no live push. A cold start with no network shows the bundled ARB text.
Bundled ARB files stay the offline fallback; the Tolgee CDN is the OTA
override. Sync uses a second key that must never live in .env.*:
| Key | Where | Role |
|---|---|---|
TOLGEE_API_KEY |
.env.* (ships in app) |
Read-only runtime OTA |
TOLGEE_SYNC_API_KEY |
shell env or GitHub Actions secret only | Local / CI ARB ↔ Tolgee sync (write) |
You can sync manually with dart run tool/tolgee_sync.dart ..., or let the
Tolgee Sync workflow do it on a schedule / via Run workflow. Keep both.
CI opens a reviewable PR — it does not merge to develop by itself.
Full command order, write-key creation (Tolgee → Project API Keys → + API Key), rotate-after-exposure guidance, and CI auto-update risks are in Tolgee integration and sync.
This project follows Clean Architecture principles with clear separation of concerns across three main layers.
┌─────────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ UI Components (Screens, Widgets) │ │
│ │ State Management (Riverpod Notifiers/Providers) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
└─────────────────────────────────────────────────────────────────┘
↓ depends on
┌─────────────────────────────────────────────────────────────────┐
│ DOMAIN LAYER │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Entities (Business Objects) │ │
│ │ Use Cases (Business Logic) │ │
│ │ Repository Interfaces (Contracts) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
└─────────────────────────────────────────────────────────────────┘
↓ depends on
┌─────────────────────────────────────────────────────────────────┐
│ DATA LAYER │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Repository Implementations │ │
│ │ Data Sources (API, Local Storage) │ │
│ │ Models (DTOs for serialization) │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
↓ depends on
┌─────────────────────────────────────────────────────────────────┐
│ EXTERNAL SERVICES │
│ (Auth0, Firebase, HTTP, Storage, etc.) │
└─────────────────────────────────────────────────────────────────┘
lib/
├── core/ # Shared infrastructure & utilities
│ ├── config/ # App configuration
│ ├── di/ # Dependency injection
│ ├── error/ # Error handling (Failures)
│ ├── network/ # Network utilities
│ ├── services/ # External service integrations
│ ├── storage/ # Local storage utilities
│ └── theme/ # App theming
│
├── features/ # Feature-based modules
│ └── auth/ # ← Authentication feature example
│ ├── domain/ # Business logic (no dependencies)
│ ├── data/ # Data implementation
│ └── presentation/ # UI & state management
│
└── shared/ # Cross-cutting concerns
├── data/ # Shared data layer utilities
├── domain/ # Shared domain logic
├── presentation/ # Shared UI components
└── widgets/ # Reusable widgets
Here's how authentication follows clean architecture through all layers:
Pure business logic with no framework dependencies
domain/
├── entities/
│ └── user.dart # User business entity
├── repositories/
│ └── auth_repository.dart # Repository interface (contract)
└── usecases/
├── login_usecase.dart # Login business logic
├── get_current_user_usecase.dart
└── logout_usecase.dart
Key points:
Userentity: Pure Dart class with business propertiesAuthRepository: Abstract interface defining data operationsLoginUseCase: Orchestrates login logic, returnsEither<Failure, User>
Implements domain contracts, handles external dependencies
data/
├── models/
│ └── user_model.dart # DTO for JSON serialization
├── datasources/
│ └── auth_remote_datasource.dart # API/Service calls
└── repositories/
└── auth_repository_impl.dart # Implements AuthRepository
Key points:
UserModel: Handles JSON ↔ Dart conversionAuthRemoteDataSource: Makes actual API callsAuthRepositoryImpl: Implements domain interface, uses datasource
UI and state management
presentation/
├── providers/
│ ├── auth_notifier.dart # State management
│ ├── auth_providers.dart # DI setup
│ └── use_case_providers.dart # Use case providers
├── screens/
│ └── login_page.dart # Login UI
└── widgets/
└── login_form.dart # Reusable form widget
Key points:
AuthNotifier: Manages auth state, calls use casesauthProviders: Wire up dependencies using RiverpodLoginPage: UI that consumes state via providers
| Layer | Responsibility | Dependencies |
|---|---|---|
| Domain | Business rules & logic | None (pure Dart) |
| Data | Data sources & persistence | Domain, external services |
| Presentation | UI & state management | Domain (via use cases) |
- Testable: Each layer can be unit tested independently
- Maintainable: Changes in one layer don't break others
- Scalable: Easy to add new features following same pattern
- Flexible: Swap implementations (e.g., change API) without affecting business logic
Dio is a powerful HTTP client for Dart. Think of it as a supercharged http package with built-in support for interceptors, retries, timeout handling, and request transformation.
| Feature | http package |
Dio |
|---|---|---|
| Interceptors | No | Yes (we use this heavily) |
| Global configuration | Limited | Full |
| Automatic retries | Manual | Built-in |
Location: lib/core/network/dio_client.dart
class DioClient {
DioClient({
required AuthInterceptor authInterceptor,
required RetryInterceptor retryInterceptor,
// ... other interceptors
}) : _dio = Dio(options) {
// Interceptor order is critical
_dio.interceptors.addAll([
authInterceptor, // 1. Add auth headers first
cacheInterceptor, // 2. Check cache
retryInterceptor, // 3. Handle 401 & network errors
errorInterceptor, // 4. Convert errors
loggingInterceptor, // 5. Log final result
]);
}
}Why this order? Each interceptor processes the request in sequence. Auth must run first to add tokens before the request goes out.
Request: Auth → Cache → Retry → Error → Log → Server
Response: Log → Error → Retry → Cache → Auth → UI
What it does: Checks if the API endpoint requires authentication, adds the auth token.
// lib/core/network/interceptors/auth_interceptor.dart
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
// Only add auth for protected routes
if (ProtectedRoutes.isProtected(options.path)) {
final token = await _tokenProvider.getToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
}
handler.next(options); // Pass to next interceptor
}Key point: Uses TokenProvider abstraction so we can swap token sources without changing this code.
What it does: Handles 401 (token expired) errors by refreshing the token and retrying the request.
// lib/core/network/interceptors/retry_interceptor.dart
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
// Handle 401 - Token expired
if (err.response?.statusCode == 401) {
if (_isRefreshing) {
// Already refreshing, queue this request
_refreshQueue.add(_RetryRequest(err, handler));
return;
}
_isRefreshing = true;
final newToken = await _authService.refreshIdToken();
// Retry all queued requests with new token
for (final request in _refreshQueue) {
request.error.requestOptions.headers['Authorization'] = 'Bearer $newToken';
// Retry the request...
}
}
// Retry network errors with exponential backoff
if (_shouldRetry(err)) {
await Future.delayed(Duration(milliseconds: 1000 * (1 << retryCount)));
// Retry...
}
}OAuth 2.0 is an authorization framework that lets users grant limited access to their accounts without sharing passwords.
Real-world analogy: Like giving a valet key to your car - it can only drive the car, not open the trunk.
- Security: User never shares password with your app
- Control: User can revoke access anytime
- Standardization: Industry-wide protocol
- Social Login: Leverage existing accounts
┌─────────────┐ ┌─────────────┐
│ User │ │ Auth0 Server │
│ (Resource │ │ │
│ Owner) │ │ │
└──────┬──────┘ └──────┬──────┘
│ │
│ 1. Tap "Login with Google" │
├───────────────────────────────►│
│ │
│ 2. Show Google login page │
│◄───────────────────────────────┤
│ │
│ 3. User authenticates │
├───────────────────────────────►│
│ │
│ 4. Return tokens │
│◄───────────────────────────────┤
│ │
│ (Access Token, ID Token, │
│ Refresh Token) │
Location: lib/features/auth/auth_service.dart
class AuthService {
final Auth0 _auth0 = Auth0('YOUR_DOMAIN', 'YOUR_CLIENT_ID');
Future<Credentials?> loginWithGoogle() async {
final credentials = await _auth0.webAuthentication(scheme: 'org.pecha.app')
.login(
useHTTPS: true,
parameters: {"connection": "google-oauth2"},
scopes: {"openid", "profile", "email", "offline_access"},
);
// Auth0 SDK handles PKCE automatically
// Credentials contain: accessToken, idToken, refreshToken, expiresIn
await _auth0.credentialsManager.storeCredentials(credentials);
return credentials;
}
}What scopes mean:
openid: Enables OIDC protocolprofile: Access to user profile dataemail: Access to user emailoffline_access: Enables refresh tokens
Step 1: UI - Login Button
// lib/features/auth/presentation/widgets/auth_buttons.dart
ElevatedButton(
onPressed: () {
ref.read(authProvider.notifier).login(connection: 'google-oauth2');
},
child: Text('Login with Google'),
)Step 2: AuthNotifier - State Management
// lib/features/auth/presentation/providers/auth_notifier.dart
Future<void> login({String? connection}) async {
state = state.copyWith(isLoading: true);
final result = await _loginUseCase(LoginParams(connection: connection));
result.fold(
(failure) => state = state.copyWith(errorMessage: failure.message),
(credentials) => _handleSuccessfulLogin(credentials),
);
}Step 3: UseCase - Orchestration
// lib/features/auth/domain/usecases/login_usecase.dart
Future<Either<Failure, AuthCredentials>> call(LoginParams params) async {
switch (params.connection) {
case 'google-oauth2':
return await _repository.loginWithGoogle();
case 'apple':
return await _repository.loginWithApple();
}
}Step 4: Repository - Data Layer
// lib/features/auth/data/repositories/auth_repository_impl.dart
Future<Either<Failure, AuthCredentials>> loginWithGoogle() async {
try {
final credentials = await _authService.loginWithGoogle();
return Right(_toAuthCredentials(credentials));
} catch (e) {
return Left(AuthenticationFailure('Login failed'));
}
}Step 5: AuthService - Auth0 Integration
// lib/features/auth/auth_service.dart
Future<Credentials?> loginWithGoogle() async {
return _loginWithConnection('google-oauth2');
}
Future<Credentials?> _loginWithConnection(String connection) async {
final credentials = await _auth0.webAuthentication(scheme: 'org.pecha.app')
.login(
useHTTPS: true,
parameters: {"connection": connection},
scopes: {"openid", "profile", "email", "offline_access"},
);
await _auth0.credentialsManager.storeCredentials(credentials);
return credentials;
}Location: lib/core/config/router/go_router.dart
final goRouterProvider = Provider<GoRouter>((ref) {
return GoRouter(
refreshListenable: GoRouterRefreshStream(ref.watch(authProvider.notifier).stream),
redirect: (context, state) async {
final authState = ref.watch(authProvider);
// Unauthenticated trying to access protected route
if (!authState.isLoggedIn && RouteConfig.isProtectedRoute(currentPath)) {
return RouteConfig.login; // Redirect to login
}
// Authenticated user on login page
if (authState.isLoggedIn && currentPath == RouteConfig.login) {
return RouteConfig.home; // Redirect to home
}
return null; // No redirect
},
);
});How it works:
- Router watches
authProviderfor state changes - When auth state changes, router re-evaluates redirect logic
- Automatically redirects based on auth status
// lib/features/auth/presentation/providers/auth_notifier.dart
Future<void> logout() async {
// 1. Clear credentials from storage
await _localLogoutUseCase(const NoParams());
// 2. Clear user data
await ref.read(userProvider.notifier).clearUser();
// 3. Update state
state = state.copyWith(isLoggedIn: false);
// 4. Router automatically redirects to login
}// lib/features/auth/presentation/providers/auth_notifier.dart
AuthNotifier(...) : super(const AuthState(isLoading: true)) {
_restoreLoginState(); // Runs immediately on creation
}
Future<void> _restoreLoginState() async {
// 1. Check for valid credentials
final hasCredentials = await _hasValidCredentialsUseCase();
if (hasCredentials) {
// 2. Restore user data
state = state.copyWith(isLoggedIn: true, isLoading: false);
ref.read(userProvider.notifier).initializeUser();
} else {
// 3. Check for guest mode
final isGuest = await _isGuestModeUseCase();
state = state.copyWith(isLoggedIn: isGuest, isGuest: isGuest, isLoading: false);
}
}┌─────────────────────────────────────────────────────────────────────────┐
│ AUTHENTICATION & API CALL FLOW │
└─────────────────────────────────────────────────────────────────────────┘
APP LAUNCH
│
▼
┌─────────────────────────┐
│ AuthNotifier created │
│ (isLoading: true) │
└───────────┬─────────────┘
│
▼
┌─────────────────────────┐
│ Check stored creds │
│ (CredentialsManager) │
└───────────┬─────────────┘
│
┌───────────┴───────────┐
│ │
Has Creds No Creds
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ isLoggedIn=true │ │ Check guest mode │
│ isLoading=false │ └────────┬────────┘
└────────┬────────┘ │
│ ┌───────┴───────┐
│ Was Guest? Not Guest
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌──────────┐ ┌──────────┐
│ Show Home │ │Show Home │ │Show Login│
└──────┬──────┘ └──────┬───┘ └──────────┘
│ │
│ │
▼ ▼
┌──────────────────────────────────┐
│ USER TAPS LOGIN BUTTON │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ AuthNotifier.login() called │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ LoginUseCase called │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ AuthRepository.loginWithGoogle() │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ AuthService.loginWithGoogle() │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ Auth0 Web Auth opens │
│ (PKCE flow handled by SDK) │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ User authenticates with Google │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ Credentials returned │
│ (access, id, refresh tokens) │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ Stored in CredentialsManager │
│ (Keychain/Keystore) │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ AuthNotifier state updated │
│ (isLoggedIn: true) │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ GoRouter redirects to Home │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ User fetches their plans │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ UserPlansNotifier.fetchPlans() │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ GetPlansUseCase called │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ Repository.getUserPlans() │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ DataSource calls Dio.get() │
│ URL: /users/me/plans │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ REQUEST INTERCEPTOR CHAIN │
│ │
│ 1. AuthInterceptor │
│ - Path is protected? YES │
│ - Get token from Provider │
│ - Provider calls AuthService │
│ - AuthService checks expiry │
│ - If expired, refreshes │
│ - Returns valid token │
│ - Adds Authorization header │
│ │
│ 2. CacheInterceptor │
│ - Not in cache, proceed │
│ │
│ 3. RetryInterceptor │
│ - No error, proceed │
│ │
│ 4. Send to server │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ SERVER RESPONSE: 200 OK │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ RESPONSE INTERCEPTOR CHAIN │
│ │
│ 1. RetryInterceptor │
│ - No 401, proceed │
│ │
│ 2. CacheInterceptor │
│ - Store in cache │
│ │
│ 3. ErrorInterceptor │
│ - No error, proceed │
│ │
│ 4. LoggingInterceptor │
│ - Log success │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ DataSource parses JSON │
│ Returns List<UserPlanModel> │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ Repository maps to entities │
│ Returns Either<Failure, Plans> │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ UseCase returns Either │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ Notifier folds Either │
│ Updates state with data │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ UI rebuilds with plans data │
└──────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ 401 TOKEN EXPIRED FLOW │
└─────────────────────────────────────────────────────────────────────────┘
API Request with expired token
│
▼
┌──────────────────────────────────┐
│ Server returns 401 Unauthorized │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ RetryInterceptor.onError() │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ Check: Has valid credentials? │
└──────────────┬───────────────────┘
│
┌────────┴────────┐
│ │
YES NO
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Check if │ │ Pass error │
│ already │ │ through │
│ refreshing? │ └──────────────┘
└──────┬───────┘
│
┌─────┴─────┐
│ │
Refreshing Not refreshing
│ │
▼ ▼
┌─────────┐ ┌──────────────────┐
│ Queue │ │ Set refreshing = │
│ request │ │ true │
└─────────┘ └────────┬─────────┘
│
▼
┌──────────────────────┐
│ Call AuthService │
│ .refreshIdToken() │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Auth0 API renews │
│ using refresh token │
└──────────┬───────────┘
│
┌────────┴────────┐
│ │
Success Failure
│ │
▼ ▼
┌───────────────┐ ┌────────────────┐
│ Store new │ │ onAuthExpired │
│ credentials │ │ callback │
└───────┬───────┘ │ → Logout │
│ └────────────────┘
▼
┌───────────────┐
│ Retry queued │
│ requests │
└───────┬───────┘
│
▼
┌───────────────┐
│ Retry original│
│ request │
└───────┬───────┘
│
▼
┌───────────────┐
│ Set refreshing│
│ = false │
└───────────────┘
Pull requests are welcome! For major changes, please open an issue first to discuss what you would like to change.
Table of Contents Screen
- Browse through organized text content lists with ease
- View all available versions of each text in one convenient location
Reader Screen
- Immersive reading experience with optimized formatting