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.
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- 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'))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