diff --git a/.gitignore b/.gitignore index aa724b7..89f1239 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /.idea/assetWizardSettings.xml .DS_Store /build +*/build/ /captures .externalNativeBuild .cxx diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..820fe60 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,599 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Current Progress & Next Steps + +### Current Task: Ready for PR Review ✅ + +**Branch**: `splashScreen` +**Status**: Complete - Ready to merge + +All implementation and testing complete for splash screen feature using Android 12+ Splash Screen API. + +### Recently Completed + +#### 1. Type-Safe Navigation (Branch: featureAuth-Navigation) +**What was done:** +- Implemented type-safe navigation using Kotlin Serialization and Navigation Compose +- Created `Routes.kt` with serializable route objects: `AuthGraph`, `AgendaGraph`, `AuthRoute.Login`, `AuthRoute.Register`, `AgendaRoute.Agenda` +- Built `TaskyNavHost.kt` with nested navigation graphs +- Connected Login ↔ Register navigation +- Added navigation from Login to Agenda on successful authentication (clears auth backstack) + +**Files modified:** +- `app/src/main/java/com/dmd/tasky/ui/navigation/Routes.kt` (new) +- `app/src/main/java/com/dmd/tasky/ui/navigation/TaskyNavHost.kt` (new, replaces old NavGraph.kt) +- `app/build.gradle.kts` (added kotlinx-serialization plugin and navigation dependencies) + +#### 2. Test Suite Improvements +**What was done:** +- Fixed bug in `RegisterViewModelTest.kt` where generic error test was checking for wrong error type +- Added event emission tests to `LoginViewModelTest.kt` for Success and Error events +- Added "typing email should clear error" test to `LoginViewModelTest.kt` +- Uncommented and fixed validation tests in `RegisterViewModelTest.kt` +- Started `DefaultAuthRepositoryTest.kt` with basic login/register success and error tests + +**Key findings from test analysis:** +- Discovered that Login API returns `AuthResponse` with `accessToken`, `refreshToken`, `userId`, `username`, and `accessTokenExpirationTimestamp` +- Register API returns `Unit` (no token - user must login after registration) +- Current implementation only returns `accessToken` string from repository, discarding other critical data +- LoginViewModel has TODO comment on line 61: `// TODO: Save token, navigate to next screen` +- Both ViewModels support dual testing approaches: Fakes (RegisterViewModel) and Mocks (LoginViewModel) for learning purposes + +### Current Task: Token Persistence & Session Management ✅ COMPLETE + +**Branch**: `featureTokenPersistance` +**Status**: Implementation complete - integrated with splash screen for auth state navigation + +#### What Was Implemented + +##### Phase 1-2: Token Storage Infrastructure & Repository Integration ✅ + +**Created**: `:core:data` module with complete token management system + +**Files created:** +- `core/data/src/main/java/com/dmd/tasky/core/data/token/TokenManager.kt` - Interface for session management +- `core/data/src/main/java/com/dmd/tasky/core/data/token/DataStoreTokenStorage.kt` - Implementation using DataStore + Encryption +- `core/data/src/main/java/com/dmd/tasky/core/data/security/CryptoManager.kt` - AES-256-GCM encryption handler +- `core/data/src/main/java/com/dmd/tasky/core/data/remote/AuthTokenInterceptor.kt` - OkHttp interceptor for Bearer tokens +- `core/data/src/main/java/com/dmd/tasky/core/data/di/CoreDataModule.kt` - Hilt DI configuration +- `core/data/src/main/java/com/dmd/tasky/core/data/local/EncryptedTokenData.kt` - Data class for serialization + +**Key Features Implemented:** +- ✅ `TokenManager` interface with complete CRUD operations +- ✅ `SessionData` stores: accessToken, refreshToken, userId, username, accessTokenExpirationTimestamp +- ✅ AES-256-GCM encryption using Android Keystore (hardware-backed keys) +- ✅ DataStore Preferences for persistent storage (with manual encryption layer) +- ✅ Token expiration checking with 5-minute safety buffer +- ✅ Flow-based reactive authentication state via `isAuthenticated()` +- ✅ Base64 encoding for encrypted data +- ✅ Comprehensive error handling and logging + +**Security Implementation:** +- **Encryption**: AES-256-GCM with 256-bit keys +- **Key Storage**: Android Keystore (secure hardware element) +- **IV Generation**: Random 12-byte IV per encryption operation +- **Authentication Tag**: GCM provides built-in integrity verification +- **No Plain Text Logging**: Tokens never logged in production builds + +**Repository Integration** (`DefaultAuthRepository.kt`): +- ✅ Lines 27-35: Saves complete session via `TokenManager` after successful login +- ✅ Changed return type from `Result` to `Result` +- ✅ Lines 97-111: `logout()` clears tokens via `TokenManager.clearSession()` +- ✅ Proper error handling with HTTP status code mapping + +**Network Configuration** (`CoreDataModule.kt`): +- ✅ Centralized OkHttp and Retrofit configuration in `:core:data` +- ✅ Interceptor chain: ApiKeyInterceptor → AuthTokenInterceptor → HttpLoggingInterceptor +- ✅ All API requests automatically include authentication tokens + +**Dependencies Added:** +```gradle +// gradle/libs.versions.toml +securityCrypto = "1.1.0-alpha06" +datastore = "1.1.1" + +// core/data/build.gradle.kts +implementation(libs.androidx.security.crypto) +implementation(libs.androidx.datastore.preferences) +implementation(libs.okhttp) +implementation(libs.okhttp.logging.interceptor) +implementation(libs.retrofit) +implementation(libs.retrofit.converter.kotlinx.serialization) +``` + +##### Phase 3: Token Requests & Expiration ✅ + +**Status**: COMPLETE +- ✅ `AuthTokenInterceptor` adds Bearer tokens to all API requests +- ✅ `logout()` method clears tokens +- ✅ Token expiration validation with safety buffer +- ⚠️ 401 response handling not yet implemented (future enhancement) + +#### Breaking Changes Made + +1. **LoginResult Type Change**: + - FROM: `Result` (returned token string) + - TO: `Result` (token saved internally) + - Impact: ViewModels no longer receive token directly + +2. **Repository Responsibility Shift**: + - Repository now handles token storage (separation of concerns) + - ViewModels only handle UI state and navigation + - Tokens never exposed to presentation layer + +#### Auth State Navigation Integration + +**Implementation**: Auth state checking integrated with Splash Screen (see section 4 below) +- ✅ `MainViewModel` checks `TokenManager.isTokenValid()` on app startup +- ✅ `TaskyNavHost` receives `isAuthenticated` parameter and routes accordingly +- ✅ Splash screen keeps visible while auth state is being checked +- ✅ Automatic navigation to AgendaGraph (authenticated) or AuthGraph (not authenticated) + +#### Architecture Decisions Made + +**Why DataStore + CryptoManager (Not EncryptedSharedPreferences)?** +- ✅ Modern, coroutine-based API +- ✅ Flow-based reactive state +- ✅ Manual encryption gives control over key management +- ✅ Consistent with async architecture + +**Why Changed LoginResult from String to Unit?** +- ✅ Separation of concerns (repository handles storage) +- ✅ Prevents token exposure in ViewModels +- ✅ Centralizes token management logic +- ✅ Easier to add features like token refresh + +**Why Centralize Networking in :core:data?** +- ✅ Single source of truth for OkHttpClient +- ✅ Interceptors applied consistently +- ✅ Easier to add global error handling +- ✅ Follows Clean Architecture principles + +#### Session Flow + +``` +Login Screen + ↓ +Enter credentials + ↓ +Repository.login() → API call → AuthResponse + ↓ ↓ +TokenManager.saveSession() ←───────┘ + ↓ +Encrypt with CryptoManager + ↓ +Store in DataStore + ↓ +Return Success(Unit) + ↓ +Navigate to AgendaScreen + ↓ +App Restart + ↓ +Splash Screen shows (Android 12+ API) + ↓ +MainViewModel checks TokenManager.isTokenValid() + ↓ +Read from DataStore → Decrypt → Check expiration + ↓ +MainViewModel.isAuthenticated updates (true/false) + ↓ +Splash screen dismisses + ↓ +TaskyNavHost receives isAuthenticated parameter + ↓ +If valid: Navigate to AgendaGraph ✅ +If expired: Navigate to AuthGraph +``` + +#### Testing Status +- ✅ `DefaultAuthRepositoryTest` - Verifies token saving on login (4 tests) +- ✅ `RegisterViewModelTest` - Uses fake repository pattern (8 tests) +- ✅ `LoginViewModelTest` - Fixed type mismatch issue (4 tests) +- ✅ `MainViewModelTest` - Tests auth state initialization (3 tests) +- ❌ Token encryption/decryption - No dedicated tests +- ❌ AuthTokenInterceptor - No tests + +**Total Test Coverage**: 19 unit tests across 4 test files - ALL PASSING ✅ + +## Project Overview + +Tasky is an Android task management application built with Kotlin and Jetpack Compose, following a modular multi-module architecture with Clean Architecture principles. + +**Base package**: `com.dmd.tasky` + +**Key Technologies**: +- Kotlin 2.0.21 with Compose 2.0.21 +- Jetpack Compose for UI +- Hilt for dependency injection +- Retrofit + OkHttp + Kotlinx Serialization for networking +- Timber for logging +- MockK + Kotlinx Coroutines Test for testing + +## Module Architecture + +The project follows a feature-based modular architecture: + +``` +:app - Main application module, hosts MainActivity and TaskyApplication +:features:auth - Authentication feature (login, register) +:core:domain:util - Shared domain utilities (Result, Error) +:core:data - Shared data layer (token storage, session management) +``` + +**Core Modules:** +- `:core:domain:util` - Domain-level abstractions (Result, Error interfaces) +- `:core:data` - Data persistence, token storage, session management (uses DataStore with AES-256-GCM encryption) + +Each feature module follows Clean Architecture layers: +- `data/` - Repository implementations, API clients, DTOs, interceptors +- `domain/` - Repository interfaces, domain models, business logic +- `presentation/` - ViewModels, UI states, actions, Compose screens +- `di/` - Hilt modules for dependency injection + +**Token Storage Architecture:** +- `TokenManager` interface defines contract for secure session management +- `DataStoreTokenStorage` implementation uses DataStore with manual AES-256-GCM encryption +- Encryption handled by `CryptoManager` using Android Keystore (hardware-backed keys) +- Stores: accessToken, refreshToken, userId, username, accessTokenExpirationTimestamp +- Provides Flow-based authentication state with automatic expiration checking (5-minute safety buffer) + +## Common Commands + +### Build & Assemble +```bash +./gradlew build # Build all modules +./gradlew app:assembleDebug # Build debug APK +./gradlew app:assembleRelease # Build release APK +./gradlew clean # Clean build artifacts +``` + +### Testing +```bash +./gradlew test # Run all unit tests +./gradlew testDebugUnitTest # Run debug unit tests +./gradlew features:auth:testDebugUnitTest # Run tests for specific module +./gradlew connectedAndroidTest # Run instrumented tests on connected devices +./gradlew connectedDebugAndroidTest # Run debug instrumented tests +``` + +### Code Quality +```bash +./gradlew lint # Run lint on default variant +./gradlew lintDebug # Run lint on debug variant +./gradlew lintFix # Apply safe lint suggestions +./gradlew check # Run all checks (tests + lint) +``` + +## Architecture Patterns + +### Result/Error Handling + +The codebase uses a custom `Result` sealed interface for error handling: + +```kotlin +// Located in :core:domain:util +sealed interface Result { + data class Success(val data: D) + data class Error(val error: E) +} +``` + +Feature modules define domain-specific errors (e.g., `AuthError`) that implement the `Error` interface from `:core:domain:util`. + +**Extension functions**: `map()`, `onSuccess()`, `onError()`, `asEmptyDataResult()` + +**Usage pattern**: Repository methods return `Result` types, ViewModels handle success/error cases using extension functions. + +### Dependency Injection + +Hilt is used throughout with the following setup: +- Application class: `TaskyApplication` annotated with `@HiltAndroidApp` +- MainActivity: Annotated with `@AndroidEntryPoint` +- ViewModels: Annotated with `@HiltViewModel` with `@Inject` constructor +- DI modules: Feature modules provide their own Hilt modules in `di/` package (e.g., `AuthModule`) + +### Presentation Layer Pattern + +Features use MVI-style architecture: +- **ViewModel**: Holds `state` (UI state data class) and `onAction(action: Action)` method +- **Action**: Sealed interface defining all user interactions +- **UiState**: Data class containing all screen state +- **Screen**: Composable that observes ViewModel state and dispatches actions + +Example from auth feature: +- `LoginViewModel` with `LoginUiState` and `LoginAction` +- `LoginScreen` Composable + +### API Configuration + +The auth feature uses `BuildConfig` for API configuration: +- Base URL: Defined in `features:auth/build.gradle.kts` as `BuildConfig.BASE_URL` +- API Key: Loaded from `local.properties` file as `apiKey` property +- Interceptor: `ApiKeyInterceptor` adds API key to requests + +**Note**: The `local.properties` file is git-ignored and must be created locally with the `apiKey` property. + +### Networking + +Retrofit setup in `AuthModule`: +- Uses Kotlinx Serialization for JSON parsing +- OkHttp with logging interceptor (body level logging) +- Custom `ApiKeyInterceptor` for authentication +- DTOs in `data/remote/dto/` package +- API interface in `data/remote/` package + +### Navigation + +The app uses type-safe navigation with Navigation Compose and Kotlin Serialization: + +**Setup:** +- Routes defined in `app/src/main/java/com/dmd/tasky/ui/navigation/Routes.kt` +- All route objects are `@Serializable` data objects +- Navigation graphs: `AuthGraph` and `AgendaGraph` +- Individual routes: `AuthRoute.Login`, `AuthRoute.Register`, `AgendaRoute.Agenda` + +**Pattern:** +```kotlin +// Navigation graphs (top-level destinations) +@Serializable +data object AuthGraph + +// Route sealed interfaces for type-safety +sealed interface AuthRoute { + @Serializable + data object Login : AuthRoute +} +``` + +**Usage in NavHost:** +- `TaskyNavHost` in `app/src/main/java/com/dmd/tasky/ui/navigation/TaskyNavHost.kt` +- Uses nested navigation with `navigation()` composable +- Screens receive navigation callbacks (e.g., `onNavigateToLogin`, `onLoginSuccess`) +- Clearing backstacks: Use `popUpTo(Graph) { inclusive = true }` for login→agenda flow + +**Dependencies:** +- `androidx.navigation:navigation-compose` (in app module) +- `kotlinx-serialization-json` (in app module) +- Kotlin Serialization plugin enabled in `app/build.gradle.kts` + +#### 3. Repository Refactoring - `safeApiCall` Pattern ✅ COMPLETE + +**Branch**: `featureTokenPersistance` (merged) +**Date**: November 29, 2024 + +**What was done:** +- Implemented `safeApiCall` helper function to eliminate code duplication +- Refactored all repository methods (`login`, `register`, `logout`) to use DRY pattern +- Added contextual logging with operation names for better debugging +- Used `.onSuccess` for declarative side effects (session management) + +**Implementation:** +- File: `features/auth/src/main/java/com/dmd/tasky/features/auth/data/repository/DefaultAuthRepository.kt` +- Helper function: `safeApiCall(operation: String, apiCall: suspend () -> T)` (lines 65-91) +- Centralized error handling for all HTTP exceptions, timeouts, and network errors +- Reduced from ~120 lines to 92 lines (28 lines saved) + +**Pattern used:** +```kotlin +override suspend fun logout(): LogoutResult { + return safeApiCall("logout") { api.logout() } + .onSuccess { tokenManager.clearSession() } +} +``` + +**Benefits achieved:** +- ✅ DRY principle - error handling in one place +- ✅ Declarative side effects with `.onSuccess` +- ✅ Contextual logging ("HTTP Error during login: Code=401") +- ✅ Easier to maintain and test +- ✅ All tests updated and passing + +#### 4. Splash Screen Implementation ✅ COMPLETE (Updated after PR Review) + +**Branch**: `splashScreen` +**Date**: November 30, 2024 +**PR Review Fix**: January 2025 + +**Architecture Decision:** +- Using Android 12+ Splash Screen API instead of custom splash screen/view +- Auth state check handled in `MainViewModel` (not dedicated SplashViewModel) +- Simpler implementation, leverages platform capabilities + +**What was implemented:** + +**Files created:** +- `app/src/main/java/com/dmd/tasky/MainViewModel.kt` - Manages auth state on app startup +- `app/src/test/java/com/dmd/tasky/MainViewModelTest.kt` - 4 unit tests, all passing + +**Files modified:** +- `app/src/main/java/com/dmd/tasky/MainActivity.kt` - Integrated Splash Screen API +- `app/src/main/java/com/dmd/tasky/ui/navigation/TaskyNavHost.kt` - Accepts `isAuthenticated` parameter +- `app/src/main/java/com/dmd/tasky/ui/navigation/Routes.kt` - Removed unused Splash route +- `app/build.gradle.kts` - Added `androidx.core:core-splashscreen` dependency + +**Implementation Details (CORRECTED after PR review):** + +`MainViewModel.kt`: +```kotlin +data class MainState( + val isCheckingAuth: Boolean = true, // Splash screen condition + val isLoggedIn: Boolean = false // Navigation decision +) + +@HiltViewModel +class MainViewModel @Inject constructor( + private val tokenManager: TokenManager +) : ViewModel() { + var state by mutableStateOf(MainState()) + private set + + init { + viewModelScope.launch { + val isValid = tokenManager.isTokenValid() + state = state.copy( + isCheckingAuth = false, + isLoggedIn = isValid + ) + } + } +} +``` + +`MainActivity.kt`: +```kotlin +override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Keep splash visible while CHECKING auth (not while unauthenticated!) + installSplashScreen().setKeepOnScreenCondition { + viewModel.state.isCheckingAuth + } + + setContent { + if (!viewModel.state.isCheckingAuth) { + TaskyNavHost(isAuthenticated = viewModel.state.isLoggedIn) + } + } +} +``` + +**Flow (CORRECTED):** +``` +App Launch + ↓ +isCheckingAuth = true, isLoggedIn = false + ↓ +Splash stays visible (isCheckingAuth = true) + ↓ +Token check completes + ↓ +isCheckingAuth = false, isLoggedIn = true/false + ↓ +Splash dismisses (isCheckingAuth = false) + ↓ +TaskyNavHost renders → AgendaGraph OR AuthGraph +``` + +**Test Coverage:** +- `MainViewModelTest.kt` - 4 tests + 1. ✅ MainState defaults have correct values + 2. ✅ Token valid → isLoggedIn = true, isCheckingAuth = false + 3. ✅ Token invalid → isLoggedIn = false, isCheckingAuth = false + 4. ✅ Verifies token check called exactly once + +**Advantages of this approach:** +- ✅ Leverages Android platform Splash Screen API +- ✅ No custom splash screen composable needed +- ✅ Simple, maintainable code +- ✅ Automatic splash screen animations and transitions +- ✅ Works with system theme and branding + +**⚠️ LESSON LEARNED - Splash Screen Anti-Pattern:** + +The original implementation had a bug caught in PR review: + +```kotlin +// ❌ WRONG - Splash stays forever for logged-out users! +installSplashScreen().setKeepOnScreenCondition { + !viewModel.isAuthenticated // FALSE for logged-out users = splash never dismisses +} +``` + +**The problem:** Using authentication STATUS as splash condition means: +- Logged-in users: `isAuthenticated = true` → `!true = false` → splash dismisses ✓ +- Logged-out users: `isAuthenticated = false` → `!false = true` → splash NEVER dismisses ✗ + +**The fix:** Separate "checking" from "result": +- `isCheckingAuth` - Are we still checking? (splash condition) +- `isLoggedIn` - What was the result? (navigation decision) + +**Key insight:** Splash screen should dismiss when the CHECK COMPLETES, not based on the CHECK RESULT. + +## Development Workflow + +### Adding a New Feature Module + +1. Create module structure in `settings.gradle.kts` +2. Follow the standard layer structure: `data/`, `domain/`, `presentation/`, `di/` +3. Create Hilt module for dependencies +4. Depend on `:core:domain:util` for shared utilities +5. Add feature module dependency to `:app` module + +### Testing Guidelines + +- Unit tests: Located in `src/test/` directory +- Use MockK for mocking +- Use `kotlinx-coroutines-test` for testing coroutines +- ViewModels should have corresponding test files (e.g., `RegisterViewModelTest`) +- Test files follow `*Test.kt` naming convention + +### Code Style + +- Package structure follows reverse domain: `com.dmd.tasky` +- Feature-specific packages include feature name: `com.dmd.tasky.features.auth` +- Use Timber for logging (configured in `TaskyApplication`) +- Timber only plants `DebugTree` in debug builds + +## Common Pitfalls & Lessons Learned + +### 1. Splash Screen: Separate "Checking" from "Result" + +**Anti-pattern:** +```kotlin +// ❌ WRONG - condition based on auth RESULT +installSplashScreen().setKeepOnScreenCondition { + !viewModel.isAuthenticated +} +``` + +**Correct pattern:** +```kotlin +// ✅ CORRECT - condition based on check COMPLETION +data class MainState( + val isCheckingAuth: Boolean = true, // For splash + val isLoggedIn: Boolean = false // For navigation +) + +installSplashScreen().setKeepOnScreenCondition { + viewModel.state.isCheckingAuth +} +``` + +**Why:** Splash should dismiss when async operation COMPLETES, not based on RESULT. + +### 2. Composition vs Inheritance for Domain Models + +When domain models share many properties but differ in few, prefer composition: + +**Anti-pattern (inheritance):** +```kotlin +sealed interface AgendaItem { + data class Event(override val id, override val title, ..., val attendees) : AgendaItem + data class Task(override val id, override val title, ..., val isDone) : AgendaItem +} +// Problem: Repeated "override val" for every common property +``` + +**Correct pattern (composition):** +```kotlin +data class AgendaItem( + val id: String, + val title: String, + // ...common properties defined ONCE + val details: AgendaItemDetails +) + +sealed interface AgendaItemDetails { + data class Event(val to: LocalDateTime, val attendees: List) : AgendaItemDetails + data class Task(val isDone: Boolean) : AgendaItemDetails + data object Reminder : AgendaItemDetails // No unique properties +} +``` + +**Why:** +- Common properties defined once +- `data object` elegantly expresses "no unique properties" +- Sorting/filtering works directly: `items.sortedBy { it.time }` \ No newline at end of file diff --git a/INTERVIEW_PREP.md b/INTERVIEW_PREP.md new file mode 100644 index 0000000..022f2ab --- /dev/null +++ b/INTERVIEW_PREP.md @@ -0,0 +1,1479 @@ +# Android Developer Interview Preparation Guide + +**Prepared for:** Tasky Project Review & General Android Interviews +**Last Updated:** January 30, 2026 + +--- + +## Table of Contents + +1. [Project-Specific Topics](#1-project-specific-topics) + - [Architecture Patterns](#architecture-patterns) + - [Dependency Injection with Hilt](#dependency-injection-with-hilt) + - [Jetpack Compose](#jetpack-compose) + - [Networking & Serialization](#networking--serialization) + - [Coroutines & Flow](#coroutines--flow) + - [Security & Encryption](#security--encryption) + - [Navigation](#navigation) + - [Testing](#testing) +2. [Android Fundamentals](#2-android-fundamentals) +3. [Kotlin Language Fundamentals](#3-kotlin-language-fundamentals) +4. [Common Interview Questions](#4-common-interview-questions) +5. [System Design Questions](#5-system-design-questions) +6. [Behavioral Questions](#6-behavioral-questions) + +--- + +## 1. Project-Specific Topics + +### Architecture Patterns + +#### Clean Architecture + +**What is it?** +- Separation of concerns into layers: Presentation → Domain → Data +- Dependency rule: Inner layers know nothing about outer layers +- Domain layer is framework-independent (pure Kotlin) + +**Layers in Tasky:** + +``` +┌─────────────────────────────────────┐ +│ Presentation Layer │ +│ (ViewModels, UI States, Screens) │ +│ Depends on: Domain │ +└──────────────┬──────────────────────┘ + │ +┌──────────────▼──────────────────────┐ +│ Domain Layer │ +│ (Repositories, Use Cases, Models) │ +│ Pure Kotlin - No Android deps │ +└──────────────┬──────────────────────┘ + │ +┌──────────────▼──────────────────────┐ +│ Data Layer │ +│ (Repository Impl, API, DTOs) │ +│ Depends on: Domain │ +└─────────────────────────────────────┘ +``` + +**Benefits:** +- Testability: Domain logic can be tested without Android framework +- Flexibility: Easy to swap data sources (API → Database) +- Maintainability: Clear separation of responsibilities + +**Interview Questions:** +- *"Why use Clean Architecture instead of MVVM alone?"* + - MVVM is presentation pattern, Clean Architecture is full-stack architecture + - Clean Architecture adds domain layer for business logic isolation + - Better for complex apps with multiple data sources + +- *"What's the difference between domain models and DTOs?"* + - DTOs (Data Transfer Objects): Match API/database structure, may have extra/missing fields + - Domain models: App's business representation, optimized for use cases + - Example: `AuthResponse` (DTO) → `SessionData` (domain model) + +#### Multi-Module Architecture + +**Structure in Tasky:** +``` +:app # Application entry point, DI setup +:features:auth # Authentication feature +:features:agenda # Agenda feature +:core:domain:util # Shared domain utilities +:core:data # Shared data layer +``` + +**Benefits:** +- Build time: Parallel builds, incremental compilation +- Separation: Clear feature boundaries +- Reusability: Core modules shared across features +- Team scalability: Teams can own specific modules + +**Interview Questions:** +- *"When should you split into multiple modules?"* + - Feature grows beyond ~20 files + - Multiple developers working on same codebase + - Want to enforce separation (e.g., prevent UI from accessing DB directly) + - Build times exceed 30-60 seconds + +#### MVI (Model-View-Intent) + +**Pattern in Tasky:** +```kotlin +// State (Model) +data class LoginUiState( + val email: String = "", + val password: String = "", + val isLoading: Boolean = false, + val error: AuthError? = null +) + +// Intent (User Actions) +sealed interface LoginAction { + data class EmailChanged(val email: String) : LoginAction + data class PasswordChanged(val password: String) : LoginAction + data object LoginClicked : LoginAction +} + +// ViewModel +class LoginViewModel : ViewModel() { + var state by mutableStateOf(LoginUiState()) + private set + + fun onAction(action: LoginAction) { + when (action) { + is LoginAction.EmailChanged -> state = state.copy(email = action.email) + // ... + } + } +} +``` + +**Benefits:** +- Unidirectional data flow (predictable) +- Single source of truth (state) +- Immutable state (easier debugging) +- All actions explicit (easy to log/test) + +**Interview Questions:** +- *"MVI vs MVVM?"* + - MVVM: Multiple LiveData/StateFlow, bidirectional binding + - MVI: Single state object, unidirectional flow + - MVI better for complex state interactions + +--- + +### Dependency Injection with Hilt + +#### Core Concepts + +**What is DI?** +- Pattern where objects receive dependencies instead of creating them +- Promotes loose coupling, testability + +**Why Hilt over manual DI?** +- Compile-time verification +- Android lifecycle awareness +- Less boilerplate than Dagger +- Official Google recommendation + +**Hilt Setup in Tasky:** + +```kotlin +// 1. Application class +@HiltAndroidApp +class TaskyApplication : Application() + +// 2. Activity +@AndroidEntryPoint +class MainActivity : ComponentActivity() + +// 3. ViewModel +@HiltViewModel +class LoginViewModel @Inject constructor( + private val repository: AuthRepository +) : ViewModel() + +// 4. Module +@Module +@InstallIn(SingletonComponent::class) +object AuthModule { + @Provides + @Singleton + fun provideAuthRepository( + api: AuthApi, + tokenManager: TokenManager + ): AuthRepository = DefaultAuthRepository(api, tokenManager) +} +``` + +**Scopes:** +- `@Singleton`: Lives for app lifetime +- `@ActivityScoped`: Lives for Activity lifetime +- `@ViewModelScoped`: Lives for ViewModel lifetime + +**Interview Questions:** +- *"How does Hilt know which implementation to inject for an interface?"* + - `@Binds` or `@Provides` in modules tell Hilt the binding + - Example: `AuthRepository` interface → `DefaultAuthRepository` impl + +- *"What happens if you forget @AndroidEntryPoint?"* + - Compile error: "Hilt ViewModels must be used in a Hilt-enabled component" + - Activity/Fragment must be annotated for injection to work + +- *"How would you provide different implementations for debug/release?"* + ```kotlin + @Provides + fun provideAuthRepository( + @ApplicationContext context: Context + ): AuthRepository { + return if (BuildConfig.DEBUG) { + FakeAuthRepository() + } else { + DefaultAuthRepository(/* ... */) + } + } + ``` + +--- + +### Jetpack Compose + +#### Fundamentals + +**Composable Functions:** +```kotlin +@Composable +fun LoginScreen( + state: LoginUiState, + onAction: (LoginAction) -> Unit +) { + Column { + TextField( + value = state.email, + onValueChange = { onAction(LoginAction.EmailChanged(it)) } + ) + Button(onClick = { onAction(LoginAction.LoginClicked) }) { + Text("Login") + } + } +} +``` + +**Key Concepts:** +- **Recomposition**: Function re-executes when state changes +- **State hoisting**: State lives in ViewModel, not in Composable +- **Side effects**: Use `LaunchedEffect`, `DisposableEffect`, etc. + +**Compose State:** +```kotlin +// ViewModel +var state by mutableStateOf(LoginUiState()) + +// Composable +val state by viewModel.state.collectAsState() +``` + +**Interview Questions:** +- *"What's the difference between `remember` and `rememberSaveable`?"* + - `remember`: Survives recomposition, NOT configuration changes + - `rememberSaveable`: Survives both recomposition AND config changes (rotation) + +- *"When should you use `LaunchedEffect` vs `SideEffect`?"* + - `LaunchedEffect`: Suspend operations (API calls, delays) + - `SideEffect`: Non-suspend side effects (analytics logging) + - `DisposableEffect`: Cleanup needed (register/unregister listener) + +- *"How do you optimize Compose performance?"* + - Use `key()` in lists to help Compose track items + - Avoid lambda allocations with `remember { {} }` + - Use `derivedStateOf` for computed state + - Stable parameters (immutable data classes) + +**Compose vs Views:** + +| Aspect | Views (XML) | Compose | +|--------|-------------|---------| +| Paradigm | Imperative | Declarative | +| Code location | XML + Kotlin | Kotlin only | +| Preview | Requires build | Instant @Preview | +| State management | Manual findViewById | Automatic recomposition | +| Performance | Faster initial | Faster updates | + +--- + +### Networking & Serialization + +#### Retrofit + OkHttp + +**Setup in Tasky:** +```kotlin +@Module +@InstallIn(SingletonComponent::class) +object CoreDataModule { + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor(ApiKeyInterceptor()) + .addInterceptor(AuthTokenInterceptor()) + .addInterceptor(HttpLoggingInterceptor()) + .build() + } + + @Provides + @Singleton + fun provideRetrofit(client: OkHttpClient): Retrofit { + return Retrofit.Builder() + .baseUrl(BuildConfig.BASE_URL) + .client(client) + .addConverterFactory(Json.asConverterFactory("application/json".toMediaType())) + .build() + } +} +``` + +**API Interface:** +```kotlin +interface AuthApi { + @POST("/login") + suspend fun login(@Body request: LoginRequest): AuthResponse + + @POST("/register") + suspend fun register(@Body request: RegisterRequest) + + @GET("/logout") + suspend fun logout() +} +``` + +**Interview Questions:** +- *"What's an interceptor? Give examples."* + - Code that runs before/after HTTP requests + - Examples: Adding headers, logging, authentication, error handling + - Chain of responsibility pattern + +- *"How do you handle 401 Unauthorized responses?"* + ```kotlin + class AuthTokenInterceptor @Inject constructor( + private val tokenManager: TokenManager + ) : Interceptor { + override fun intercept(chain: Chain): Response { + val request = chain.request().newBuilder() + .addHeader("Authorization", "Bearer ${tokenManager.getToken()}") + .build() + + val response = chain.proceed(request) + + if (response.code == 401) { + // Token expired - refresh or logout + tokenManager.clearSession() + } + + return response + } + } + ``` + +- *"Retrofit vs Ktor?"* + - Retrofit: Java-based, mature ecosystem, annotation-based + - Ktor: Kotlin-first, coroutines-native, more flexible + - Retrofit better for REST APIs, Ktor better for custom protocols + +#### Kotlinx Serialization + +**Why over Gson/Moshi?** +- Kotlin-first (better type safety) +- Compile-time code generation (faster) +- Multiplatform support (KMP) +- Better null handling + +**Usage:** +```kotlin +@Serializable +data class AuthResponse( + val accessToken: String, + val refreshToken: String, + val userId: String, + val fullName: String, + val accessTokenExpirationTimestamp: Long +) + +// Custom names +@Serializable +data class User( + @SerialName("user_id") val userId: String +) + +// Enums +@Serializable +enum class AgendaItemType { + @SerialName("EVENT") EVENT, + @SerialName("TASK") TASK, + @SerialName("REMINDER") REMINDER +} +``` + +--- + +### Coroutines & Flow + +#### Coroutines Basics + +**What are coroutines?** +- Lightweight threads for async programming +- Suspend functions: Can pause without blocking thread +- Structured concurrency: Parent-child relationship + +**Scopes in Android:** +```kotlin +// ViewModel +viewModelScope.launch { + // Cancelled when ViewModel cleared +} + +// Lifecycle-aware +lifecycleScope.launch { + // Cancelled when lifecycle destroyed +} + +// Application-wide +GlobalScope.launch { + // ⚠️ Avoid - never cancelled +} +``` + +**Dispatchers:** +- `Dispatchers.Main`: UI thread +- `Dispatchers.IO`: Network/disk operations (64 threads) +- `Dispatchers.Default`: CPU-intensive work (cores count) +- `Dispatchers.Unconfined`: Don't use (testing only) + +**Example from Tasky:** +```kotlin +fun login(email: String, password: String) { + viewModelScope.launch { + state = state.copy(isLoading = true) + + repository.login(email, password) + .onSuccess { + // Navigate to next screen + } + .onError { error -> + state = state.copy(error = error) + } + + state = state.copy(isLoading = false) + } +} +``` + +#### Flow + +**What is Flow?** +- Asynchronous stream of values +- Cold stream (emits only when collected) +- Reactive programming for Kotlin + +**StateFlow vs SharedFlow:** +```kotlin +// StateFlow - Always has value, replays last value +val isAuthenticated: StateFlow + +// SharedFlow - May have no value, configurable replay +val events: SharedFlow +``` + +**Usage in Tasky:** +```kotlin +// TokenManager +fun isAuthenticated(): Flow = dataStore.data + .map { prefs -> + val encryptedToken = prefs[TOKEN_KEY] + encryptedToken != null && isTokenValid() + } + +// Collecting in ViewModel +init { + viewModelScope.launch { + tokenManager.isAuthenticated() + .collect { isAuth -> + state = state.copy(isAuthenticated = isAuth) + } + } +} +``` + +**Interview Questions:** +- *"launch vs async?"* + - `launch`: Fire and forget, returns Job + - `async`: Returns Deferred, call .await() to get result + - Use `async` when you need return value + +- *"What's structured concurrency?"* + - Parent coroutine waits for children to complete + - If parent cancelled, all children cancelled + - Prevents coroutine leaks + +- *"Flow vs LiveData?"* + - Flow: Kotlin-first, more operators, cold stream, multiplatform + - LiveData: Android-specific, lifecycle-aware, hot stream + - Flow is newer, more powerful + +--- + +### Security & Encryption + +#### Token Storage in Tasky + +**Architecture:** +``` +TokenManager (interface) + ↓ +DataStoreTokenStorage (implementation) + ↓ uses +CryptoManager (AES-256-GCM encryption) + ↓ uses +Android Keystore (hardware-backed keys) + ↓ stores in +DataStore Preferences (encrypted data) +``` + +**Encryption Implementation:** +```kotlin +class CryptoManager { + private val keyStore = KeyStore.getInstance("AndroidKeyStore") + + fun encrypt(data: ByteArray): EncryptedData { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val key = getOrCreateKey() + + cipher.init(Cipher.ENCRYPT_MODE, key) + val iv = cipher.iv // Random 12-byte IV + val encryptedBytes = cipher.doFinal(data) + + return EncryptedData( + ciphertext = encryptedBytes.toBase64(), + iv = iv.toBase64() + ) + } + + fun decrypt(encryptedData: EncryptedData): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val key = getOrCreateKey() + + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, encryptedData.iv)) + return cipher.doFinal(encryptedData.ciphertext) + } +} +``` + +**Security Features:** +- **AES-256-GCM**: Authenticated encryption (confidentiality + integrity) +- **Android Keystore**: Hardware-backed keys (never exposed to app) +- **Random IV**: Different IV per encryption (prevents pattern analysis) +- **GCM Tag**: Detects tampering (authentication tag) + +**Interview Questions:** +- *"Why not just use EncryptedSharedPreferences?"* + - Tasky uses DataStore for modern coroutine-based API + - Manual encryption gives more control + - EncryptedSharedPreferences is fine for simpler apps + +- *"What's the difference between encryption and hashing?"* + - Encryption: Reversible (encrypt → decrypt) + - Hashing: One-way (password → hash, can't reverse) + - Use encryption for tokens, hashing for passwords + +- *"How do you protect against man-in-the-middle attacks?"* + - HTTPS/TLS: Encrypts network traffic + - Certificate pinning: Validate server certificate + - Don't trust user-installed certificates on device + +**Token Expiration:** +```kotlin +fun isTokenValid(): Boolean { + val expirationTime = tokenManager.getExpirationTimestamp() + val currentTime = System.currentTimeMillis() + val bufferTime = 5.minutes.inWholeMilliseconds + + return expirationTime - currentTime > bufferTime +} +``` + +--- + +### Navigation + +#### Type-Safe Navigation with Navigation Compose + +**Routes in Tasky:** +```kotlin +// Navigation graphs +@Serializable +data object AuthGraph + +@Serializable +data object AgendaGraph + +// Individual routes +sealed interface AuthRoute { + @Serializable + data object Login : AuthRoute + + @Serializable + data object Register : AuthRoute +} + +sealed interface AgendaRoute { + @Serializable + data object Agenda : AgendaRoute + + @Serializable + data class EventDetail(val eventId: String) : AgendaRoute +} +``` + +**NavHost Implementation:** +```kotlin +@Composable +fun TaskyNavHost(isAuthenticated: Boolean) { + val navController = rememberNavController() + + NavHost( + navController = navController, + startDestination = if (isAuthenticated) AgendaGraph else AuthGraph + ) { + navigation(startDestination = AuthRoute.Login) { + composable { + LoginScreen( + onNavigateToRegister = { navController.navigate(AuthRoute.Register) }, + onLoginSuccess = { + navController.navigate(AgendaGraph) { + popUpTo(AuthGraph) { inclusive = true } + } + } + ) + } + + composable { + RegisterScreen( + onNavigateToLogin = { navController.navigateUp() } + ) + } + } + + navigation(startDestination = AgendaRoute.Agenda) { + composable { + AgendaScreen( + onEventClick = { eventId -> + navController.navigate(AgendaRoute.EventDetail(eventId)) + } + ) + } + } + } +} +``` + +**Benefits:** +- Compile-time safety (no string routes) +- Type-safe arguments +- Refactoring-friendly (rename class → routes update automatically) + +**Interview Questions:** +- *"How do you pass complex objects between screens?"* + - Don't! Pass only IDs, fetch data in destination screen + - Reason: Reduces coupling, prevents stale data + - SavedStateHandle limited to primitives + parcelables + +- *"What's the difference between popUpTo and popBackStack?"* + - `popUpTo`: Remove destinations up to a specific route + - `popBackStack()`: Remove top destination + - `inclusive = true`: Also remove the destination you popped up to + +--- + +### Testing + +#### Unit Testing in Tasky + +**Test Structure:** +```kotlin +@Test +fun `login with valid credentials saves session`() = runTest { + // Given (Arrange) + val email = "test@example.com" + val password = "password123" + val expectedResponse = AuthResponse(/* ... */) + coEvery { api.login(any()) } returns expectedResponse + + // When (Act) + val result = repository.login(email, password) + + // Then (Assert) + assertThat(result).isInstanceOf>() + coVerify { tokenManager.saveSession(expectedResponse.toSessionData()) } +} +``` + +**MockK Usage:** +```kotlin +// Mock +val api: AuthApi = mockk() + +// Stub +coEvery { api.login(any()) } returns AuthResponse(/* ... */) + +// Verify +coVerify { tokenManager.saveSession(any()) } +coVerify(exactly = 1) { api.login(any()) } + +// Relaxed mock (returns default values) +val relaxedMock: AuthApi = mockk(relaxed = true) +``` + +**Fake vs Mock:** + +**Mock (LoginViewModelTest):** +```kotlin +class LoginViewModelTest { + private val repository: AuthRepository = mockk() + + @Test + fun `test login success`() { + coEvery { repository.login(any(), any()) } returns Result.Success(Unit) + // ... + } +} +``` + +**Fake (RegisterViewModelTest):** +```kotlin +class FakeAuthRepository : AuthRepository { + var shouldReturnError = false + + override suspend fun register( + fullName: String, + email: String, + password: String + ): Result { + return if (shouldReturnError) { + Result.Error(AuthError.NetworkError) + } else { + Result.Success(Unit) + } + } +} +``` + +**When to use which?** +- **Mocks**: Test specific interactions, verify calls +- **Fakes**: Test behavior, more realistic, reusable across tests + +**Coroutine Testing:** +```kotlin +@Test +fun `test coroutine delay`() = runTest { + // Virtual time - instant execution + delay(1000) // Doesn't actually wait 1 second + + // Assert + assertThat(result).isEqualTo(expected) +} +``` + +**Interview Questions:** +- *"What's the difference between unit, integration, and UI tests?"* + - Unit: Single class in isolation (ViewModel) + - Integration: Multiple classes together (Repository + API) + - UI: User interactions (click button → verify navigation) + +- *"How do you test ViewModels?"* + - Mock/fake dependencies (repository, use cases) + - Test state changes + - Test actions dispatch correct events + - Use `runTest` for coroutines + +- *"What is TDD (Test-Driven Development)?"* + - Write test first (fails) + - Write minimal code to pass + - Refactor + - Benefits: Better design, 100% coverage, living documentation + +--- + +## 2. Android Fundamentals + +### Activity Lifecycle + +``` +onCreate() → onStart() → onResume() → [RUNNING] + ↓ + onPause() + ↓ + onStop() + ↓ + onDestroy() +``` + +**Key Methods:** +- `onCreate()`: Initialize UI, set content view +- `onStart()`: Visible to user +- `onResume()`: Foreground, interactive +- `onPause()`: Losing focus (dialog appears) +- `onStop()`: No longer visible (another activity on top) +- `onDestroy()`: Activity finishing + +**Configuration Changes:** +- Rotation, language change → Activity recreated +- Data lost unless saved in `onSaveInstanceState()` +- ViewModels survive configuration changes + +**Interview Questions:** +- *"What happens if you start async work in onCreate() and user rotates?"* + - Activity destroyed and recreated + - Async work continues, may leak memory + - Solution: Use ViewModel + viewModelScope + +### Fragment Lifecycle + +``` +onAttach() → onCreate() → onCreateView() → onViewCreated() + ↓ +onStart() → onResume() → [RUNNING] + ↓ +onPause() → onStop() → onDestroyView() → onDestroy() → onDetach() +``` + +**Fragment vs Activity:** +- Fragments: Modular UI components, reusable +- Activities: Entry points, host fragments +- Modern approach: Single-Activity architecture with fragments/Compose + +### Intents + +**Explicit Intent:** +```kotlin +val intent = Intent(this, DetailActivity::class.java) +intent.putExtra("USER_ID", userId) +startActivity(intent) +``` + +**Implicit Intent:** +```kotlin +val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://google.com")) +startActivity(intent) +``` + +**PendingIntent:** +- Used for notifications, alarms +- Intent wrapper for delayed execution +- Flags: `FLAG_IMMUTABLE`, `FLAG_UPDATE_CURRENT` + +### Services + +**Types:** +- **Foreground Service**: Visible notification (music player) +- **Background Service**: No UI (deprecated for long-running tasks) +- **Bound Service**: Client-server interface + +**Modern Alternatives:** +- WorkManager for background tasks +- Foreground services for user-aware tasks + +### Broadcast Receivers + +**Register:** +```kotlin +// Manifest + + + + + + +// Dynamic +registerReceiver(receiver, IntentFilter("MY_ACTION")) +``` + +**Use Cases:** +- System events (boot completed, battery low) +- App-to-app communication +- LocalBroadcastManager for internal events (deprecated - use LiveData/Flow) + +### Content Providers + +**Purpose:** +- Share data between apps +- Standard interface for CRUD operations + +**Examples:** +- Contacts, Calendar, Media Store + +**Modern Alternative:** +- Direct database access with Room (within app) +- Expose APIs for cross-app sharing + +### Permissions + +**Types:** +- **Normal**: Auto-granted (INTERNET, ACCESS_NETWORK_STATE) +- **Dangerous**: User approval (CAMERA, LOCATION, READ_CONTACTS) + +**Request Runtime Permission:** +```kotlin +val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() +) { isGranted -> + if (isGranted) { + // Permission granted + } +} + +Button(onClick = { launcher.launch(android.Manifest.permission.CAMERA) }) { + Text("Request Camera") +} +``` + +--- + +## 3. Kotlin Language Fundamentals + +### Null Safety + +```kotlin +var nullable: String? = null +var nonNull: String = "Hello" + +// Safe call +val length = nullable?.length + +// Elvis operator +val length = nullable?.length ?: 0 + +// Not-null assertion (avoid!) +val length = nullable!!.length // Throws if null + +// Safe cast +val str: String? = obj as? String +``` + +### Data Classes + +```kotlin +data class User(val id: String, val name: String) + +// Auto-generated: +// - equals() / hashCode() +// - toString() +// - copy() +// - componentN() for destructuring + +val user = User("1", "John") +val updated = user.copy(name = "Jane") +val (id, name) = user // Destructuring +``` + +### Sealed Classes + +```kotlin +sealed interface Result { + data class Success(val data: T) : Result + data class Error(val error: String) : Result +} + +// Exhaustive when +when (result) { + is Result.Success -> println(result.data) + is Result.Error -> println(result.error) + // No else needed - compiler knows all cases +} +``` + +### Extension Functions + +```kotlin +fun String.isValidEmail(): Boolean { + return android.util.Patterns.EMAIL_ADDRESS.matcher(this).matches() +} + +// Usage +val email = "test@example.com" +if (email.isValidEmail()) { /* ... */ } +``` + +### Higher-Order Functions + +```kotlin +// Function as parameter +fun List.customFilter(predicate: (T) -> Boolean): List { + val result = mutableListOf() + for (item in this) { + if (predicate(item)) result.add(item) + } + return result +} + +// Usage +val numbers = listOf(1, 2, 3, 4, 5) +val evens = numbers.customFilter { it % 2 == 0 } +``` + +### Scope Functions + +```kotlin +// let - transform object +val length = str?.let { it.length } + +// run - execute block, return result +val result = run { + val x = 5 + val y = 10 + x + y +} + +// apply - configure object, return object +val user = User().apply { + name = "John" + age = 30 +} + +// also - side effects, return object +val list = mutableListOf().also { + println("Creating list") +} + +// with - group operations +with(user) { + println(name) + println(age) +} +``` + +### Collections + +```kotlin +// List (immutable) +val list = listOf(1, 2, 3) + +// MutableList +val mutable = mutableListOf(1, 2, 3) +mutable.add(4) + +// Map +val map = mapOf("key" to "value") + +// Common operations +list.filter { it > 2 } +list.map { it * 2 } +list.forEach { println(it) } +list.groupBy { it % 2 } +list.sortedBy { it } +``` + +--- + +## 4. Common Interview Questions + +### Memory & Performance + +**Q: What causes memory leaks in Android?** + +A: Common causes: +1. **Static references to Context/Activity** + ```kotlin + // ❌ BAD + companion object { + var activity: Activity? = null // Leaks activity + } + + // ✅ GOOD + companion object { + var appContext: Context? = null // Application context OK + } + ``` + +2. **Inner classes holding references** + ```kotlin + // ❌ BAD - Inner class holds Activity reference + inner class MyRunnable : Runnable { + override fun run() { /* ... */ } + } + + // ✅ GOOD - Static class with weak reference + class MyRunnable(activity: Activity) : Runnable { + private val weakRef = WeakReference(activity) + override fun run() { + weakRef.get()?.let { /* ... */ } + } + } + ``` + +3. **Listeners not unregistered** +4. **Coroutines/RxJava not cancelled** + +**Q: How do you detect memory leaks?** + +A: Tools: +- **LeakCanary**: Auto-detects leaks in debug builds +- **Android Profiler**: Monitor memory usage +- **Heap Dump**: Analyze with MAT (Memory Analyzer Tool) + +**Q: What is `onTrimMemory()`?** + +A: Callback when system is low on memory. You should release caches: +```kotlin +override fun onTrimMemory(level: Int) { + when (level) { + TRIM_MEMORY_RUNNING_LOW -> imageCache.clear() + TRIM_MEMORY_UI_HIDDEN -> releaseUI() + } +} +``` + +### Data Persistence + +**Q: Compare SharedPreferences, DataStore, Room, and Files.** + +| Storage | Use Case | Max Size | Type Safety | +|---------|----------|----------|-------------| +| SharedPreferences | Key-value pairs | ~1 MB | No | +| DataStore | Key-value pairs | ~1 MB | Yes (Typed) | +| Room | Structured data, queries | Unlimited | Yes | +| Files | Large blobs (images) | Unlimited | No | + +**Q: When would you use each?** + +- **SharedPreferences/DataStore**: Settings, flags, tokens +- **Room**: User data, cached API responses, complex queries +- **Files**: Images, videos, documents + +**Q: How does Room differ from SQLite?** + +- Room is abstraction over SQLite +- Compile-time SQL verification +- LiveData/Flow integration +- Less boilerplate + +### Threading + +**Q: What's the difference between Thread, Handler, AsyncTask, and Coroutines?** + +**Thread:** +```kotlin +Thread { + // Background work + runOnUiThread { + // Update UI + } +}.start() +``` + +**Handler + Looper:** +```kotlin +val handler = Handler(Looper.getMainLooper()) +handler.post { + // Runs on main thread +} +``` + +**AsyncTask (DEPRECATED):** +```kotlin +class MyTask : AsyncTask() { + override fun doInBackground(vararg params: Void?): String { } + override fun onPostExecute(result: String?) { } +} +``` + +**Coroutines (MODERN):** +```kotlin +viewModelScope.launch { + val result = withContext(Dispatchers.IO) { + // Background work + } + // Update UI (automatically on Main) +} +``` + +**Q: What is the main thread / UI thread?** + +- Thread that handles UI updates +- Only this thread can touch Views +- Long operations block UI (ANR - Application Not Responding) +- Use background threads for network/database + +### RecyclerView + +**Q: How does RecyclerView work?** + +1. **LayoutManager**: Arranges items (Linear, Grid, Staggered) +2. **Adapter**: Binds data to ViewHolders +3. **ViewHolder**: Holds view references (avoids findViewById) +4. **ItemDecoration**: Dividers, spacing +5. **ItemAnimator**: Add/remove animations + +**Q: What is the ViewHolder pattern?** + +```kotlin +class MyAdapter : RecyclerView.Adapter() { + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val textView: TextView = view.findViewById(R.id.text) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + // Inflate layout (called once per view type) + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + // Bind data (called on scroll) + holder.textView.text = items[position] + } +} +``` + +**Q: How do you optimize RecyclerView?** + +- **ViewHolder pattern**: Cache view references +- **setHasFixedSize(true)**: If size doesn't change +- **DiffUtil**: Smart updates (only changed items) +- **Pagination**: Load data in chunks +- **RecycledViewPool**: Share ViewHolders across RecyclerViews + +### LazyColumn (Compose Alternative) + +```kotlin +LazyColumn { + items(users) { user -> + UserItem(user) + } + + // Or with key for better recomposition + items(users, key = { it.id }) { user -> + UserItem(user) + } +} +``` + +--- + +## 5. System Design Questions + +### Design an Instagram-like feed + +**Requirements:** +- Display posts (image + caption) +- Infinite scroll +- Like/comment +- Pull-to-refresh + +**Architecture:** + +``` +┌─────────────────────────────────────┐ +│ Presentation │ +│ FeedScreen → FeedViewModel │ +└──────────────┬──────────────────────┘ + │ +┌──────────────▼──────────────────────┐ +│ Domain │ +│ GetFeedUseCase, LikePostUseCase │ +└──────────────┬──────────────────────┘ + │ +┌──────────────▼──────────────────────┐ +│ Data │ +│ FeedRepository → API + Database │ +└─────────────────────────────────────┘ +``` + +**Key Decisions:** + +1. **Pagination:** + - Paging 3 library + - Load 20 posts per page + - Cache in Room database + +2. **Images:** + - Coil/Glide for loading + - Cache on disk + - Placeholder while loading + +3. **Offline:** + - Room database as source of truth + - Sync when network available + - WorkManager for background sync + +4. **Real-time updates:** + - WebSocket for new posts + - Or polling every 30 seconds + +**Code Sketch:** + +```kotlin +@Pager( + config = PagingConfig(pageSize = 20), + pagingSourceFactory = { FeedPagingSource(api, database) } +) +val feedPager: Flow> + +@Composable +fun FeedScreen(viewModel: FeedViewModel) { + val posts = viewModel.feedPager.collectAsLazyPagingItems() + + LazyColumn { + items(posts) { post -> + PostItem(post) + } + } +} +``` + +### Design offline-first note-taking app + +**Requirements:** +- Create/edit/delete notes +- Works offline +- Sync when online +- Conflict resolution + +**Architecture:** + +``` +Single Source of Truth: Room Database + ↓ +Repository watches database + ↓ +API syncs in background (WorkManager) + ↓ +Conflicts: Last-write-wins OR server timestamp +``` + +**Sync Strategy:** + +```kotlin +class SyncWorker : CoroutineWorker() { + override suspend fun doWork(): Result { + // 1. Upload local changes + val localChanges = database.getUnsynced() + localChanges.forEach { note -> + try { + api.updateNote(note) + database.markSynced(note.id) + } catch (e: Exception) { + // Retry later + } + } + + // 2. Download server changes + val serverNotes = api.getNotesSince(lastSyncTime) + database.insertAll(serverNotes) + + return Result.success() + } +} +``` + +--- + +## 6. Behavioral Questions + +### STAR Method + +**Situation → Task → Action → Result** + +Example: + +**Q: Tell me about a time you optimized app performance.** + +**S:** Our app had slow startup time (5+ seconds), users complained. + +**T:** Reduce startup time to under 2 seconds. + +**A:** +1. Profiled with Android Profiler, found heavy initialization on main thread +2. Moved network calls to background using coroutines +3. Lazy-loaded non-critical dependencies +4. Used baseline profiles for ART optimization + +**R:** Startup time reduced to 1.2 seconds (76% improvement), Play Store rating increased from 3.8 to 4.3. + +### Common Questions + +**Q: How do you stay up-to-date with Android development?** + +A: +- Official Android Blog & Now in Android newsletter +- Android Dev Summit & Google I/O +- Twitter: @AndroidDev, @JakeWharton, @chrisbanes +- Reddit: r/androiddev +- Blogs: ProAndroidDev, Medium + +**Q: Describe a challenging bug you solved.** + +A: (Use STAR method, focus on debugging process) + +**Q: How do you handle code reviews?** + +A: +- Constructive feedback, not criticism +- Ask questions: "Why did you choose this approach?" +- Suggest alternatives, don't demand changes +- Praise good code +- Focus on: correctness, performance, readability, security + +**Q: What's your development process?** + +A: +1. Understand requirements +2. Design architecture (Clean Architecture + MVI) +3. Write failing tests (TDD) +4. Implement feature +5. Refactor +6. Code review +7. Merge to main + +--- + +## Key Takeaways + +### What Makes a Strong Android Developer? + +1. **Solid fundamentals**: Lifecycle, threading, memory management +2. **Modern tools**: Compose, Coroutines, Flow, Hilt +3. **Architecture knowledge**: Clean Architecture, MVVM/MVI +4. **Testing**: Unit tests, UI tests, integration tests +5. **Performance**: Memory leaks, rendering, battery +6. **Security**: Encryption, secure storage, HTTPS +7. **Soft skills**: Communication, code review, debugging + +### Red Flags in Interviews + +❌ "I don't write tests, QA handles that" +❌ "I use GlobalScope for all coroutines" +❌ "I store passwords in SharedPreferences" +❌ "I haven't learned Compose, XML is fine" +❌ "I don't know why it works, I copied from StackOverflow" + +### Green Flags + +✅ Explains trade-offs (e.g., Retrofit vs Ktor) +✅ Asks clarifying questions before jumping to code +✅ Discusses testing strategy +✅ Mentions recent Android changes (Compose, Material 3, etc.) +✅ Talks about performance/security proactively + +--- + +## Resources + +### Official Documentation +- [Android Developers](https://developer.android.com) +- [Kotlin Docs](https://kotlinlang.org/docs) +- [Jetpack Compose](https://developer.android.com/jetpack/compose) + +### Courses +- [Android Basics with Compose](https://developer.android.com/courses/android-basics-compose/course) +- [Advanced Android Development](https://developer.android.com/courses/advanced-training/overview) + +### Books +- "Android Programming: The Big Nerd Ranch Guide" +- "Kotlin in Action" by Dmitry Jemerov +- "Clean Architecture" by Robert C. Martin + +### YouTube Channels +- Android Developers +- Philipp Lackner +- Coding in Flow + +### Practice +- LeetCode (Kotlin solutions) +- HackerRank +- Build side projects + +--- + +**Good luck with your interviews! 🚀** diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..5e8f89f --- /dev/null +++ b/TODO.md @@ -0,0 +1,9 @@ +# Project TODO List + +## Technical Debt & Refactoring + +- [ ] **Refactor Gradle Scripts with Convention Plugins** + - **Problem:** Significant duplication exists across module-level `build.gradle.kts` files (`plugins`, `android` config, `dependencies`). This makes maintenance difficult and error-prone. + - **Solution:** Create a `build-logic` module to house custom "convention plugins" (e.g., `tasky.android.feature`) that encapsulate all the shared build logic. + - **Goal:** Drastically simplify module build scripts to only declare what is unique to them. This improves maintainability and creates a single source of truth for dependencies and build configurations. + - **Affected Files:** `features/auth/build.gradle.kts`, `features/agenda/build.gradle.kts`, and eventually `app/build.gradle.kts`. diff --git a/app/.gitignore b/app/.gitignore index 0990d25..cd262ad 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -5,9 +5,8 @@ local.properties *.iml # Build files -build/ +/build */build/ - # Other captures/ .DS_Store \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c939ac0..00b3493 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -46,6 +46,7 @@ android { dependencies { implementation(projects.features.auth) + implementation(projects.features.agenda) implementation(projects.core.data) implementation(libs.androidx.core.ktx) diff --git a/app/src/androidTest/java/com/dmd/tasky/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/dmd/tasky/ExampleInstrumentedTest.kt deleted file mode 100644 index 579f7d3..0000000 --- a/app/src/androidTest/java/com/dmd/tasky/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.dmd.tasky - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.dmd.tasky", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/dmd/tasky/ui/navigation/TaskyNavHost.kt b/app/src/main/java/com/dmd/tasky/ui/navigation/TaskyNavHost.kt index dcbfeba..59335b2 100644 --- a/app/src/main/java/com/dmd/tasky/ui/navigation/TaskyNavHost.kt +++ b/app/src/main/java/com/dmd/tasky/ui/navigation/TaskyNavHost.kt @@ -7,7 +7,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController -import com.dmd.tasky.features.auth.presentation.agenda.AgendaScreen +import com.dmd.tasky.features.agenda.presentation.AgendaScreen import com.dmd.tasky.features.auth.presentation.login.TaskyLoginScreen import com.dmd.tasky.features.auth.presentation.register.TaskyRegisterScreen @@ -57,7 +57,14 @@ fun TaskyNavHost( ) { composable { AgendaScreen( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + onLogout = { + navController.navigate(AuthGraphRoutes.AuthGraph) { + popUpTo(AgendaGraphRoutes.AgendaGraph) { + inclusive = true + } + } + } ) } } diff --git a/app/src/main/java/com/dmd/tasky/ui/theme/Type.kt b/app/src/main/java/com/dmd/tasky/ui/theme/Type.kt index 0a000da..4201e8a 100644 --- a/app/src/main/java/com/dmd/tasky/ui/theme/Type.kt +++ b/app/src/main/java/com/dmd/tasky/ui/theme/Type.kt @@ -14,8 +14,8 @@ val Typography = Typography( fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp - ) - /* Other default text styles to override + ), + // Other default text styles to override titleLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, @@ -30,5 +30,4 @@ val Typography = Typography( lineHeight = 16.sp, letterSpacing = 0.5.sp ) - */ ) \ No newline at end of file diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 48e174e..3718d38 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -10,9 +10,7 @@ plugins { android { namespace = "com.dmd.tasky.core.data" - compileSdk { - version = release(36) - } + compileSdk = 36 defaultConfig { minSdk = 33 @@ -22,6 +20,14 @@ android { } buildTypes { + val localProperties = Properties() + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localPropertiesFile.inputStream().use { + localProperties.load(it) + } + } + getByName("debug") { buildConfigField("String", "API_KEY", project.ext["apiKey"] as String) buildConfigField("String", "BASE_URL", "\"${project.ext["baseUrl"]}\"") diff --git a/features/agenda/.gitignore b/features/agenda/.gitignore new file mode 100644 index 0000000..a99ca0e --- /dev/null +++ b/features/agenda/.gitignore @@ -0,0 +1,2 @@ +/build +*/build/ \ No newline at end of file diff --git a/features/agenda/build.gradle.kts b/features/agenda/build.gradle.kts index 11f63bf..642907f 100644 --- a/features/agenda/build.gradle.kts +++ b/features/agenda/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) @@ -19,6 +21,14 @@ android { } buildTypes { + val localProperties = Properties() + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localPropertiesFile.inputStream().use { + localProperties.load(it) + } + } + getByName("debug") { buildConfigField("String", "API_KEY", project.ext["apiKey"] as String) buildConfigField("String", "BASE_URL", "\"${project.ext["baseUrl"]}\"") @@ -63,6 +73,12 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + // Image loading + implementation(libs.coil.compose) + + // Permissions (for photo picker) + implementation(libs.accompanist.permissions) + // Hilt implementation(libs.hilt.android) ksp(libs.hilt.compiler) @@ -89,4 +105,4 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) debugImplementation(libs.androidx.compose.ui.tooling) -} \ No newline at end of file +} diff --git a/features/agenda/consumer-rules.pro b/features/agenda/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/AgendaApi.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/AgendaApi.kt new file mode 100644 index 0000000..74ff00e --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/AgendaApi.kt @@ -0,0 +1,81 @@ +package com.dmd.tasky.features.agenda.data.remote + +import com.dmd.tasky.features.agenda.data.remote.dto.* +import retrofit2.http.* + +interface AgendaApi { + // ========== AGENDA ========== + @GET("agenda") + suspend fun getAgenda(@Query("time") timestamp: Long): AgendaResponseDto + + @GET("fullAgenda") + suspend fun getFullAgenda(): AgendaResponseDto + + @POST("syncAgenda") + suspend fun syncAgenda(@Body request: SyncAgendaRequest) + + // ========== EVENTS ========== + @POST("event") + suspend fun createEvent(@Body request: CreateEventRequest): CreateEventResponse + + @PUT("event/{eventId}") + suspend fun updateEvent( + @Path("eventId") eventId: String, + @Body request: UpdateEventRequest + ): CreateEventResponse + + @POST("event/{eventId}/confirm-upload") + suspend fun confirmUpload( + @Path("eventId") eventId: String, + @Body request: ConfirmUploadRequest + ): CreateEventResponse + + + @GET("event/{eventId}") + suspend fun getEvent(@Path("eventId") eventId: String): EventDetailDto + + @DELETE("event") + suspend fun deleteEvent( + @Query("eventId") eventId: String, + @Query("deleteAt") deleteAt: String? = null + ) + + // ========== ATTENDEES ========== + @GET("attendee") + suspend fun checkAttendee(@Query("email") email: String): AttendeeCheckResponse + + @DELETE("attendee") + suspend fun removeAttendee(@Query("eventId") eventId: String) + + // ========== TASKS ========== + @POST("task") + suspend fun createTask(@Body request: CreateTaskRequest): TaskDto + + @PUT("task") + suspend fun updateTask(@Body request: UpdateTaskRequest): TaskDto + + @GET("task/{taskId}") + suspend fun getTask(@Path("taskId") taskId: String): TaskDto + + @DELETE("task/{taskId}") + suspend fun deleteTask( + @Path("taskId") taskId: String, + @Query("deletedAt") deletedAt: String? = null + ) + + // ========== REMINDERS ========== + @POST("reminder") + suspend fun createReminder(@Body request: CreateReminderRequest): ReminderDto + + @PUT("reminder") + suspend fun updateReminder(@Body request: UpdateReminderRequest): ReminderDto + + @GET("reminder/{reminderId}") + suspend fun getReminder(@Path("reminderId") reminderId: String): ReminderDto + + @DELETE("reminder/{reminderId}") + suspend fun deleteReminder( + @Path("reminderId") reminderId: String, + @Query("deletedAt") deletedAt: String? = null + ) +} \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/Mappers.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/Mappers.kt new file mode 100644 index 0000000..abe704a --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/Mappers.kt @@ -0,0 +1,122 @@ +package com.dmd.tasky.features.agenda.data.remote + +import com.dmd.tasky.features.agenda.data.remote.dto.* +import com.dmd.tasky.features.agenda.domain.model.AgendaItem +import com.dmd.tasky.features.agenda.domain.model.AgendaItemDetails +import com.dmd.tasky.features.agenda.domain.model.Attendee +import com.dmd.tasky.features.agenda.domain.model.Photo +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +/** + * Parses a timestamp string (epoch millis) to LocalDateTime. + * + * Why a helper function? + * - API returns timestamps as epoch milliseconds (String in DTOs) + * - Domain model uses LocalDateTime for type safety and easy formatting + * - Centralizes parsing logic in one place + */ +private fun String.toLocalDateTime(): LocalDateTime { + val epochMillis = this.toLongOrNull() ?: 0L + return LocalDateTime.ofInstant( + Instant.ofEpochMilli(epochMillis), + ZoneId.systemDefault() + ) +} + +// ========== EVENT MAPPINGS ========== + +/** + * Maps EventDetailDto (from GET /event/{id}) to domain AgendaItem. + * EventDetailDto has full attendee info, not just IDs. + */ +fun EventDetailDto.toAgendaItem(): AgendaItem = AgendaItem( + id = this.id, + title = this.title, + description = this.description, + time = this.from.toLocalDateTime(), // 'from' becomes common 'time' + remindAt = this.remindAt.toLocalDateTime(), + updatedAt = LocalDateTime.now(), // Not in DTO, use current time + details = AgendaItemDetails.Event( + to = this.to.toLocalDateTime(), + host = this.hostId, // Could fetch username if needed + isUserEventCreator = this.isUserEventCreator, + attendees = this.attendees.map { it.toAttendee() }, + photos = this.photoKeys.map { it.toPhoto() } + ) +) + +/** + * Maps EventDto (from GET /agenda list) to domain AgendaItem. + * EventDto only has attendee IDs, not full info. + */ +fun EventDto.toAgendaItem(): AgendaItem = AgendaItem( + id = this.id, + title = this.title, + description = this.description, + time = this.from.toLocalDateTime(), // 'from' becomes common 'time' + remindAt = this.remindAt.toLocalDateTime(), + updatedAt = this.updatedAt.toLocalDateTime(), + details = AgendaItemDetails.Event( + to = this.to.toLocalDateTime(), + host = "", // Not in list DTO + isUserEventCreator = false, // Not in list DTO, assume false + attendees = this.attendeeIds.map { attendeeId -> + // List endpoint only gives IDs, create partial Attendee + Attendee( + email = "", + username = "", + userId = attendeeId, + eventId = this.id, + isGoing = true, // Assume going if in list + remindAt = "" + ) + }, + photos = this.photoKeys.map { key -> + Photo(key = key, url = "") // URL not in list DTO + } + ) +) + +// ========== TASK MAPPINGS ========== + +fun TaskDto.toAgendaItem(): AgendaItem = AgendaItem( + id = this.id, + title = this.title, + description = this.description, + time = this.time.toLocalDateTime(), + remindAt = this.remindAt.toLocalDateTime(), + updatedAt = this.updatedAt.toLocalDateTime(), + details = AgendaItemDetails.Task( + isDone = this.isDone + ) +) + +// ========== REMINDER MAPPINGS ========== + +fun ReminderDto.toAgendaItem(): AgendaItem = AgendaItem( + id = this.id, + title = this.title, + description = this.description, + time = this.time.toLocalDateTime(), + remindAt = this.remindAt.toLocalDateTime(), + updatedAt = this.updatedAt.toLocalDateTime(), + details = AgendaItemDetails.Reminder // data object - no properties needed +) + +// ========== ATTENDEE & PHOTO MAPPINGS ========== + +fun AttendeeDto.toAttendee(): Attendee = Attendee( + email = this.email, + username = this.username, + userId = this.userId, + eventId = this.eventId, + isGoing = this.isGoing, + remindAt = this.remindAt +) + +fun PhotoDto.toPhoto(): Photo = Photo( + key = this.key, + url = this.url +) diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/AgendaResponseDto.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/AgendaResponseDto.kt new file mode 100644 index 0000000..c0f677e --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/AgendaResponseDto.kt @@ -0,0 +1,10 @@ +package com.dmd.tasky.features.agenda.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class AgendaResponseDto( + val events: List, + val tasks: List, + val reminders: List +) \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/AttendeeCheckResponse.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/AttendeeCheckResponse.kt new file mode 100644 index 0000000..75228df --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/AttendeeCheckResponse.kt @@ -0,0 +1,10 @@ +package com.dmd.tasky.features.agenda.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class AttendeeCheckResponse( + val email: String, + val fullName: String, + val userId: String +) \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/AttendeeDto.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/AttendeeDto.kt new file mode 100644 index 0000000..b2fb800 --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/AttendeeDto.kt @@ -0,0 +1,13 @@ +package com.dmd.tasky.features.agenda.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class AttendeeDto( + val email: String, + val username: String, + val userId: String, + val eventId: String, + val isGoing: Boolean, + val remindAt: String +) \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/ConfirmUploadRequest.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/ConfirmUploadRequest.kt new file mode 100644 index 0000000..dde258c --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/ConfirmUploadRequest.kt @@ -0,0 +1,8 @@ +package com.dmd.tasky.features.agenda.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ConfirmUploadRequest( + val uploadedKeys: List // Server-generated uploadKeys +) \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/CreateEventRequest.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/CreateEventRequest.kt new file mode 100644 index 0000000..f054f62 --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/CreateEventRequest.kt @@ -0,0 +1,16 @@ +package com.dmd.tasky.features.agenda.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class CreateEventRequest( + val id: String, + val title: String, + val description: String, + val from: String, // ISO 8601 + val to: String, + val remindAt: String, + val attendeeIds: List, + val photoKeys: List, // e.g., ["photo0", "photo1"] + val updatedAt: String +) diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/CreateEventResponse.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/CreateEventResponse.kt new file mode 100644 index 0000000..6480d66 --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/CreateEventResponse.kt @@ -0,0 +1,16 @@ +package com.dmd.tasky.features.agenda.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class CreateEventResponse( + val event: EventDetailDto, + val uploadUrls: List +) + +@Serializable +data class PhotoUploadUrl( + val photoKey: String, // Your original key (e.g., "photo0") + val uploadKey: String, // Server-generated UUID + val url: String // S3 upload URL +) \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/CreateReminderRequest.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/CreateReminderRequest.kt new file mode 100644 index 0000000..aa86879 --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/CreateReminderRequest.kt @@ -0,0 +1,13 @@ +package com.dmd.tasky.features.agenda.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class CreateReminderRequest( + val id: String, + val title: String, + val description: String, + val time: String, + val remindAt: String, + val updatedAt: String +) diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/CreateTaskRequest.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/CreateTaskRequest.kt new file mode 100644 index 0000000..bfdd1ac --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/CreateTaskRequest.kt @@ -0,0 +1,14 @@ +package com.dmd.tasky.features.agenda.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class CreateTaskRequest( + val id: String, + val title: String, + val description: String, + val time: String, + val remindAt: String, + val updatedAt: String, + val isDone: Boolean +) \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/EventDetailDto.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/EventDetailDto.kt new file mode 100644 index 0000000..bfa7b86 --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/EventDetailDto.kt @@ -0,0 +1,17 @@ +package com.dmd.tasky.features.agenda.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class EventDetailDto( + val id: String, + val title: String, + val description: String, + val from: String, + val to: String, + val remindAt: String, + val hostId: String, + val isUserEventCreator: Boolean, + val attendees: List, + val photoKeys: List +) \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/EventDto.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/EventDto.kt new file mode 100644 index 0000000..a5323ce --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/EventDto.kt @@ -0,0 +1,16 @@ +package com.dmd.tasky.features.agenda.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class EventDto( + val id: String, + val title: String, + val attendeeIds: List, + val description: String, + val from: String, + val to: String, + val photoKeys: List, + val remindAt: String, + val updatedAt: String, +) \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/PhotoDto.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/PhotoDto.kt new file mode 100644 index 0000000..cdb84aa --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/PhotoDto.kt @@ -0,0 +1,9 @@ +package com.dmd.tasky.features.agenda.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class PhotoDto( + val key: String, + val url: String // Download URL +) \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/ReminderDto.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/ReminderDto.kt new file mode 100644 index 0000000..b473ccd --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/ReminderDto.kt @@ -0,0 +1,13 @@ +package com.dmd.tasky.features.agenda.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ReminderDto( + val id: String, + val title: String, + val description: String, + val remindAt: String, + val updatedAt: String, + val time: String, +) \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/SyncAgendaRequest.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/SyncAgendaRequest.kt new file mode 100644 index 0000000..061ef47 --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/SyncAgendaRequest.kt @@ -0,0 +1,10 @@ +package com.dmd.tasky.features.agenda.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class SyncAgendaRequest( + val deletedEventIds: List, + val deletedTaskIds: List, + val deletedReminderIds: List +) \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/TaskDto.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/TaskDto.kt new file mode 100644 index 0000000..7ea748e --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/TaskDto.kt @@ -0,0 +1,14 @@ +package com.dmd.tasky.features.agenda.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class TaskDto( + val id: String, + val title: String, + val description: String, + val remindAt: String, + val updatedAt: String, + val time: String, + val isDone: Boolean, +) \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/UpdateEventRequest.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/UpdateEventRequest.kt new file mode 100644 index 0000000..74d1e4b --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/UpdateEventRequest.kt @@ -0,0 +1,17 @@ +package com.dmd.tasky.features.agenda.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateEventRequest( + val title: String, + val description: String, + val from: String, + val to: String, + val remindAt: String, + val attendeeIds: List, + val newPhotoKeys: List, // Photos to upload + val deletedPhotoKeys: List, // Photos to remove + val isGoing: Boolean, + val updatedAt: String +) \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/UpdateReminderRequest.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/UpdateReminderRequest.kt new file mode 100644 index 0000000..0ca1b02 --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/UpdateReminderRequest.kt @@ -0,0 +1,6 @@ +package com.dmd.tasky.features.agenda.data.remote.dto + +import kotlinx.serialization.Serializable + +// UpdateReminderRequest is identical to CreateReminderRequest for PUT /reminder +typealias UpdateReminderRequest = CreateReminderRequest \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/UpdateTaskRequest.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/UpdateTaskRequest.kt new file mode 100644 index 0000000..0386c0e --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/remote/dto/UpdateTaskRequest.kt @@ -0,0 +1,6 @@ +package com.dmd.tasky.features.agenda.data.remote.dto + +import kotlinx.serialization.Serializable + +// UpdateTaskRequest is identical to CreateTaskRequest for PUT /task +typealias UpdateTaskRequest = CreateTaskRequest \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/repository/DefaultAgendaRepository.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/repository/DefaultAgendaRepository.kt new file mode 100644 index 0000000..e093c2a --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/data/repository/DefaultAgendaRepository.kt @@ -0,0 +1,325 @@ +package com.dmd.tasky.features.agenda.data.repository + +import com.dmd.tasky.core.data.token.TokenManager +import com.dmd.tasky.core.domain.util.Result +import com.dmd.tasky.features.agenda.data.remote.AgendaApi +import com.dmd.tasky.features.agenda.data.remote.dto.* +import com.dmd.tasky.features.agenda.data.remote.toAgendaItem +import com.dmd.tasky.features.agenda.data.remote.toAttendee +import com.dmd.tasky.features.agenda.domain.model.AgendaItem +import com.dmd.tasky.features.agenda.domain.model.AgendaItemDetails +import com.dmd.tasky.features.agenda.domain.model.Attendee +import com.dmd.tasky.features.agenda.domain.repository.* +import kotlinx.coroutines.CancellationException +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import retrofit2.HttpException +import timber.log.Timber +import java.io.IOException +import java.net.SocketTimeoutException +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId + +class DefaultAgendaRepository( + private val api: AgendaApi, + private val tokenManager: TokenManager, + private val okHttpClient: OkHttpClient +) : AgendaRepository { + + // ========== HELPER METHODS ========== + + /** + * Converts LocalDateTime to epoch milliseconds String for API calls. + * Inverse of the toLocalDateTime() extension in Mappers.kt. + */ + private fun LocalDateTime.toEpochMillisString(): String { + return this.atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + .toString() + } + + /** + * Wraps API calls with consistent error handling. + * See earlier discussion on why this pattern is used. + */ + private suspend fun safeApiCall( + operation: String, + apiCall: suspend () -> T + ): Result { + return try { + Result.Success(apiCall()) + } catch (e: HttpException) { + val code = e.code() + Timber.e("HTTP Error during $operation: Code=$code") + when (code) { + 400 -> Result.Error(AgendaError.VALIDATION_ERROR) + 401 -> Result.Error(AgendaError.UNAUTHORIZED) + 403 -> Result.Error(AgendaError.FORBIDDEN) + 404 -> Result.Error(AgendaError.NOT_FOUND) + 409 -> Result.Error(AgendaError.CONFLICT) + in 500..599 -> Result.Error(AgendaError.SERVER_ERROR) + else -> Result.Error(AgendaError.UNKNOWN) + } + } catch (e: SocketTimeoutException) { + Timber.e("Timeout error during $operation") + Result.Error(AgendaError.TIMEOUT) + } catch (e: IOException) { + Timber.e("Network error during $operation") + Result.Error(AgendaError.NO_INTERNET) + } catch (e: Exception) { + Timber.e("Exception during $operation: ${e.message}") + if (e is CancellationException) throw e + Result.Error(AgendaError.UNKNOWN) + } + } + + private suspend fun uploadPhotoToS3( + url: String, + photoByteArray: ByteArray + ): Result { + return safeApiCall("uploadPhotoToS3") { + val requestBody = photoByteArray.toRequestBody("image/*".toMediaTypeOrNull()) + val request = Request.Builder() + .url(url) + .put(requestBody) + .build() + + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IOException("S3 upload failed with code: ${response.code}") + } + } + } + } + + // ========== AGENDA OPERATIONS ========== + + override suspend fun getAgendaForDate(date: LocalDate): AgendaResult { + val timestamp = date.atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + + val result = safeApiCall("getAgendaForDate") { + api.getAgenda(timestamp) + } + + return when (result) { + is Result.Success -> { + // Map all DTOs to unified AgendaItem + val events = result.data.events.map { it.toAgendaItem() } + val tasks = result.data.tasks.map { it.toAgendaItem() } + val reminders = result.data.reminders.map { it.toAgendaItem() } + + // Combine and sort by time (common property - no type check needed!) + Result.Success((events + tasks + reminders).sortedBy { it.time }) + } + is Result.Error -> result + } + } + + override suspend fun logout(): LogoutResult { + return safeApiCall("logout") { + tokenManager.clearSession() + } + } + + // ========== EVENT OPERATIONS ========== + + override suspend fun createEvent(item: AgendaItem, photos: List): AgendaItemResult { + // Runtime check - caller must provide correct type + val eventDetails = item.details as? AgendaItemDetails.Event + ?: return Result.Error(AgendaError.VALIDATION_ERROR) + + return safeApiCall("createEvent") { + val photoKeys = photos.mapIndexed { index, _ -> "photo$index" } + val request = CreateEventRequest( + id = item.id, + title = item.title, + description = item.description ?: "", + from = item.time.toEpochMillisString(), // Common 'time' = event 'from' + to = eventDetails.to.toEpochMillisString(), // Event-specific 'to' + remindAt = item.remindAt.toEpochMillisString(), + attendeeIds = eventDetails.attendees.map { it.userId }, + photoKeys = photoKeys, + updatedAt = LocalDateTime.now().toEpochMillisString() + ) + + val createResponse = api.createEvent(request) + + // Phase 2: Upload photos to S3 + if (photos.isNotEmpty()) { + createResponse.uploadUrls.forEachIndexed { index, uploadUrl -> + val uploadResult = uploadPhotoToS3(uploadUrl.url, photos[index]) + if (uploadResult is Result.Error) { + throw Exception("Photo upload failed") + } + } + } + + createResponse.event.toAgendaItem() + } + } + + override suspend fun updateEvent(item: AgendaItem, newPhotos: List): AgendaItemResult { + val eventDetails = item.details as? AgendaItemDetails.Event + ?: return Result.Error(AgendaError.VALIDATION_ERROR) + + return safeApiCall("updateEvent") { + val newPhotoKeys = newPhotos.mapIndexed { index, _ -> "photo$index" } + + val request = UpdateEventRequest( + title = item.title, + description = item.description ?: "", + from = item.time.toEpochMillisString(), + to = eventDetails.to.toEpochMillisString(), + remindAt = item.remindAt.toEpochMillisString(), + attendeeIds = eventDetails.attendees.map { it.userId }, + newPhotoKeys = newPhotoKeys, + deletedPhotoKeys = emptyList(), + isGoing = true, + updatedAt = LocalDateTime.now().toEpochMillisString() + ) + + val updateResponse = api.updateEvent(request) + + if (newPhotos.isNotEmpty()) { + updateResponse.uploadUrls.forEachIndexed { index, uploadUrl -> + uploadPhotoToS3(uploadUrl.url, newPhotos[index]) + } + } + + updateResponse.event.toAgendaItem() + } + } + + override suspend fun deleteEvent(eventId: String): Result { + return safeApiCall("deleteEvent") { api.deleteEvent(eventId) } + } + + override suspend fun getEvent(eventId: String): AgendaItemResult { + return safeApiCall("getEvent") { + api.getEvent(eventId).toAgendaItem() + } + } + + // ========== TASK OPERATIONS ========== + + override suspend fun createTask(item: AgendaItem): AgendaItemResult { + val taskDetails = item.details as? AgendaItemDetails.Task + ?: return Result.Error(AgendaError.VALIDATION_ERROR) + + return safeApiCall("createTask") { + val request = CreateTaskRequest( + id = item.id, + title = item.title, + description = item.description ?: "", + time = item.time.toEpochMillisString(), + remindAt = item.remindAt.toEpochMillisString(), + updatedAt = LocalDateTime.now().toEpochMillisString(), + isDone = taskDetails.isDone + ) + api.createTask(request).toAgendaItem() + } + } + + override suspend fun updateTask(item: AgendaItem): AgendaItemResult { + val taskDetails = item.details as? AgendaItemDetails.Task + ?: return Result.Error(AgendaError.VALIDATION_ERROR) + + return safeApiCall("updateTask") { + val request = UpdateTaskRequest( + id = item.id, + title = item.title, + description = item.description ?: "", + time = item.time.toEpochMillisString(), + remindAt = item.remindAt.toEpochMillisString(), + updatedAt = LocalDateTime.now().toEpochMillisString(), + isDone = taskDetails.isDone + ) + api.updateTask(request).toAgendaItem() + } + } + + override suspend fun deleteTask(taskId: String): Result { + return safeApiCall("deleteTask") { api.deleteTask(taskId) } + } + + override suspend fun getTask(taskId: String): AgendaItemResult { + return safeApiCall("getTask") { + api.getTask(taskId).toAgendaItem() + } + } + + // ========== REMINDER OPERATIONS ========== + + override suspend fun createReminder(item: AgendaItem): AgendaItemResult { + // Reminder has no unique fields, but validate it's the right type + if (item.details !is AgendaItemDetails.Reminder) { + return Result.Error(AgendaError.VALIDATION_ERROR) + } + + return safeApiCall("createReminder") { + val request = CreateReminderRequest( + id = item.id, + title = item.title, + description = item.description ?: "", + time = item.time.toEpochMillisString(), + remindAt = item.remindAt.toEpochMillisString(), + updatedAt = LocalDateTime.now().toEpochMillisString() + ) + api.createReminder(request).toAgendaItem() + } + } + + override suspend fun updateReminder(item: AgendaItem): AgendaItemResult { + if (item.details !is AgendaItemDetails.Reminder) { + return Result.Error(AgendaError.VALIDATION_ERROR) + } + + return safeApiCall("updateReminder") { + val request = UpdateReminderRequest( + id = item.id, + title = item.title, + description = item.description ?: "", + time = item.time.toEpochMillisString(), + remindAt = item.remindAt.toEpochMillisString(), + updatedAt = LocalDateTime.now().toEpochMillisString() + ) + api.updateReminder(request).toAgendaItem() + } + } + + override suspend fun deleteReminder(reminderId: String): Result { + return safeApiCall("deleteReminder") { api.deleteReminder(reminderId) } + } + + override suspend fun getReminder(reminderId: String): AgendaItemResult { + return safeApiCall("getReminder") { + api.getReminder(reminderId).toAgendaItem() + } + } + + // ========== ATTENDEE OPERATIONS ========== + + override suspend fun checkAttendeeExists(email: String): AttendeeResult { + return safeApiCall("checkAttendeeExists") { + val response = api.checkAttendee(email) + Attendee( + email = response.email, + username = response.fullName, + userId = response.userId, + eventId = "", + isGoing = false, + remindAt = "" + ) + } + } + + override suspend fun removeAttendee(eventId: String): Result { + return safeApiCall("removeAttendee") { api.removeAttendee(eventId) } + } +} diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/di/AgendaModule.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/di/AgendaModule.kt new file mode 100644 index 0000000..3a31241 --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/di/AgendaModule.kt @@ -0,0 +1,45 @@ +package com.dmd.tasky.features.agenda.di + +import com.dmd.tasky.core.data.token.TokenManager +import com.dmd.tasky.features.agenda.data.remote.AgendaApi +import com.dmd.tasky.features.agenda.data.repository.DefaultAgendaRepository +import com.dmd.tasky.features.agenda.domain.repository.AgendaRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AgendaModule { + + @Provides + @Singleton + fun provideAgendaApi(retrofit: Retrofit): AgendaApi { + return retrofit.create(AgendaApi::class.java) + } + + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + } + + @Provides + @Singleton + fun provideAgendaRepository( + api: AgendaApi, + tokenManager: TokenManager, + okHttpClient: OkHttpClient + ): AgendaRepository { + return DefaultAgendaRepository(api, tokenManager, okHttpClient) + } +} \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/model/AgendaItem.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/model/AgendaItem.kt new file mode 100644 index 0000000..7963648 --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/model/AgendaItem.kt @@ -0,0 +1,39 @@ +package com.dmd.tasky.features.agenda.domain.model + +import java.time.LocalDateTime + +/** + * Unified agenda item using COMPOSITION pattern. + * + * Before (inheritance): + * sealed interface AgendaItem { + * data class Event(override val id, override val title, ..., val attendees) : AgendaItem + * data class Task(override val id, override val title, ..., val isDone) : AgendaItem + * } + * Problem: Repeated "override val" for every common property in every subtype. + * + * After (composition): + * data class AgendaItem(val id, val title, ..., val details: AgendaItemDetails) + * Benefit: Common properties defined ONCE. Type-specific in 'details'. + * + * Usage: + * // Common access - no type check needed + * items.sortedBy { it.time } + * Text(item.title) + * + * // Type-specific access + * when (item.details) { + * is AgendaItemDetails.Event -> item.details.attendees + * is AgendaItemDetails.Task -> item.details.isDone + * is AgendaItemDetails.Reminder -> { } + * } + */ +data class AgendaItem( + val id: String, + val title: String, + val description: String?, + val time: LocalDateTime, // For Events: this is 'from'. For Task/Reminder: the scheduled time. + val remindAt: LocalDateTime, + val updatedAt: LocalDateTime, + val details: AgendaItemDetails // Type-specific properties live here +) diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/model/AgendaItemDetails.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/model/AgendaItemDetails.kt new file mode 100644 index 0000000..cba0742 --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/model/AgendaItemDetails.kt @@ -0,0 +1,41 @@ +package com.dmd.tasky.features.agenda.domain.model + +import java.time.LocalDateTime + +/** + * Sealed hierarchy containing ONLY the properties that differ between agenda item types. + * Common properties (id, title, description, time, remindAt, updatedAt) live in AgendaItem. + * + * Design decision: Using composition over inheritance because: + * - 95% of code works with common properties (display title, sort by time) + * - Only detail screens need type-specific data + * - Reminder has NO unique properties (data object elegantly expresses this) + */ +sealed interface AgendaItemDetails { + + /** + * Event-specific properties. + * Note: 'from' time is NOT here - it's the common 'time' property in AgendaItem. + * Events are unique in having an END time (to). + */ + data class Event( + val to: LocalDateTime, + val host: String, + val isUserEventCreator: Boolean, + val attendees: List, + val photos: List + ) : AgendaItemDetails + + /** + * Task-specific: only the completion status differs from base. + */ + data class Task( + val isDone: Boolean + ) : AgendaItemDetails + + /** + * Reminder has NO unique properties - it's essentially a Task without completion. + * data object = singleton, no instances needed, just a type marker. + */ + data object Reminder : AgendaItemDetails +} \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/model/Attendee.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/model/Attendee.kt new file mode 100644 index 0000000..f57af83 --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/model/Attendee.kt @@ -0,0 +1,10 @@ +package com.dmd.tasky.features.agenda.domain.model + +data class Attendee( + val email: String, + val username: String, + val userId: String, + val eventId: String, + val isGoing: Boolean, + val remindAt: String +) diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/model/Photo.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/model/Photo.kt new file mode 100644 index 0000000..0201165 --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/model/Photo.kt @@ -0,0 +1,6 @@ +package com.dmd.tasky.features.agenda.domain.model + +data class Photo( + val key: String, + val url: String +) \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/repository/AgendaError.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/repository/AgendaError.kt new file mode 100644 index 0000000..94df4d9 --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/repository/AgendaError.kt @@ -0,0 +1,23 @@ +package com.dmd.tasky.features.agenda.domain.repository + +import com.dmd.tasky.core.domain.util.Error + +enum class AgendaError : Error { + // Network errors + NO_INTERNET, // IOException + TIMEOUT, // SocketTimeoutException + SERVER_ERROR, // 500-599 + UNKNOWN, // Unexpected + + // HTTP errors + VALIDATION_ERROR, // 400 + UNAUTHORIZED, // 401 + FORBIDDEN, // 403 (not creator) + NOT_FOUND, // 404 + CONFLICT, // 409 + + // App-specific errors + PHOTO_TOO_LARGE, // Photo > 1MB after compression + INVALID_EMAIL, // Invalid attendee email + ATTENDEE_NOT_FOUND // User doesn't exist +} diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/repository/AgendaRepository.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/repository/AgendaRepository.kt new file mode 100644 index 0000000..5ce5638 --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/repository/AgendaRepository.kt @@ -0,0 +1,74 @@ +package com.dmd.tasky.features.agenda.domain.repository + +import com.dmd.tasky.core.domain.util.Result +import com.dmd.tasky.features.agenda.domain.model.AgendaItem +import com.dmd.tasky.features.agenda.domain.model.Attendee +import java.time.LocalDate + +/** + * Type aliases for cleaner signatures. + * + * Design change: With composition, we no longer have AgendaItem.Event etc. + * All methods now return AgendaItem (check details for type-specific data). + */ +typealias AgendaResult = Result, AgendaError> +typealias AgendaItemResult = Result +typealias LogoutResult = Result +typealias AttendeeResult = Result + +interface AgendaRepository { + + // ========== AGENDA OPERATIONS ========== + suspend fun getAgendaForDate(date: LocalDate): AgendaResult + suspend fun logout(): LogoutResult + + // ========== EVENT OPERATIONS ========== + /** + * Creates an event. The [item] MUST have details of type AgendaItemDetails.Event. + * @param photos Compressed photo byte arrays to upload + */ + suspend fun createEvent(item: AgendaItem, photos: List): AgendaItemResult + + /** + * Updates an event. The [item] MUST have details of type AgendaItemDetails.Event. + * @param newPhotos New photos to add + */ + suspend fun updateEvent(item: AgendaItem, newPhotos: List): AgendaItemResult + + suspend fun deleteEvent(eventId: String): Result + suspend fun getEvent(eventId: String): AgendaItemResult + + // ========== TASK OPERATIONS ========== + /** + * Creates a task. The [item] MUST have details of type AgendaItemDetails.Task. + */ + suspend fun createTask(item: AgendaItem): AgendaItemResult + + /** + * Updates a task. The [item] MUST have details of type AgendaItemDetails.Task. + */ + suspend fun updateTask(item: AgendaItem): AgendaItemResult + + suspend fun deleteTask(taskId: String): Result + suspend fun getTask(taskId: String): AgendaItemResult + + // ========== REMINDER OPERATIONS ========== + /** + * Creates a reminder. The [item] MUST have details of type AgendaItemDetails.Reminder. + */ + suspend fun createReminder(item: AgendaItem): AgendaItemResult + + /** + * Updates a reminder. The [item] MUST have details of type AgendaItemDetails.Reminder. + */ + suspend fun updateReminder(item: AgendaItem): AgendaItemResult + + suspend fun deleteReminder(reminderId: String): Result + suspend fun getReminder(reminderId: String): AgendaItemResult + + // ========== ATTENDEE OPERATIONS ========== + suspend fun checkAttendeeExists(email: String): AttendeeResult + suspend fun removeAttendee(eventId: String): Result + + +} diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/usecase/GetAgendaUseCase.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/usecase/GetAgendaUseCase.kt new file mode 100644 index 0000000..a428904 --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/usecase/GetAgendaUseCase.kt @@ -0,0 +1,20 @@ +package com.dmd.tasky.features.agenda.domain.usecase + +import com.dmd.tasky.core.domain.util.Result +import com.dmd.tasky.features.agenda.domain.model.AgendaItem +import com.dmd.tasky.features.agenda.domain.repository.AgendaError +import com.dmd.tasky.features.agenda.domain.repository.AgendaRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import java.time.LocalDate +import javax.inject.Inject + +class GetAgendaUseCase @Inject constructor( + private val repository: AgendaRepository +) { + operator fun invoke(date: LocalDate): Flow, AgendaError>> { + return flow { + emit(repository.getAgendaForDate(date)) + } + } +} diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/usecase/LogoutUseCase.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/usecase/LogoutUseCase.kt new file mode 100644 index 0000000..effe073 --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/domain/usecase/LogoutUseCase.kt @@ -0,0 +1,15 @@ +package com.dmd.tasky.features.agenda.domain.usecase + +import com.dmd.tasky.features.agenda.domain.repository.AgendaRepository +import com.dmd.tasky.features.agenda.domain.repository.LogoutResult +import javax.inject.Inject + +class LogoutUseCase @Inject constructor( + private val repository: AgendaRepository +) { + suspend operator fun invoke(): LogoutResult { + return repository.logout() + + } + +} \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/presentation/AgendaEvent.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/presentation/AgendaEvent.kt new file mode 100644 index 0000000..c6f32ca --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/presentation/AgendaEvent.kt @@ -0,0 +1,8 @@ +package com.dmd.tasky.features.agenda.presentation + +import java.time.LocalDate + +sealed interface AgendaEvent { + data class OnDateSelected(val date: LocalDate) : AgendaEvent + data object OnLogoutClicked : AgendaEvent +} diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/presentation/AgendaScreen.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/presentation/AgendaScreen.kt new file mode 100644 index 0000000..b39855b --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/presentation/AgendaScreen.kt @@ -0,0 +1,33 @@ +package com.dmd.tasky.features.agenda.presentation + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel + +@Composable +fun AgendaScreen( + modifier: Modifier = Modifier, + viewModel: AgendaViewModel = hiltViewModel(), + onLogout: () -> Unit +) { + val state = viewModel.state + + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(text = "Agenda for ${state.selectedDate}") + } +} + + +@Preview(showBackground = true, backgroundColor = 0xFF16161C) +@Composable +fun AgendaScreenPreview() { + AgendaScreen(onLogout = {}) +} \ No newline at end of file diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/presentation/AgendaViewModel.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/presentation/AgendaViewModel.kt new file mode 100644 index 0000000..6c89e16 --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/presentation/AgendaViewModel.kt @@ -0,0 +1,84 @@ +package com.dmd.tasky.features.agenda.presentation + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.dmd.tasky.core.domain.util.Result +import com.dmd.tasky.features.agenda.domain.model.AgendaItem +import com.dmd.tasky.features.agenda.domain.repository.AgendaError +import com.dmd.tasky.features.agenda.domain.usecase.GetAgendaUseCase +import com.dmd.tasky.features.agenda.domain.usecase.LogoutUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import java.time.LocalDate +import javax.inject.Inject + +@HiltViewModel +class AgendaViewModel @Inject constructor( + private val getAgendaUseCase: GetAgendaUseCase, + private val logoutUseCase: LogoutUseCase +) : ViewModel() { + + var state by mutableStateOf(AgendaState()) + private set + + private val eventChannel = Channel() + val events = eventChannel.receiveAsFlow() + + init { + fetchAgenda(state.selectedDate) + } + + fun onEvent(event: AgendaEvent) { + when (event) { + is AgendaEvent.OnDateSelected -> { + state = state.copy(selectedDate = event.date) + fetchAgenda(event.date) + } + + is AgendaEvent.OnLogoutClicked -> { + logout() + } + } + } + + private fun fetchAgenda(date: LocalDate) { + viewModelScope.launch { + state = state.copy(isLoading = true, error = null) + + getAgendaUseCase(date).collect { result -> + // Handle the Result type properly + state = when (result) { + is Result.Success -> state.copy( + items = result.data, + isLoading = false, + error = null + ) + + is Result.Error -> state.copy( + items = emptyList(), + isLoading = false, + error = result.error + ) + } + } + } + } + private fun logout() { + viewModelScope.launch { + logoutUseCase() + // Navigation handled by TaskyNavHost callback + } + } +} + +data class AgendaState( + val selectedDate: LocalDate = LocalDate.now(), + val items: List = emptyList(), + val isLoading: Boolean = false, + val error: AgendaError? = null // Added for error handling +) diff --git a/features/agenda/src/main/java/com/dmd/tasky/features/agenda/util/PhotoCompressor.kt b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/util/PhotoCompressor.kt new file mode 100644 index 0000000..1efeac2 --- /dev/null +++ b/features/agenda/src/main/java/com/dmd/tasky/features/agenda/util/PhotoCompressor.kt @@ -0,0 +1,56 @@ +package com.dmd.tasky.features.agenda.util + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import com.dmd.tasky.core.domain.util.Result +import com.dmd.tasky.features.agenda.domain.repository.AgendaError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okio.`-DeprecatedOkio`.source +import timber.log.Timber +import java.io.ByteArrayOutputStream + +object PhotoCompressor { + private const val MAX_SIZE_BYTES = 1_048_576 // 1MB + private const val INITIAL_QUALITY = 90 + private const val QUALITY_STEP = 10 + private const val MIN_QUALITY = 50 + + suspend fun compressPhoto( + context: Context, + uri: Uri + ): Result = withContext(Dispatchers.IO) { + try { + val source = ImageDecoder.createSource(context.contentResolver, uri) + val bitmap = ImageDecoder.decodeBitmap(source) + + var quality = INITIAL_QUALITY + var compressedData = compressBitmap(bitmap, quality) + + // Try progressively lower quality + while (compressedData.size > MAX_SIZE_BYTES && quality >= MIN_QUALITY) { + quality -= QUALITY_STEP + compressedData = compressBitmap(bitmap, quality) + } + + if (compressedData.size > MAX_SIZE_BYTES) { + Result.Error(AgendaError.PHOTO_TOO_LARGE) + } else { + Result.Success(compressedData) + } + } catch (e: Exception) { + Timber.e("Photo compression failed: ${e.message}") + Result.Error(AgendaError.UNKNOWN) + } + } + + private fun compressBitmap(bitmap: Bitmap, quality: Int): ByteArray { + val outputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) + return outputStream.toByteArray() + } +} \ No newline at end of file diff --git a/features/auth/src/androidTest/java/com/dmd/tasky/features/auth/ExampleInstrumentedTest.kt b/features/auth/src/androidTest/java/com/dmd/tasky/features/auth/ExampleInstrumentedTest.kt deleted file mode 100644 index d88a330..0000000 --- a/features/auth/src/androidTest/java/com/dmd/tasky/features/auth/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.dmd.tasky.features.auth - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.dmd.tasky.features.auth.test", appContext.packageName) - } -} \ No newline at end of file diff --git a/features/auth/src/main/java/com/dmd/tasky/features/auth/presentation/agenda/AgendaScreen.kt b/features/auth/src/main/java/com/dmd/tasky/features/auth/presentation/agenda/AgendaScreen.kt deleted file mode 100644 index af75fc8..0000000 --- a/features/auth/src/main/java/com/dmd/tasky/features/auth/presentation/agenda/AgendaScreen.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.dmd.tasky.features.auth.presentation.agenda - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp - -@Composable -fun AgendaScreen( - modifier: Modifier = Modifier -) { - Scaffold( - modifier = modifier - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .background(Color.Black), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = "📅", - style = MaterialTheme.typography.displayLarge, - modifier = Modifier.padding(bottom = 16.dp) - ) - Text( - text = "Agenda Screen", - style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.Bold, - color = Color.White - ) - Text( - text = "Coming Soon!", - style = MaterialTheme.typography.bodyLarge, - color = Color.White.copy(alpha = 0.7f), - modifier = Modifier.padding(top = 8.dp) - ) - } - } -} - -@Preview(showBackground = true, backgroundColor = 0xFF16161C) -@Composable -fun AgendaScreenPreview() { - AgendaScreen() -} \ No newline at end of file diff --git a/features/auth/src/main/java/com/dmd/tasky/features/auth/presentation/register/RegisterViewModel.kt b/features/auth/src/main/java/com/dmd/tasky/features/auth/presentation/register/RegisterViewModel.kt index 342edf5..320c328 100644 --- a/features/auth/src/main/java/com/dmd/tasky/features/auth/presentation/register/RegisterViewModel.kt +++ b/features/auth/src/main/java/com/dmd/tasky/features/auth/presentation/register/RegisterViewModel.kt @@ -55,7 +55,7 @@ class RegisterViewModel @Inject constructor( viewModelScope.launch { state = state.copy(isLoading = true, error = null) - Timber.d("📝 Starting registration...") + Timber.d(" Starting registration...") Timber.d(" Full Name: '${state.fullName}' (length: ${state.fullName.length})") Timber.d(" Email: '${state.email}'") Timber.d(" Password: '${state.password}' (length: ${state.password.length})") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fd97ef7..ac0330d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,7 +24,9 @@ retrofitKotlinxSerializationConverter = "1.0.0" kotlinxSerialization = "1.7.1" datastore = "1.1.1" security-crypto = "1.1.0-alpha06" -splashscreen = "1.0.1" +splashscreen = "1.2.0" +coil = "2.4.0" +accompanist = "0.32.0" [libraries] @@ -62,6 +64,8 @@ androidx-datastore-preferences = { group = "androidx.datastore", name = "datasto kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto"} androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }