diff --git a/.gitignore b/.gitignore index e91557f..5b5cafb 100644 --- a/.gitignore +++ b/.gitignore @@ -558,3 +558,8 @@ fabric.properties # Firebase on iOS iosApp/iosApp/GoogleService-Info.plist /shared/schemas/ + +# Secret files +gradle.properties +iosApp/Configuration/Config.xcconfig +iosApp/iosApp/Info.plist diff --git a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/org.jetbrains.compose.ui-ui-uikit-1.9.3-uikitMain-cinterop/org.jetbrains.compose.ui_ui-uikit-cinterop-utils-c6AlBg.klib b/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/org.jetbrains.compose.ui-ui-uikit-1.9.3-uikitMain-cinterop/org.jetbrains.compose.ui_ui-uikit-cinterop-utils-c6AlBg.klib index e08507a..c47f1a8 100644 Binary files a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/org.jetbrains.compose.ui-ui-uikit-1.9.3-uikitMain-cinterop/org.jetbrains.compose.ui_ui-uikit-cinterop-utils-c6AlBg.klib and b/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/org.jetbrains.compose.ui-ui-uikit-1.9.3-uikitMain-cinterop/org.jetbrains.compose.ui_ui-uikit-cinterop-utils-c6AlBg.klib differ diff --git a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-iosMain-cinterop/org.jetbrains.kotlinx_atomicfu-cinterop-interop-WOkCig.klib b/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-iosMain-cinterop/org.jetbrains.kotlinx_atomicfu-cinterop-interop-WOkCig.klib index 651bafb..8e075d7 100644 Binary files a/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-iosMain-cinterop/org.jetbrains.kotlinx_atomicfu-cinterop-interop-WOkCig.klib and b/.kotlin/metadata/kotlinTransformedCInteropMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-iosMain-cinterop/org.jetbrains.kotlinx_atomicfu-cinterop-interop-WOkCig.klib differ diff --git a/CLAUDE.md b/CLAUDE.md index ee43397..b9b6abd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,1137 +2,136 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Development Guidelines (IMPORTANT) - -1. **Do NOT invent or hallucinate information** - Always verify facts using official documentation -2. **Use web search when needed** - Consult official Kotlin Multiplatform, Ktor, and Compose documentation for implementation details -3. **Ask questions if unclear** - If requirements are ambiguous or you're unsure about an approach, ask the user for clarification before proceeding -4. **Follow established patterns** - Use the MVVM architecture and repository pattern when implementing features -5. **Code comments must be in English** - All technical comments, documentation (KDoc), and code-level explanations must be written in English. User-facing strings in the UI (button labels, messages, etc.) can be in Spanish for the target audience - ## Project Overview -SpainDecides is a **Kotlin Multiplatform (KMP) project** using **Compose Multiplatform** for shared UI across Android and iOS platforms. The project uses a single codebase to build native applications for both platforms. - -**Package namespace:** `com.apptolast.spaindecides` +**BaseLogin** is a reusable KMP (Kotlin Multiplatform) authentication library (`custom-login`) with a sample consumer app (`composeApp`). Targets: Android, iOS. Stack: Kotlin Multiplatform · Compose Multiplatform · Koin · Firebase Auth (via GitLive SDK). -## Build and Run Commands - -### Android +## Build Commands ```bash -./gradlew :composeApp:assembleDebug # Build debug APK -./gradlew :composeApp:assembleRelease # Build release APK -./gradlew :composeApp:installDebug # Run on connected device/emulator -``` +# Build Android debug APK +./gradlew :composeApp:assembleDebug -### iOS +# Build iOS framework (for Xcode/CocoaPods) +./gradlew :composeApp:podInstall -iOS builds must be done through Xcode: -1. Open the `/iosApp` directory in Xcode -2. Build and run from Xcode +# Run Android app +./gradlew :composeApp:installDebug -The Kotlin framework can be compiled separately with: -```bash -./gradlew :composeApp:linkDebugFrameworkIosArm64 -./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 -``` +# Run all tests +./gradlew test -### Testing +# Run tests for a single module +./gradlew :custom-login:test +./gradlew :composeApp:test -Run common tests: -```bash -./gradlew :composeApp:cleanTestDebugUnitTest :composeApp:testDebugUnitTest -``` +# Run a specific test class +./gradlew :custom-login:test --tests "com.apptolast.customlogin.SomeTestClass" -## Architecture +# Build the library only +./gradlew :custom-login:assemble -### MVVM Architecture Pattern - -This project should follow **MVVM (Model-View-ViewModel)** architecture as recommended by Google for Kotlin Multiplatform: - -``` -composeApp/src/commonMain/kotlin/com/apptolast/spaindecides/ -├── presentation/ -│ ├── ui/ # Composable UI (View) -│ │ ├── App.kt # Main app entry point -│ │ ├── screens/ # Individual screen composables -│ │ └── components/ # Reusable UI components -│ └── viewmodel/ # ViewModels -│ └── *ViewModel.kt -│ -├── domain/ -│ └── repository/ # Repository interfaces -│ └── *Repository.kt -│ -├── data/ -│ ├── model/ # Data models (DTOs) -│ │ └── *.kt -│ ├── remote/ # Network layer -│ │ ├── api/ # API service definitions -│ │ │ └── *ApiService.kt -│ │ └── KtorClient.kt # Ktor HTTP client configuration -│ └── repository/ # Repository implementations -│ └── *RepositoryImpl.kt -│ -├── di/ # Dependency Injection modules -│ ├── KoinInitializer.kt -│ ├── DataModule.kt -│ ├── DomainModule.kt -│ └── PresentationModule.kt -│ -└── util/ # Utilities and helpers - └── *.kt +# Clean build +./gradlew clean ``` -### Data Flow +For iOS, open `iosApp/iosApp.xcworkspace` in Xcode and run from there. -1. **ViewModel initialization** → Calls repository methods -2. **Repository** → Calls API service or data source -3. **Ktor Client** → Makes HTTP requests -4. **StateFlow updates** → UI recomposes with new data -5. **User interaction** → Triggers ViewModel methods -6. **Success/Error** → Updates state and shows feedback - -### Multiplatform Source Structure +## Module Structure ``` -composeApp/src/ -├── commonMain/ # Shared code for all platforms -│ ├── kotlin/ # Common Kotlin code (MVVM architecture) -│ └── composeResources/ # Shared resources (images, strings, etc.) -├── androidMain/ # Android-specific code -│ └── kotlin/ -│ └── MainActivity.kt -├── iosMain/ # iOS-specific code (Kotlin) -│ └── kotlin/ -│ └── MainViewController.kt -└── commonTest/ # Shared test code +root/ +├── composeApp/ ← Sample consumer app (Android + iOS). Contains no auth logic. +└── custom-login/ ← Publishable KMP library. All auth code lives here. + ├── commonMain/ ← Shared code (interfaces, ViewModels, Compose UI) + ├── androidMain/ ← Android-specific implementations (Google/Phone providers) + └── iosMain/ ← iOS-specific implementations (Google/Phone providers) ``` -### Adding New Code - -- **Models**: Add to `data/model/` -- **API Services**: Add to `data/remote/api/` -- **Repositories**: Interface in `domain/repository/`, implementation in `data/repository/` -- **ViewModels**: Add to `presentation/viewmodel/` -- **UI Screens**: Add to `presentation/ui/screens/` -- **Reusable Components**: Add to `presentation/ui/components/` -- **Platform-specific**: Add to respective platform source sets (androidMain, iosMain) - -## Dependency Injection with Koin - -This project uses **Koin** as the dependency injection framework for managing object creation and lifecycle across all platforms. - -### Why Koin? - -- **Multiplatform Support**: Official support for all KMP targets (Android, iOS, Desktop, Web) -- **Lightweight**: No code generation or reflection, just Kotlin DSL -- **Compose Integration**: First-class support for Compose Multiplatform with `koinViewModel()` -- **Easy Testing**: Simple to provide fake implementations for unit tests -- **Google Best Practices**: Follows MVVM architecture recommendations with constructor injection - -### Koin Configuration - -#### Version (gradle/libs.versions.toml) - -```toml -[versions] -koin-bom = "4.1.1" - -[libraries] -koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" } -koin-core = { module = "io.insert-koin:koin-core" } -koin-compose = { module = "io.insert-koin:koin-compose" } -koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" } -koin-compose-viewmodel-navigation = { module = "io.insert-koin:koin-compose-viewmodel-navigation" } -koin-android = { module = "io.insert-koin:koin-android" } -koin-test = { module = "io.insert-koin:koin-test" } -``` - -#### Build Configuration (composeApp/build.gradle.kts) - -```kotlin -commonMain.dependencies { - implementation(project.dependencies.platform(libs.koin.bom)) - implementation(libs.koin.core) - implementation(libs.koin.compose) - implementation(libs.koin.compose.viewmodel) - implementation(libs.koin.compose.viewmodel.navigation) -} - -androidMain.dependencies { - implementation(libs.koin.android) -} - -commonTest.dependencies { - implementation(libs.koin.test) -} -``` - -### Defining Koin Modules - -#### Data Module (di/DataModule.kt) - -Provides network and repository dependencies: - -```kotlin -val dataModule = module { - // HttpClient singleton - single { createHttpClient() } - - // API Service with constructor injection - singleOf(::ApiService) - - // Repository Implementation bound to interface - singleOf(::RepositoryImpl) bind Repository::class -} -``` - -**Key Points:** -- `single` creates a singleton (one instance for app lifetime) -- `singleOf(::ClassName)` is concise syntax for constructor injection -- `bind` allows injecting by interface type -- Koin automatically resolves constructor dependencies with `get()` - -#### Presentation Module (di/PresentationModule.kt) - -Provides ViewModels with lifecycle management: - -```kotlin -val presentationModule = module { - // ViewModel with lifecycle-aware scope - viewModelOf(::MainViewModel) -} -``` - -**Key Points:** -- `viewModelOf` creates a ViewModel-scoped instance -- Automatically handles lifecycle and configuration changes -- Repository is auto-injected via constructor - -### Using Koin for Injection - -#### Injecting ViewModel in Composables - -Starting with **Koin 4.1+**, use `koinViewModel()` for **all scenarios**: - -```kotlin -import org.koin.compose.viewmodel.koinViewModel - -@Composable -fun App() { - val viewModel: MainViewModel = koinViewModel() - MainScreen(viewModel = viewModel) -} -``` - -**For Navigation Compose**: - -```kotlin -import org.koin.compose.viewmodel.koinViewModel - -@Composable -fun App() { - val navController = rememberNavController() - - NavHost(navController, startDestination = "home") { - composable("home") { - // koinViewModel() automatically handles Navigation integration - val viewModel: HomeViewModel = koinViewModel() - HomeScreen(viewModel = viewModel) - } - - composable("detail/{itemId}") { - val viewModel: DetailViewModel = koinViewModel() - DetailScreen(viewModel) - } - } -} -``` - -**Important Note**: `koinNavViewModel()` is **DEPRECATED** in Koin 4.1+. Always use `koinViewModel()`. - -#### Constructor Injection in Classes - -All dependencies use constructor injection (no field injection): - -```kotlin -// ViewModel receives Repository -class MainViewModel( - private val repository: Repository // Koin injects -) : ViewModel() - -// Repository receives API service -class RepositoryImpl( - private val apiService: ApiService // Koin injects -) : Repository - -// API Service receives HttpClient -class ApiService( - private val httpClient: HttpClient // Koin injects -) -``` - -### Platform-Specific Initialization - -#### Android - -Create an `Application` class to initialize Koin: - -```kotlin -class SpainDecidesApplication : Application() { - override fun onCreate() { - super.onCreate() - - initKoin { - androidLogger() // Enable Android logging - androidContext(this@SpainDecidesApplication) // Provide context - } - } -} -``` - -Register in `AndroidManifest.xml`: - -```xml - -``` - -#### iOS - -Initialize in `iOSApp.swift`: - -```swift -import ComposeApp - -@main -struct iOSApp: App { - init() { - KoinInitializerKt.doInitKoin() - } - - var body: some Scene { - WindowGroup { - ContentView() - } - } -} -``` - -### Koin Scoping Strategies - -| Scope | Usage | Lifecycle | -|-------|-------|-----------| -| `single` | Singletons (HttpClient, Repositories, API services) | App lifetime | -| `factory` | Short-lived objects (use cases) | Created on each injection | -| `viewModelOf` | ViewModels | Survives configuration changes | - -### Best Practices - -1. **Constructor Injection Only**: Never use field injection -2. **Interface-Based Design**: Depend on abstractions (Repository interface, not RepositoryImpl) -3. **Single Responsibility**: Each class should depend only on what it needs -4. **Module Organization**: Separate by architectural layers (data, domain, presentation) - -### Common Issues and Solutions - -#### Issue: `KoinAppAlreadyStartedException` - -**Cause:** Starting Koin multiple times - -**Solution:** Only call `initKoin()` once at Application/App entry point, never in Activities or Composables - -#### Issue: Missing dependency injection - -**Cause:** Class not defined in any module - -**Solution:** Add the class to the appropriate module (DataModule, DomainModule, or PresentationModule) - -### Koin Resources - -- **Official Documentation**: https://insert-koin.io -- **KMP Guide**: https://insert-koin.io/docs/reference/koin-mp/kmp/ -- **Compose Integration**: https://insert-koin.io/docs/reference/koin-compose/compose/ - -## Navigation with Compose - -### Type-Safe Navigation - -Use **Jetpack Navigation Compose** with Kotlin Serialization for type-safe navigation: - -#### Version Configuration (gradle/libs.versions.toml) - -```toml -[versions] -navigation-compose = "2.8.0-alpha10" # Or latest version - -[libraries] -navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation-compose" } -``` - -#### Build Configuration (composeApp/build.gradle.kts) +## Architecture -```kotlin -commonMain.dependencies { - implementation(libs.navigation.compose) -} +### Layer Dependencies ``` - -#### Define Routes with Serialization - -```kotlin -import kotlinx.serialization.Serializable - -@Serializable -object HomeRoute - -@Serializable -data class DetailRoute(val itemId: String) - -@Serializable -data class ProfileRoute(val userId: String) +[ Presentation ] → [ Domain ] ← [ Data ] + ↑ + [ DI ] (orchestrates, no logic) ``` -#### Navigation Setup +- **domain**: Pure interfaces (`AuthRepository`, `AuthProvider`) and models. No external imports. +- **data**: Firebase/SDK implementations. Imports domain only. Never throws exceptions upward — always returns `AuthResult`. +- **presentation**: ViewModels + Compose UI. Imports domain only. Never imports data layer. +- **di**: Koin wiring. Imports all layers. -```kotlin -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import androidx.navigation.toRoute +### MVI Pattern -@Composable -fun App() { - val navController = rememberNavController() +Every screen has exactly these files: +- `XxxAction.kt` — `sealed interface` for user intents +- `XxxUiState.kt` — immutable `data class`, updated via `copy()` +- `XxxEffect.kt` — `sealed class` for one-shot events (navigation, snackbars) +- `XxxViewModel.kt` — exposes `StateFlow` and `Flow` (via `Channel.receiveAsFlow()`) +- `XxxScreen.kt` — `@Composable` collecting state/effects, using slots - NavHost(navController, startDestination = HomeRoute) { - composable { - val viewModel: HomeViewModel = koinViewModel() - HomeScreen( - viewModel = viewModel, - onNavigateToDetail = { itemId -> - navController.navigate(DetailRoute(itemId)) - } - ) - } +### Slots System - composable { backStackEntry -> - val route: DetailRoute = backStackEntry.toRoute() - val viewModel: DetailViewModel = koinViewModel() - DetailScreen( - itemId = route.itemId, - viewModel = viewModel, - onBack = { navController.popBackStack() } - ) - } - } -} -``` - -### Shared ViewModels Across Destinations +`AuthScreenSlots` (in `AuthSlots.kt`) groups per-screen slot data classes (`LoginScreenSlots`, `RegisterScreenSlots`, etc.). Each slot has a working default. `null` = hidden. Slots receive only state values and callbacks — no business logic. +Consumer app integrates via: ```kotlin -NavHost( +authRoutesFlow( navController = navController, - startDestination = "screenA", - route = "parentRoute" // Important: Define parent route -) { - composable("screenA") { backStackEntry -> - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry("parentRoute") - } - val sharedViewModel: SharedViewModel = koinViewModel( - viewModelStoreOwner = parentEntry // Scope to parent - ) - ScreenA(sharedViewModel) - } - - composable("screenB") { backStackEntry -> - val parentEntry = remember(backStackEntry) { - navController.getBackStackEntry("parentRoute") - } - val sharedViewModel: SharedViewModel = koinViewModel( - viewModelStoreOwner = parentEntry // Same instance - ) - ScreenB(sharedViewModel) - } -} -``` - -### Navigation Best Practices - -1. **Pass data through routes**: Use serializable data classes for type-safety -2. **Keep navigation logic in UI layer**: Don't navigate from ViewModels -3. **Use callbacks**: Pass navigation callbacks as lambda parameters to screens -4. **Avoid deep navigation stacks**: Provide clear back navigation - -## Network Layer with Ktor Client - -This project uses **Ktor Client** for HTTP communication instead of Retrofit, as it provides full multiplatform support. - -### Why Ktor Over Retrofit? - -- **Multiplatform Support**: Works on Android, iOS, Desktop, Web -- **Coroutines-First**: Built from the ground up for Kotlin coroutines -- **Lightweight**: Smaller footprint than Retrofit -- **Official JetBrains Library**: Maintained by the Kotlin team - -### Ktor Configuration - -#### Version (gradle/libs.versions.toml) - -```toml -[versions] -ktor = "3.0.3" -kotlinx-serialization = "1.8.0" - -[libraries] -ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } -ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } -ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } -ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } -ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } - -[plugins] -kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } -``` - -#### Build Configuration (composeApp/build.gradle.kts) - -```kotlin -plugins { - alias(libs.plugins.kotlinx.serialization) -} - -kotlin { - sourceSets { - commonMain.dependencies { - implementation(libs.ktor.client.core) - implementation(libs.ktor.client.content.negotiation) - implementation(libs.ktor.serialization.kotlinx.json) - implementation(libs.ktor.client.logging) - } - - androidMain.dependencies { - implementation(libs.ktor.client.okhttp) // OkHttp engine for Android - } - - iosMain.dependencies { - implementation(libs.ktor.client.darwin) // Darwin engine for iOS - } - } -} -``` - -### HttpClient Setup - -Create a factory function in `data/remote/KtorClient.kt`: - -```kotlin -import io.ktor.client.* -import io.ktor.client.plugins.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.client.plugins.logging.* -import io.ktor.serialization.kotlinx.json.* -import kotlinx.serialization.json.Json - -fun createHttpClient(): HttpClient { - return HttpClient { - install(ContentNegotiation) { - json(Json { - prettyPrint = true - isLenient = true - ignoreUnknownKeys = true - }) - } - - install(Logging) { - logger = Logger.DEFAULT - level = LogLevel.INFO - } - - install(HttpTimeout) { - requestTimeoutMillis = 30_000 - connectTimeoutMillis = 30_000 - } - - defaultRequest { - url("https://api.example.com/") // Base URL - } - } -} -``` - -### API Service Example - -```kotlin -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.request.* -import kotlinx.serialization.Serializable - -@Serializable -data class User(val id: String, val name: String) - -class ApiService(private val httpClient: HttpClient) { - suspend fun getUsers(): Result> { - return try { - val response = httpClient.get("users") - Result.success(response.body()) - } catch (e: Exception) { - Result.failure(e) - } - } - - suspend fun getUserById(id: String): Result { - return try { - val response = httpClient.get("users/$id") - Result.success(response.body()) - } catch (e: Exception) { - Result.failure(e) - } - } - - suspend fun createUser(user: User): Result { - return try { - val response = httpClient.post("users") { - setBody(user) - } - Result.success(response.body()) - } catch (e: Exception) { - Result.failure(e) - } - } -} -``` - -### Ktor Resources - -- **Official Documentation**: https://ktor.io/docs/client-create-multiplatform-application.html -- **Content Negotiation**: https://ktor.io/docs/serialization-client.html -- **Ktor Client Setup**: https://ktor.io/docs/client-create-new-application.html - -## Expect/Actual Pattern - Platform-Specific Code - -### When to Use Expect/Actual - -The expect/actual mechanism enables accessing platform-specific APIs when: - -1. **No multiplatform library exists** - The functionality is not available through official KMP libraries -2. **Factory functions** - Need to return platform-specific implementations -3. **Inheriting platform classes** - Must extend existing platform-specific base classes -4. **Direct native API access** - Require direct access to platform APIs for performance or features - -### When NOT to Use (IMPORTANT) - -**Official Recommendation**: Prefer interfaces over expect/actual in most cases. - -❌ **DO NOT use expect/actual if:** -- A multiplatform library already exists (e.g., kotlinx-datetime, kotlinx-coroutines) -- An interface would be sufficient -- You can use dependency injection with interfaces -- Standard Kotlin constructs solve the problem - -✅ **Interfaces are better because:** -- Allow multiple implementations per platform -- Make testing easier with fake/mock implementations -- More flexible and standard Kotlin approach -- Avoid Beta feature limitations - -### Example: Platform-Specific Date/Time - -```kotlin -// commonMain/util/DateTimeProvider.kt -expect fun getCurrentTimestamp(): String - -// androidMain/util/DateTimeProvider.android.kt -actual fun getCurrentTimestamp(): String { - return kotlin.time.Clock.System.now().toString() -} - -// iosMain/util/DateTimeProvider.ios.kt -actual fun getCurrentTimestamp(): String { - // iOS uses Foundation NSDate directly for better platform integration - val formatter = NSISO8601DateFormatter() - return formatter.stringFromDate(NSDate()) -} -``` - -### Process for Handling Missing Multiplatform Libraries - -When encountering functionality without multiplatform support: - -1. **Search for Official KMP Libraries** - - Check JetBrains kotlinx.* libraries first - - Search Maven Central for "kmp-*" or "kmm-*" prefixed libraries - - Verify library supports all your target platforms - -2. **Verify Library Documentation** - - Read official documentation to confirm multiplatform support - - Check GitHub releases for latest stable versions - - Review platform compatibility matrix - -3. **Test Library Integration** - - Add dependency to `commonMain` - - Sync Gradle and verify no errors - - Test compilation for each platform target - -4. **Implement Expect/Actual as Last Resort** - - Only when no suitable multiplatform library exists - - Document the decision and alternatives evaluated - - Create expect declaration in `commonMain` - - Provide actual implementations for each platform - - Use platform-native APIs (e.g., NSDate for iOS, java.time for JVM) - -5. **Document the Implementation** - - Add comments explaining why expect/actual was necessary - - Reference any GitHub issues or documentation consulted - - Note future migration path if library becomes available - -### Rules for Expect/Actual Declarations - -1. **Declaration Location**: `expect` in `commonMain`, `actual` in each platform source set -2. **Same Package**: Both must be in the identical package -3. **Matching Signatures**: Names, parameters, and return types must match exactly -4. **No Implementation in Expect**: Expected declarations cannot contain implementation code -5. **All Platforms**: Every platform must provide an `actual` implementation - -### Best Practices - -- **Verify First**: Always search for existing multiplatform solutions before implementing expect/actual -- **Use Web Search**: When unsure, search official Kotlin and library documentation -- **Ask Questions**: If requirements are unclear, ask for clarification rather than guessing -- **Document Decisions**: Explain why expect/actual was chosen over alternatives -- **Keep It Simple**: Minimize the surface area of platform-specific code -- **Test All Platforms**: Verify implementation works on every target platform - -### Beta Feature Warning - -Expected/actual classes are in **Beta status** - migration steps may be required in future Kotlin versions. Suppress warnings if needed: - -```kotlin -freeCompilerArgs.add("-Xexpect-actual-classes") -``` - -### Resources for Expect/Actual - -- **Official Documentation**: https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-expect-actual.html -- **Kotlin Language Docs**: https://kotlinlang.org/docs/multiplatform-expect-actual.html -- **Connect to Platform APIs**: https://kotlinlang.org/docs/multiplatform-connect-to-apis.html - -## UI Design & Theming System - -This project uses **Material Design 3** with custom theming for consistent UI across all platforms. - -### Theme Architecture - -The theming system should be located in `presentation/ui/theme/`: - -``` -presentation/ui/theme/ -├── Color.kt # Color palette definitions (light & dark schemes) -├── Font.kt # Custom font family definitions -├── Type.kt # Typography scale (Material 3) -└── Theme.kt # Main AppTheme composable -``` - -### Color System - -#### Material 3 Color Roles - -Always use `MaterialTheme.colorScheme` - never hardcode colors: - -```kotlin -// ✅ CORRECT - Uses theme colors -Text( - text = "Title", - color = MaterialTheme.colorScheme.onSurface + slots = AuthScreenSlots(login = LoginScreenSlots(header = { MyLogo() })), + onNavigateToHome = { /* handled by AuthState change */ } ) - -Button( - onClick = { }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) -) { - Text("Action") -} - -// ❌ WRONG - Hardcoded color -Text( - text = "Title", - color = Color(0xFFFFFFFF) // Don't do this! -) -``` - -| Role | Usage | Example Components | -|------|-------|-------------------| -| `primary` | Main brand color, primary actions | FABs, prominent buttons | -| `primaryContainer` | Tinted backgrounds | Cards with emphasis | -| `secondary` | Less prominent actions | Secondary buttons | -| `tertiary` | Contrasting accents | Special highlights | -| `surface` | Backgrounds for components | Cards, dialogs | -| `surfaceVariant` | Alternative surfaces | Input fields | -| `outline` | Borders and dividers | TextField borders | -| `error` | Error states and warnings | Error messages | - -### Typography System - -#### Material 3 Type Scale - -| Category | Sizes | Usage | Font Weight | -|----------|-------|-------|-------------| -| **Display** | Large (57sp), Medium (45sp), Small (36sp) | Large, expressive text | Bold/Normal | -| **Headline** | Large (32sp), Medium (28sp), Small (24sp) | Page titles | SemiBold/Medium | -| **Title** | Large (22sp), Medium (16sp), Small (14sp) | Section titles | SemiBold/Medium | -| **Body** | Large (16sp), Medium (14sp), Small (12sp) | Main content | Normal | -| **Label** | Large (14sp), Medium (12sp), Small (11sp) | Buttons, labels | Medium | - -#### Using Typography in UI - -**Always use MaterialTheme.typography**: - -```kotlin -// Page title -Text( - text = "Dashboard", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurface -) - -// Section heading -Text( - text = "Recent Items", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface -) - -// Body text -Text( - text = "Description text", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant -) - -// Button text -Button(onClick = { }) { - Text( - text = "Save", - style = MaterialTheme.typography.labelLarge - ) -} ``` -### Custom Fonts - -#### Adding Custom Fonts (Step-by-Step) - -##### Step 1: Download Font Files - -1. Visit **Google Fonts**: https://fonts.google.com -2. **Recommended fonts**: - - **Inter**: Modern, screen-optimized (https://fonts.google.com/specimen/Inter) - - **Roboto**: Material Design standard (https://fonts.google.com/specimen/Roboto) -3. Download at least **4 weights**: Regular (400), Medium (500), SemiBold (600), Bold (700) - -##### Step 2: Create Font Directory - -```bash -mkdir -p composeApp/src/commonMain/composeResources/font -``` - -##### Step 3: Place Font Files - -Copy the `.ttf` files with **lowercase, underscore-separated names**: - -``` -composeApp/src/commonMain/composeResources/font/ -├── inter_regular.ttf -├── inter_medium.ttf -├── inter_semibold.ttf -└── inter_bold.ttf -``` - -##### Step 4: Build Project - -```bash -./gradlew build -``` - -This generates resource accessors in `spaindecides.composeapp.generated.resources.Res.font.*` - -##### Step 5: Create Font.kt - -```kotlin -import androidx.compose.runtime.Composable -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import org.jetbrains.compose.resources.Font -import spaindecides.composeapp.generated.resources.Res -import spaindecides.composeapp.generated.resources.inter_regular -import spaindecides.composeapp.generated.resources.inter_medium -import spaindecides.composeapp.generated.resources.inter_semibold -import spaindecides.composeapp.generated.resources.inter_bold - -@Composable -fun appFontFamily(): FontFamily { - return FontFamily( - Font(Res.font.inter_regular, FontWeight.Normal), - Font(Res.font.inter_medium, FontWeight.Medium), - Font(Res.font.inter_semibold, FontWeight.SemiBold), - Font(Res.font.inter_bold, FontWeight.Bold) - ) -} -``` - -##### Step 6: Use in Typography - -```kotlin -import androidx.compose.material3.Typography -import androidx.compose.runtime.Composable - -@Composable -fun appTypography(): Typography { - val fontFamily = appFontFamily() - return Typography( - // Use fontFamily in all text styles - displayLarge = TextStyle(fontFamily = fontFamily), - headlineMedium = TextStyle(fontFamily = fontFamily), - bodyMedium = TextStyle(fontFamily = fontFamily), - // ... all other styles - ) -} -``` - -#### Important: Font() is Composable - -In Compose Multiplatform, **Font() is a @Composable function**: - -- FontFamily must be created inside `@Composable` functions -- Typography must be created inside `@Composable` functions -- Cannot define fonts as top-level `val` properties - -**This is why** `appFontFamily()` and `appTypography()` are functions, not values. - -### Creating New Screens - -#### 1. Follow Material 3 Component Patterns - -```kotlin -@Composable -fun MyNewScreen() { - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .padding(16.dp) - ) { - // Page title - Text( - text = "Screen Title", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurface - ) - - // Content card - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) { - // Card content - } - - // Primary action button - Button( - onClick = { }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Action") - } - } -} -``` - -#### 2. Consistent Spacing - -Use **multiples of 4dp** for spacing: - -```kotlin -val spacing = object { - val extraSmall = 4.dp - val small = 8.dp - val medium = 16.dp - val large = 24.dp - val extraLarge = 32.dp -} - -Column( - modifier = Modifier.padding(spacing.medium), - verticalArrangement = Arrangement.spacedBy(spacing.small) -) { - // Content with consistent spacing -} -``` - -#### 3. Rounded Corners - -- **Small components**: `8.dp` or `12.dp` -- **Medium components**: `12.dp` -- **Large components**: `16.dp` or `24.dp` - -```kotlin -Card(shape = RoundedCornerShape(16.dp)) { /* ... */ } -Button(shape = RoundedCornerShape(12.dp)) { /* ... */ } -``` - -### Status Bar & System UI Management - -#### Android Edge-to-Edge Design - -Configure status bar in `MainActivity.kt`: - -```kotlin -import androidx.activity.ComponentActivity -import androidx.activity.SystemBarStyle -import androidx.activity.enableEdgeToEdge -import androidx.graphics.Color - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - // Configure edge-to-edge with auto-adjusting status bar icons - enableEdgeToEdge( - statusBarStyle = SystemBarStyle.auto( - lightScrim = Color.TRANSPARENT, - darkScrim = Color.TRANSPARENT - ), - navigationBarStyle = SystemBarStyle.auto( - lightScrim = Color.TRANSPARENT, - darkScrim = Color.TRANSPARENT - ) - ) - super.onCreate(savedInstanceState) - setContent { App() } - } -} -``` - -Configure icon colors in theme using expect/actual: - -```kotlin -// commonMain/presentation/ui/theme/Theme.kt -@Composable -expect fun ConfigureSystemUI(darkTheme: Boolean) - -// androidMain/presentation/ui/theme/Theme.android.kt -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.ui.platform.LocalView -import androidx.core.view.WindowCompat - -@Composable -actual fun ConfigureSystemUI(darkTheme: Boolean) { - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - val insetsController = WindowCompat.getInsetsController(window, view) - - // isAppearanceLightStatusBars = true → dark icons (for light backgrounds) - // isAppearanceLightStatusBars = false → light icons (for dark backgrounds) - insetsController.isAppearanceLightStatusBars = !darkTheme - insetsController.isAppearanceLightNavigationBars = !darkTheme - } - } -} -``` - -Call from your theme composable: - -```kotlin -@Composable -fun AppTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit -) { - ConfigureSystemUI(darkTheme) - - MaterialTheme( - colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme(), - typography = appTypography(), - content = content - ) -} -``` - -### UI Design Resources - -- **Material Design 3**: https://m3.material.io -- **Compose Material 3**: https://developer.android.com/develop/ui/compose/designsystems/material3 -- **Google Fonts**: https://fonts.google.com -- **Edge-to-Edge Design**: https://developer.android.com/develop/ui/compose/system/system-bars - -## Key Configuration - -### Version Catalog (`gradle/libs.versions.toml`) +### Platform-Specific Code (expect/actual) -Current versions: -- Kotlin: 2.2.20 -- Compose Multiplatform: 1.9.1 -- Android minSdk: 24, targetSdk: 36 -- AndroidX Lifecycle: 2.9.5 +Used for providers requiring native SDKs. Pattern: +- `commonMain/data/GoogleAuthProvider.kt` — `expect class` +- `androidMain/data/GoogleAuthProviderAndroid.kt` — `actual class` +- `iosMain/data/GoogleAuthProviderIOS.kt` — `actual class` -Dependencies are managed through Gradle version catalogs for centralized version control. +`ActivityHolder` (Android only) holds a `WeakReference` to the current `ComponentActivity`. Must call `ActivityHolder.setActivity(this)` in `MainActivity.onCreate()` and `ActivityHolder.clearActivity(this)` in `onDestroy()`. -### Build Configuration (`composeApp/build.gradle.kts`) +### Key Domain Models -- Defines all platform targets (Android, iOS) -- Configures sourceSets and dependencies -- Android namespace: `com.apptolast.spaindecides` +- `AuthResult`: `sealed class` — `Success(session: UserSession)` | `Error(error: AuthError)` +- `AuthError`: typed errors — `InvalidCredentials`, `NetworkError`, `UserNotFound`, `EmailAlreadyInUse`, `Unknown` +- `IdentityProvider`: `Email`, `Google`, `Phone`, `Apple`, `GitHub` (last two pending) +- `AuthState`: `Loading`, `Authenticated(session)`, `Unauthenticated`, `Error` -### Gradle Properties (`gradle.properties`) +### DI (Koin) -- JVM max memory: Configure if needed for large projects -- Configuration cache: Can be enabled for faster builds +- Repositories and providers: `single { }` +- ViewModels: `viewModel { }` (never `single`) +- Library entry point: `initLoginKoin { ... }` — accepts a `KoinAppDeclaration` lambda for the consumer app to add `androidContext` and its own modules. -## Development Notes +### Auth Flow (composeApp) -- All code comments and technical documentation must be in English -- User-facing UI strings can be in Spanish for the target audience -- Project uses Material Design 3 for consistent UI -- Compose Multiplatform enables write-once UI code across all platforms -- Network calls should use Ktor Client (not Retrofit) -- All API communication should go through the Repository pattern -- ViewModels use Kotlin Coroutines and StateFlow for reactive state management -- The main UI entry point is `App.kt` in `commonMain` -- Resources (images, strings, etc.) use Compose Multiplatform resources system: `Res.drawable.*`, `Res.string.*` +`SplashViewModel` observes `AuthRepository.observeAuthState()` eagerly. `App.kt` switches between `AuthNavigation` (unauthenticated) and `MainAppNavigation` (authenticated) using `AnimatedContent`. The splash screen stays visible until `SplashViewModel.isReady` is `true`. -## Useful Resources +## Adding a New Auth Provider -When implementing new features or troubleshooting, consult these official resources: +1. Add `data object NewProvider : IdentityProvider()` in `IdentityProvider.kt` +2. Create `expect class NewAuthProvider()` in `commonMain/data/` +3. Create `actual class` in `androidMain/data/` and `iosMain/data/` +4. Add mapper cases in `DataMapper.kt` if needed +5. Add `IdentityProvider.NewProvider` branch in `AuthRepositoryImpl` +6. Register `single { NewAuthProvider() }` in `DataModule.kt` +7. Add `LoginWithNew : LoginAction` in `LoginAction.kt` +8. Handle the action in `LoginViewModel` +9. Create `DefaultNewButton()` in `DefaultProviders.kt` +10. Add `val newButton: (@Composable (() -> Unit))? = null` in `LoginScreenSlots` +11. Render the slot in `LoginScreen.kt` -### Kotlin Multiplatform -- **Official Guide**: https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html -- **Compose Multiplatform**: https://www.jetbrains.com/compose-multiplatform/ -- **KMP Architecture**: https://kotlinlang.org/docs/multiplatform-mobile-understand-project-structure.html +## Configuration -### Ktor Client -- **Official Documentation**: https://ktor.io/docs/client-create-multiplatform-application.html -- **Content Negotiation**: https://ktor.io/docs/serialization-client.html +`GOOGLE_WEB_CLIENT_ID` is set in `gradle.properties` and injected as a `BuildConfig` field in `custom-login`. For Firebase, place `google-services.json` (Android) in `composeApp/` and `GoogleService-Info.plist` (iOS) in `iosApp/iosApp/`. -### Compose & Android -- **Compose Documentation**: https://developer.android.com/jetpack/compose -- **ViewModel Guide**: https://developer.android.com/topic/libraries/architecture/viewmodel -- **StateFlow**: https://developer.android.com/kotlin/flow/stateflow-and-sharedflow -- **Navigation Compose**: https://developer.android.com/jetpack/compose/navigation +## Architecture Rules (from custom-login-rules.md) -### Dependency Injection -- **Koin Documentation**: https://insert-koin.io -- **Koin KMP Guide**: https://insert-koin.io/docs/reference/koin-mp/kmp/ -- **Koin Compose**: https://insert-koin.io/docs/reference/koin-compose/compose/ +- Presentation ↔ Data must never import each other; domain is the shared contract. +- `AuthRepository` never throws; all errors become `AuthResult.Error(AuthError)`. +- `UiState` is always immutable; effects use `Channel`, never stored in state. +- No platform checks (`if platform == Android`) in `commonMain`; use expect/actual. +- `GlobalScope` is forbidden; always use `viewModelScope.launch { }`. \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index e28ebaf..0000000 --- a/GEMINI.md +++ /dev/null @@ -1,283 +0,0 @@ -# GEMINI.md - -This file provides instructions for **Google Gemini Code Assist** when -generating or modifying code inside this repository.\ -Gemini must follow these rules to maintain consistency with the -project's architecture, style, and patterns. - ------------------------------------------------------------------------- - -# General Guidelines for Gemini - -1. **Do NOT invent APIs or frameworks**\ - Always use real Kotlin, KMP, Compose Multiplatform, Ktor, Koin, and - Navigation Compose APIs. - -2. **Follow the project architecture strictly**\ - Code must follow MVVM, Repository pattern, and the established - folder structure. - -3. **When unsure → ask for clarification**\ - Do not guess requirements or generate extra abstractions. - -4. **Use English for code comments**\ - User-facing text may remain in Spanish. - -5. **Generated code must compile**\ - Ensure imports, namespaces, serialization, DI modules, and - dependencies match the project setup. - ------------------------------------------------------------------------- - -# Project Overview - -SpainDecides is a **Kotlin Multiplatform** project using: - -- Compose Multiplatform (shared UI) -- Ktor Client (network) -- Koin (dependency injection) -- Kotlin Serialization -- MVVM architecture - -Namespace: - - com.apptolast.login - -Targets: - Android (APK) - iOS (Xcode) - ------------------------------------------------------------------------- - -# Build & Run - -## Android - -``` bash -./gradlew :composeApp:assembleDebug -./gradlew :composeApp:assembleRelease -./gradlew :composeApp:installDebug -``` - -## iOS - -Build through Xcode: - -1. Open `/iosApp` -2. Build & run - -Kotlin frameworks: - -``` bash -./gradlew :composeApp:linkDebugFrameworkIosArm64 -./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 -``` - ------------------------------------------------------------------------- - -# Architecture Rules (Gemini MUST follow these) - -The project uses an MVVM layered structure: - - composeApp/src/commonMain/kotlin/com/apptolast/spaindecides/ - ├── presentation/ - │ ├── ui/ - │ │ ├── App.kt - │ │ ├── screens/ - │ │ └── components/ - │ └── viewmodel/ - ├── domain/ - │ └── repository/ - ├── data/ - │ ├── model/ - │ ├── remote/ - │ │ ├── api/ - │ │ └── KtorClient.kt - │ └── repository/ - ├── di/ - └── util/ - -### ViewModels - -- Always expose **StateFlow** -- Use constructor injection -- No navigation logic inside ViewModels -- No Android-specific APIs in shared code - -### Repositories - -- Interface in `domain/repository/` -- Implementation in `data/repository/` - -### API Layer - -- Must use Ktor Client -- DTOs must use kotlinx.serialization - -### UI Layer - -- Screens must be stateless composables -- State must come from Koin-injected ViewModels using - `koinViewModel()` - ------------------------------------------------------------------------- - -# Dependency Injection (Koin) - -Gemini must use Koin, not Hilt. - -### Data module example: - -``` kotlin -val dataModule = module { - single { createHttpClient() } - singleOf(::ApiService) - singleOf(::RepositoryImpl) bind Repository::class -} -``` - -### Presentation module: - -``` kotlin -val presentationModule = module { - viewModelOf(::MainViewModel) -} -``` - -### ViewModel injection in Compose: - -``` kotlin -val viewModel: MainViewModel = koinViewModel() -``` - -### iOS initialization: - -``` swift -KoinInitializerKt.doInitKoin() -``` - ------------------------------------------------------------------------- - -# Navigation (Jetpack Compose + Kotlin Serialization) - -Gemini must use type-safe navigation. - -Routes: - -``` kotlin -@Serializable object HomeRoute -@Serializable data class DetailRoute(val itemId: String) -``` - -Setup: - -``` kotlin -NavHost(navController, startDestination = HomeRoute) { - composable { HomeScreen() } - composable { entry -> - val args: DetailRoute = entry.toRoute() - DetailScreen(itemId = args.itemId) - } -} -``` - ------------------------------------------------------------------------- - -# Network Layer (Ktor Client) - -Correct client configuration: - -``` kotlin -fun createHttpClient() = HttpClient { - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - prettyPrint = true - }) - } - install(Logging) { level = LogLevel.INFO } - install(HttpTimeout) { - requestTimeoutMillis = 30_000 - } -} -``` - -API example: - -``` kotlin -class ApiService(private val http: HttpClient) { - suspend fun getUsers(): Result> = try { - Result.success(http.get("users").body()) - } catch (e: Exception) { - Result.failure(e) - } -} -``` - ------------------------------------------------------------------------- - -# Expect/Actual Rules - -Gemini must use expect/actual **only when needed**, such as: - -- No multiplatform API exists -- Platform-specific APIs are required (NSDate, Vibrations, etc.) - -Gemini should prefer **interfaces** when possible. - ------------------------------------------------------------------------- - -# UI & Theming - -Theme files must be placed in: - - presentation/ui/theme/ - -Gemini must always use: - -``` kotlin -MaterialTheme.colorScheme -MaterialTheme.typography -``` - -Never hardcode colors. - ------------------------------------------------------------------------- - -# Adding New Code (Rules for Gemini) - - Type Location - ---------------------- ------------------------------- - Screen `presentation/ui/screens/` - Component `presentation/ui/components/` - ViewModel `presentation/viewmodel/` - Repository interface `domain/repository/` - Repository impl `data/repository/` - DTO `data/model/` - API `data/remote/api/` - DI module `di/` - ------------------------------------------------------------------------- - -# Gemini Forbidden Actions - -❌ Inventing functions, libraries, or features\ -❌ Hardcoding platform-specific code inside shared `commonMain`\ -❌ Placing files in the wrong folder\ -❌ Adding Hilt, Dagger, or Retrofit\ -❌ Navigation logic in ViewModels\ -❌ Extra layers not required by the project (use-case layer, mappers, -etc.) - ------------------------------------------------------------------------- - -# Gemini Expected Behavior - -✔ Code must compile\ -✔ Match project architecture\ -✔ Use provided dependencies\ -✔ Use real APIs & imports\ -✔ Follow MVVM strictly\ -✔ Keep documentation in English - ------------------------------------------------------------------------- - -# End of GEMINI.md diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 03b5fbd..504fe2b 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -1,6 +1,8 @@ + + = authRepository.observeAuthState() .stateIn( scope = viewModelScope, - started = SharingStarted.Companion.Eagerly, + started = SharingStarted.Eagerly, initialValue = AuthState.Loading ) diff --git a/composeApp/src/commonMain/kotlin/com/apptolast/login/presentation/profile/ProfileContract.kt b/composeApp/src/commonMain/kotlin/com/apptolast/login/presentation/profile/ProfileContract.kt index b990fb9..874fdad 100644 --- a/composeApp/src/commonMain/kotlin/com/apptolast/login/presentation/profile/ProfileContract.kt +++ b/composeApp/src/commonMain/kotlin/com/apptolast/login/presentation/profile/ProfileContract.kt @@ -7,7 +7,7 @@ import com.apptolast.customlogin.domain.model.UserSession */ data class ProfileUiState( val isLoading: Boolean = false, - val userSession: UserSession? = null + val userSession: UserSession? = null, ) /** diff --git a/composeApp/src/commonMain/kotlin/com/apptolast/login/presentation/profile/ProfileViewModel.kt b/composeApp/src/commonMain/kotlin/com/apptolast/login/presentation/profile/ProfileViewModel.kt index c2c1346..8275452 100644 --- a/composeApp/src/commonMain/kotlin/com/apptolast/login/presentation/profile/ProfileViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/apptolast/login/presentation/profile/ProfileViewModel.kt @@ -3,6 +3,7 @@ package com.apptolast.login.presentation.profile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.apptolast.customlogin.domain.AuthRepository +import com.apptolast.customlogin.domain.model.AuthResult import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -40,8 +41,13 @@ class ProfileViewModel( private fun loadCurrentUser() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } - val session = authRepository.getCurrentSession() - _uiState.update { it.copy(isLoading = false, userSession = session) } + when (val result = authRepository.refreshSession()) { + is AuthResult.Success -> { + _uiState.update { it.copy(isLoading = false, userSession = result.session) } + } + is AuthResult.Failure -> {} + else -> {} + } } } diff --git a/composeApp/src/iosMain/kotlin/com/apptolast/login/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/apptolast/login/MainViewController.kt index b4895d1..d8854fe 100644 --- a/composeApp/src/iosMain/kotlin/com/apptolast/login/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/apptolast/login/MainViewController.kt @@ -1,8 +1,6 @@ package com.apptolast.login import androidx.compose.ui.window.ComposeUIViewController -import com.apptolast.customlogin.config.GoogleSignInConfig -import com.apptolast.customlogin.di.LoginLibraryConfig import com.apptolast.customlogin.di.initLoginKoin import com.apptolast.login.di.appModule @@ -23,17 +21,11 @@ private var koinInitialized = false private fun initKoinIfNeeded() { if (!koinInitialized) { - // Configure Google Sign-In with both Web and iOS Client IDs - val loginConfig = LoginLibraryConfig( - googleSignInConfig = GoogleSignInConfig( - webClientId = "495458702268-al98mksrlh27v607972b0oaa0g98pfru.apps.googleusercontent.com", - iosClientId = "495458702268-1ekoub6nmp7hmkhinuasdlup1rke9kg4.apps.googleusercontent.com" - ) - ) - initLoginKoin(config = loginConfig) { + initLoginKoin { modules(appModule) } + koinInitialized = true } } diff --git a/custom-login-rules.md b/custom-login-rules.md new file mode 100644 index 0000000..1d96659 --- /dev/null +++ b/custom-login-rules.md @@ -0,0 +1,655 @@ +# custom-login · Reglas de Arquitectura y Flujo de Desarrollo + +> **Módulo**: `custom-login` (librería KMP) +> **App consumidora**: `composeApp` +> **Package base**: `com.apptolast.customlogin` +> **Stack**: Kotlin Multiplatform · Compose Multiplatform · Koin · Firebase Auth +> **Targets**: Android · iOS · Desktop + +--- + +## 1. Visión General del Proyecto + +Se trata de una **librería KMP de Login reutilizable** (`custom-login`) que puede ser consumida por cualquier aplicación Android/iOS/Desktop. La app `composeApp` es únicamente un ejemplo de consumo. + +### Módulos Gradle +``` +root/ +├── composeApp/ ← App de ejemplo consumidora. Solo integra, no contiene lógica de auth. +└── custom-login/ ← Librería publicable. Todo el código de auth vive aquí. + ├── commonMain/ ← Código compartido entre plataformas + ├── androidMain/ ← Implementaciones específicas Android + └── iosMain/ ← Implementaciones específicas iOS +``` + +--- + +## 2. Estructura de Ficheros + +``` +custom-login/src/ +├── commonMain/kotlin/com/apptolast/customlogin/ +│ ├── Platform.kt ← expect declarations +│ │ +│ ├── data/ +│ │ ├── AuthRepositoryImpl.kt ← impl de AuthRepository +│ │ ├── DataMapper.kt ← conversiones Firebase → domain models +│ │ ├── FirebaseAuthProvider.kt ← email/password via Firebase +│ │ └── PhoneAuthProvider.kt ← expect class (platform-specific) +│ │ +│ ├── di/ +│ │ ├── DataModule.kt ← Koin bindings de data layer +│ │ ├── KoinInitializer.kt ← initKoin() entry point +│ │ └── PresentationModule.kt ← Koin bindings de ViewModels +│ │ +│ ├── domain/ +│ │ ├── AuthProvider.kt ← interface de provider +│ │ ├── AuthRepository.kt ← interface de repository +│ │ └── model/ +│ │ ├── AuthError.kt ← sealed class de errores +│ │ ├── AuthRequest.kt ← request hacia el repository +│ │ ├── AuthResult.kt ← sealed: Success | Error +│ │ ├── AuthState.kt ← estado de sesión global +│ │ ├── IdentityProvider.kt ← sealed class: Google | Email | Phone | Apple | ... +│ │ ├── PhoneAuthResult.kt ← resultado específico OTP +│ │ └── UserSession.kt ← datos del usuario autenticado +│ │ +│ └── presentation/ +│ ├── navigation/ +│ │ ├── NavTransitions.kt ← animaciones de navegación +│ │ ├── RootNavGraph.kt ← grafo de navegación de la librería +│ │ └── Routes.kt ← sealed class de rutas +│ │ +│ ├── screens/ +│ │ ├── components/ ← componentes compartidos entre pantallas +│ │ │ ├── CustomSnackBar.kt +│ │ │ ├── DefaultAuthContainer.kt +│ │ │ ├── DividerContent.kt +│ │ │ ├── HeaderContent.kt +│ │ │ └── RegisterLinkButtonContent.kt +│ │ │ +│ │ ├── login/ ← LoginAction · LoginEffect · LoginLoadingState +│ │ │ LoginMapper · LoginScreen · LoginUiState · LoginViewModel +│ │ ├── register/ ← RegisterAction · RegisterEffect · RegisterMapper +│ │ │ RegisterScreen · RegisterUiState · RegisterViewModel +│ │ ├── forgotpassword/ ← ForgotPasswordAction · Effect · Screen · UiState · ViewModel +│ │ ├── resetpassword/ ← ResetPasswordAction · Effect · Mapper · Screen · UiState · ViewModel +│ │ ├── phone/ ← PhoneAuthAction · Effect · Screen · UiState · ViewModel +│ │ └── welcome/ ← WelcomeScreen (pantalla inicial de bienvenida) +│ │ +│ └── slots/ +│ ├── AuthSlots.kt ← data class con todos los slots configurables +│ └── defaultslots/ +│ ├── DefaultButtons.kt ← botones por defecto (Login, Register, Forgot…) +│ ├── DefaultFields.kt ← campos por defecto (Email, Password, Phone…) +│ ├── DefaultLayouts.kt ← containers y layouts por defecto +│ ├── DefaultLinks.kt ← links por defecto (RegisterLink, ForgotLink…) +│ └── DefaultProviders.kt ← botones de provider (Google, Apple, GitHub…) +│ +├── androidMain/kotlin/com/apptolast/customlogin/ +│ ├── AndroidManifest.xml +│ ├── Platform.android.kt ← actual declarations +│ ├── data/ +│ │ ├── GoogleAuthProviderAndroid.kt ← actual Google Sign In para Android +│ │ └── PhoneAuthProviderAndroid.kt ← actual Phone auth para Android +│ └── platform/ +│ └── ActivityHolder.kt ← referencia weak a la Activity actual +│ +└── iosMain/kotlin/com/apptolast/customlogin/ + ├── Platform.ios.kt ← actual declarations + └── data/ + ├── GoogleAuthProviderIOS.kt ← actual Google Sign In para iOS + └── PhoneAuthProviderIOS.kt ← actual Phone auth para iOS +``` + +--- + +## 3. Capas de Arquitectura + +### Diagrama de dependencias +``` +[ Presentation ] → [ Domain ] ← [ Data ] + ↑ + [ DI ] (orquesta todo, no tiene lógica) +``` + +### Responsabilidades por capa + +| Capa | Responsabilidad | Puede importar | +|------|----------------|----------------| +| **domain** | Contratos (interfaces), modelos puros | Nada externo | +| **data** | Implementaciones, Firebase, mappers | domain únicamente | +| **presentation** | UI, ViewModels, navegación, slots | domain únicamente | +| **di** | Wiring de Koin | data + presentation + domain | + +> **NUNCA** importar clases de `data` en `presentation` ni viceversa. El dominio es el punto de encuentro. + +--- + +## 4. Patrón MVI por Pantalla + +Cada pantalla de la librería sigue MVI estrictamente. + +### Ficheros obligatorios por pantalla + +| Fichero | Tipo | Contenido | +|---------|------|-----------| +| `XxxAction.kt` | `sealed interface` | Intenciones del usuario hacia el ViewModel | +| `XxxUiState.kt` | `data class` inmutable | Estado completo observable de la pantalla | +| `XxxEffect.kt` | `sealed class` | Eventos de una sola vez (nav, snackbar, toast) | +| `XxxViewModel.kt` | `ViewModel` Koin | Lógica, expone StateFlow + Channel de effects | +| `XxxScreen.kt` | `@Composable` | UI que colecta state/effects y recibe slots | +| `XxxMapper.kt` | funciones puras | Convierte errores de dominio a strings UI (si es necesario) | + +### Estructura de ViewModel + +```kotlin +class XxxViewModel( + private val authRepository: AuthRepository, + // otros deps via Koin +) : ViewModel() { + + private val _state = MutableStateFlow(XxxUiState()) + val state: StateFlow = _state.asStateFlow() + + private val _effects = Channel(Channel.BUFFERED) + val effects: Flow = _effects.receiveAsFlow() + + fun handleAction(action: XxxAction) = when (action) { + is XxxAction.SomeAction -> doSomething() + is XxxAction.Navigate -> sendEffect(XxxEffect.NavigateTo(...)) + } + + private fun doSomething() { + viewModelScope.launch { + _state.update { it.copy(isLoading = true) } + val result = authRepository.someOperation() + result.fold( + onSuccess = { session -> + _state.update { it.copy(isLoading = false) } + sendEffect(XxxEffect.NavigateToHome) + }, + onError = { error -> + _state.update { it.copy(isLoading = false, error = XxxMapper.mapError(error)) } + } + ) + } + } + + private fun sendEffect(effect: XxxEffect) { + viewModelScope.launch { _effects.send(effect) } + } +} +``` + +### Estructura de Screen + +```kotlin +@Composable +fun XxxScreen( + slots: AuthSlots = AuthSlots(), + viewModel: XxxViewModel = koinViewModel(), + onNavigateToY: () -> Unit, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.effects.collect { effect -> + when (effect) { + is XxxEffect.NavigateToY -> onNavigateToY() + is XxxEffect.ShowError -> { /* snackbar */ } + } + } + } + + // Usar slots en lugar de componentes hardcoded + slots.container { + slots.header?.invoke() + slots.emailField(state.email) { viewModel.handleAction(XxxAction.EmailChanged(it)) } + slots.loginButton(state.isLoading) { viewModel.handleAction(XxxAction.Submit) } + } +} +``` + +--- + +## 5. Sistema de Slots + +Los slots permiten a la app consumidora personalizar la UI sin modificar la librería. + +### Definición de AuthSlots + +```kotlin +@Stable +data class AuthSlots( + // Layout + val container: @Composable (content: @Composable () -> Unit) -> Unit + = { DefaultAuthContainer(it) }, + val header: (@Composable () -> Unit)? = null, + + // Fields + val emailField: @Composable (String, (String) -> Unit) -> Unit + = { value, onChange -> DefaultEmailField(value, onChange) }, + val passwordField: @Composable (String, (String) -> Unit) -> Unit + = { value, onChange -> DefaultPasswordField(value, onChange) }, + val phoneField: @Composable (String, (String) -> Unit) -> Unit + = { value, onChange -> DefaultPhoneField(value, onChange) }, + + // Buttons + val loginButton: @Composable (Boolean, () -> Unit) -> Unit + = { loading, onClick -> DefaultLoginButton(loading, onClick) }, + val registerButton: @Composable (Boolean, () -> Unit) -> Unit + = { loading, onClick -> DefaultRegisterButton(loading, onClick) }, + + // Social providers + val googleButton: (@Composable (() -> Unit))? = { DefaultGoogleButton(it) }, + val appleButton: (@Composable (() -> Unit))? = null, // null = oculto + val githubButton: (@Composable (() -> Unit))? = null, + + // Extra + val extraProviders: (@Composable () -> Unit)? = null, + + // Links + val forgotPasswordLink: (@Composable (() -> Unit))? = { DefaultForgotPasswordLink(it) }, + val registerLink: (@Composable (() -> Unit))? = { DefaultRegisterLinkButton(it) }, +) +``` + +### Reglas de slots +- **Default siempre funcional**: cada slot tiene un default de `defaultslots/` que funciona sin configuración. +- **Nulo = oculto**: si un slot de provider es `null`, ese botón no se renderiza. +- **Sin lógica de negocio**: los slots solo reciben estado (value) y callbacks. Nunca lanzan corrutinas ni llaman a repositorios. +- **Un slot = una responsabilidad**: no agrupar header + fields en un solo slot. + +### Uso desde la app consumidora + +```kotlin +// composeApp +CustomLoginEntry( + slots = AuthSlots( + header = { MyBrandLogo() }, + loginButton = { loading, onClick -> + MyBrandButton("Entrar", loading = loading, onClick = onClick) + }, + googleButton = { onClick -> MyGoogleButton(onClick) }, + appleButton = { onClick -> MyAppleButton(onClick) }, // activa Apple + ) +) +``` + +--- + +## 6. Domain Models + +### AuthResult (toda operación devuelve esto) +```kotlin +sealed class AuthResult { + data class Success(val session: UserSession) : AuthResult() + data class Error(val error: AuthError) : AuthResult() +} +``` + +### AuthError (errores tipados) +```kotlin +sealed class AuthError { + data object InvalidCredentials : AuthError() + data object NetworkError : AuthError() + data object UserNotFound : AuthError() + data object EmailAlreadyInUse : AuthError() + data class Unknown(val message: String?) : AuthError() + // Añadir nuevos errores específicos de provider aquí +} +``` + +### IdentityProvider (proveedores disponibles) +```kotlin +sealed class IdentityProvider { + data object Email : IdentityProvider() + data object Google : IdentityProvider() + data object Phone : IdentityProvider() + data object Apple : IdentityProvider() // pendiente + data object GitHub : IdentityProvider() // pendiente + // Añadir nuevos aquí +} +``` + +### AuthRequest +```kotlin +data class AuthRequest( + val provider: IdentityProvider, + val email: String? = null, + val password: String? = null, + val phoneNumber: String? = null, + val otpCode: String? = null, + val idToken: String? = null, // para OAuth providers + val accessToken: String? = null, +) +``` + +### UserSession +```kotlin +data class UserSession( + val uid: String, + val email: String?, + val displayName: String?, + val photoUrl: String?, + val provider: IdentityProvider, + val isEmailVerified: Boolean = false, +) +``` + +--- + +## 7. AuthRepository e AuthProvider + +### AuthRepository (interface en domain) +```kotlin +interface AuthRepository { + suspend fun signIn(request: AuthRequest): AuthResult + suspend fun signUp(request: AuthRequest): AuthResult + suspend fun signOut(): AuthResult + suspend fun sendPasswordResetEmail(email: String): AuthResult + suspend fun confirmPasswordReset(code: String, newPassword: String): AuthResult + fun getCurrentSession(): UserSession? + fun observeAuthState(): Flow +} +``` + +### AuthProvider (interface en domain) +```kotlin +interface AuthProvider { + val supportedProviders: List + suspend fun signIn(request: AuthRequest): AuthResult + suspend fun signOut(): AuthResult +} +``` + +### AuthRepositoryImpl (data layer) +- Recibe todos los providers vía Koin +- Delega a cada provider según `request.provider` +- Usa `DataMapper` para convertir respuestas +- Nunca lanza excepciones hacia arriba: las captura y devuelve `AuthResult.Error` + +--- + +## 8. Inyección de Dependencias (Koin) + +### DataModule.kt +```kotlin +val dataModule = module { + single { AuthRepositoryImpl(get(), get(), get()) } + single { FirebaseAuthProvider() } + single { GoogleAuthProvider() } // expect/actual resuelto por plataforma + single { PhoneAuthProvider() } // expect/actual resuelto por plataforma + // Añadir nuevos providers aquí al implementarlos +} +``` + +### PresentationModule.kt +```kotlin +val presentationModule = module { + viewModel { LoginViewModel(get()) } + viewModel { RegisterViewModel(get()) } + viewModel { ForgotPasswordViewModel(get()) } + viewModel { ResetPasswordViewModel(get()) } + viewModel { PhoneAuthViewModel(get()) } + // Añadir nuevo ViewModel aquí al añadir nueva pantalla +} +``` + +### KoinInitializer.kt +```kotlin +fun initKoin(additionalModules: List = emptyList()) { + startKoin { + modules(dataModule + presentationModule + additionalModules) + } +} +``` + +### Reglas de DI +- Repositorios y providers: **`single`** +- ViewModels: **`viewModel { }`** (nunca `single`) +- La app consumidora puede pasar `additionalModules` para extender +- Nunca inyectar `Context` o `Activity` directamente: usar `ActivityHolder` en Android + +--- + +## 9. Expect / Actual (Platform-specific code) + +### Cuándo usar expect/actual +- Auth providers que requieren SDKs nativos (Google, Apple, Phone) +- APIs de sistema (ActivityHolder, biometría, etc.) +- **NUNCA** usar `if (platform == Android)` en commonMain + +### Patrón estándar +```kotlin +// commonMain/data/GoogleAuthProvider.kt +expect class GoogleAuthProvider() : AuthProvider + +// androidMain/data/GoogleAuthProviderAndroid.kt +actual class GoogleAuthProvider : AuthProvider { + private val credentialManager = CredentialManager.create(ActivityHolder.context) + actual override suspend fun signIn(request: AuthRequest): AuthResult { ... } +} + +// iosMain/data/GoogleAuthProviderIOS.kt +actual class GoogleAuthProvider : AuthProvider { + actual override suspend fun signIn(request: AuthRequest): AuthResult { ... } +} +``` + +### ActivityHolder (Android únicamente) +```kotlin +// androidMain/platform/ActivityHolder.kt +object ActivityHolder { + private var _activity: WeakReference? = null + val activity: ComponentActivity get() = _activity?.get() ?: error("Activity not set") + val context: Context get() = activity + fun set(activity: ComponentActivity) { _activity = WeakReference(activity) } +} +``` + +--- + +## 10. DataMapper + +Toda conversión entre datos externos y modelos de dominio ocurre en `DataMapper.kt`: + +```kotlin +object DataMapper { + fun FirebaseUser?.toUserSession(provider: IdentityProvider): UserSession? = this?.let { + UserSession( + uid = uid, + email = email, + displayName = displayName, + photoUrl = photoUrl?.toString(), + provider = provider, + isEmailVerified = isEmailVerified, + ) + } + + fun Exception.toAuthError(): AuthError = when (this) { + is FirebaseAuthInvalidCredentialsException -> AuthError.InvalidCredentials + is FirebaseAuthUserCollisionException -> AuthError.EmailAlreadyInUse + is FirebaseAuthException -> AuthError.Unknown(message) + else -> AuthError.NetworkError + } +} +``` + +--- + +## 11. Navegación + +### Rutas (Routes.kt) +```kotlin +sealed class AuthRoute(val route: String) { + data object Welcome : AuthRoute("welcome") + data object Login : AuthRoute("login") + data object Register : AuthRoute("register") + data object ForgotPassword: AuthRoute("forgot_password") + data object ResetPassword : AuthRoute("reset_password/{code}") + data object PhoneAuth : AuthRoute("phone_auth") + // Añadir nueva ruta aquí si se crea nueva pantalla +} +``` + +### RootNavGraph.kt +- Contiene el `NavHost` interno de la librería +- Recibe `AuthSlots` y los propaga a cada pantalla +- Expone callback `onAuthSuccess: (UserSession) -> Unit` para que la app consumidora reaccione + +### Reglas de navegación +- La navegación entre pantallas de auth es interna a la librería +- La librería no sabe nada del NavGraph de la app consumidora +- La app consumidora recibe `onAuthSuccess` y navega donde quiera + +--- + +## 12. Proveedores Implementados y Pendientes + +| Provider | Status | commonMain | androidMain | iosMain | Slot | +|----------|--------|-----------|-------------|---------|------| +| Email/Password | ✅ DONE | FirebaseAuthProvider | - | - | emailField, passwordField | +| Google | ✅ DONE | expect | GoogleAuthProviderAndroid | GoogleAuthProviderIOS | googleButton | +| Phone/OTP | ⚙️ WIP | PhoneAuthProvider (expect) | PhoneAuthProviderAndroid | PhoneAuthProviderIOS | phoneField | +| Apple | 📋 TODO | - | - | - | appleButton | +| GitHub | 📋 TODO | - | - | - | githubButton | +| Twitter/X | 📋 TODO | - | - | - | twitterButton | +| Facebook | 📋 TODO | - | - | - | facebookButton | +| Microsoft | 📋 TODO | - | - | - | microsoftButton | +| Anonymous | 📋 TODO | - | - | - | anonymousButton | + +--- + +## 13. Checklist para Añadir un Nuevo Provider + +Cuando implementes un nuevo método de autenticación, sigue este orden exacto: + +### Paso 1: Domain +- [ ] Añadir `data object NuevoProvider : IdentityProvider()` en `IdentityProvider.kt` +- [ ] Si tiene errores específicos, añadirlos en `AuthError.kt` + +### Paso 2: Data - commonMain +- [ ] Crear `expect class NuevoAuthProvider() : AuthProvider` en `commonMain/data/` + +### Paso 3: Data - plataformas +- [ ] Crear `actual class NuevoAuthProvider` en `androidMain/data/` +- [ ] Crear `actual class NuevoAuthProvider` en `iosMain/data/` +- [ ] Cada `actual` implementa `signIn(AuthRequest): AuthResult` y `signOut()` + +### Paso 4: DataMapper +- [ ] Añadir caso en `DataMapper` si el provider devuelve tipos específicos + +### Paso 5: AuthRepositoryImpl +- [ ] Añadir `IdentityProvider.NuevoProvider` al `when` que delega a providers + +### Paso 6: DI +- [ ] Registrar `single { NuevoAuthProvider() }` en `DataModule.kt` + +### Paso 7: Presentation - Actions +- [ ] Añadir `data object LoginWithNuevo : LoginAction` en `LoginAction.kt` + +### Paso 8: Presentation - ViewModel +- [ ] Manejar `LoginAction.LoginWithNuevo` en `LoginViewModel.handleAction()` + +### Paso 9: Slots UI +- [ ] Crear `DefaultNuevoButton()` en `DefaultProviders.kt` +- [ ] Añadir `val nuevoButton: (@Composable (() -> Unit))? = null` en `AuthSlots.kt` +- [ ] Renderizar el slot en `LoginScreen.kt` (si no es `null`) + +--- + +## 14. Checklist para Añadir una Nueva Pantalla + +Si se necesita una pantalla completamente nueva (ej: pantalla de selección de proveedor): + +- [ ] Crear directorio `presentation/screens/nuevapantalla/` +- [ ] Crear `NuevaPantallaAction.kt` (sealed interface) +- [ ] Crear `NuevaPantallaUiState.kt` (data class) +- [ ] Crear `NuevaPantallaEffect.kt` (sealed class) +- [ ] Crear `NuevaPantallaViewModel.kt` (ViewModel Koin) +- [ ] Crear `NuevaPantallaScreen.kt` (@Composable con slots) +- [ ] Crear `NuevaPantallaMapper.kt` si hay conversiones (opcional) +- [ ] Añadir ruta en `Routes.kt` +- [ ] Añadir composable en `RootNavGraph.kt` +- [ ] Registrar ViewModel en `PresentationModule.kt` + +--- + +## 15. Convenciones de Código + +### Nombrado +- **Pantallas**: `XxxScreen.kt` — `@Composable fun XxxScreen(...)` +- **ViewModels**: `XxxViewModel.kt` — `class XxxViewModel : ViewModel()` +- **Actions**: `XxxAction.kt` — `sealed interface XxxAction` +- **Effects**: `XxxEffect.kt` — `sealed class XxxEffect` +- **UiState**: `XxxUiState.kt` — `data class XxxUiState(...)` +- **Providers**: `XxxAuthProvider.kt` → `XxxAuthProviderAndroid.kt` / `XxxAuthProviderIOS.kt` + +### Corrutinas +- Siempre en `viewModelScope.launch { }` dentro del ViewModel +- Operaciones de I/O en `Dispatchers.IO` (o el dispatcher del repositorio) +- **Nunca** `GlobalScope` ni corrutinas sin scope controlado + +### Manejo de errores +- El repositorio **nunca** lanza excepciones hacia el ViewModel +- Toda excepción interna se captura y convierte a `AuthResult.Error(AuthError)` +- El ViewModel actualiza `UiState.error` o envía `Effect.ShowError` + +### StateFlow y Channel +```kotlin +// Estado observable (múltiples colectores, último valor cacheado) +val state: StateFlow + +// Effects de una sola vez (un único colector, sin caché) +val effects: Flow // via Channel.receiveAsFlow() +``` + +--- + +## 16. Integración desde la App Consumidora + +### Mínimo necesario en composeApp + +```kotlin +// Application.kt +class MyApp : Application() { + override fun onCreate() { + super.onCreate() + initKoin() // inicializa custom-login + } +} + +// MainActivity.kt +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ActivityHolder.set(this) // necesario para Google/Phone en Android + + setContent { + CustomLoginNavHost( + slots = AuthSlots(/* personalizaciones opcionales */), + onAuthSuccess = { session -> + // navegar a la app principal + } + ) + } + } +} +``` + +--- + +## Resumen de Reglas (TL;DR) + +1. **Dependencias unidireccionales**: Presentation → Domain ← Data. DI orquesta, nunca contiene lógica. +2. **Interfaces en domain**: `AuthRepository` y `AuthProvider` son interfaces. Nunca clases concretas en domain. +3. **MVI estricto**: Cada pantalla tiene Action + UiState + Effect + ViewModel + Screen. Sin excepciones. +4. **UiState inmutable**: Siempre `data class` con `val`. Se actualiza con `copy()`. +5. **Effects con Channel**: Navegación y snackbars por `Channel`, no en UiState. +6. **Expect/Actual para plataformas**: Nunca condiciones de plataforma en commonMain. +7. **Slots con defaults**: Cada slot tiene un composable por defecto. `null` = oculto. +8. **DataMapper centralizado**: Solo `DataMapper.kt` convierte entre Firebase types y domain models. +9. **Koin scopes correctos**: Repos/providers = `single`, ViewModels = `viewModel { }`. +10. **AuthResult siempre**: Toda operación devuelve `AuthResult`. Nunca lanzar excepciones hacia arriba. diff --git a/custom-login/.gitignore b/custom-login/.gitignore index 42afabf..5b5cafb 100644 --- a/custom-login/.gitignore +++ b/custom-login/.gitignore @@ -1 +1,565 @@ -/build \ No newline at end of file +*.iml +.kotlin +.gradle +**/build/ +xcuserdata +!src/**/build/ +local.properties +.idea +.DS_Store +captures +.externalNativeBuild +.cxx +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +**/xcshareddata/WorkspaceSettings.xcsettings + + +# Created by https://www.toptal.com/developers/gitignore/api/android,androidstudio,git,gradle,maven,macos,windows,firebase +# Edit at https://www.toptal.com/developers/gitignore?templates=android,androidstudio,git,gradle,maven,macos,windows,firebase + +### Android ### +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log/OS Files +*.log + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.apk +output.json + +# IntelliJ +*.iml +.idea/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof + +### Android Patch ### +gen-external-apklibs + +# Replacement of .externalNativeBuild directories introduced +# with Android Studio 3.5. + +### CocoaPods ### +## CocoaPods GitIgnore Template + +# CocoaPods - Only use to conserve bandwidth / Save time on Pushing +# - Also handy if you have a large number of dependant pods +# - AS PER https://guides.cocoapods.org/using/using-cocoapods.html NEVER IGNORE THE LOCK FILE +Pods/ + +### Firebase ### +.idea +**/node_modules/* +**/.firebaserc + +### Firebase Patch ### +.runtimeconfig.json +.firebase/ + +### Git ### +# Created by git for backups. To disable backups in Git: +# $ git config --global mergetool.keepBackup false +*.orig + +# Created by git when using merge tools for conflicts +*.BACKUP.* +*.BASE.* +*.LOCAL.* +*.REMOTE.* +*_BACKUP_*.txt +*_BASE_*.txt +*_LOCAL_*.txt +*_REMOTE_*.txt + +### JetBrains ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### JetBrains Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Kotlin ### +# Compiled class file +*.class + +# Log file + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Maven ### +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +# https://github.com/takari/maven-wrapper#usage-without-binary-jar +.mvn/wrapper/maven-wrapper.jar + +# Eclipse m2e generated files +# Eclipse Core +.project +# JDT-specific (Eclipse Java Development Tools) +.classpath + +### Swift ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# Pods/ +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### Xcode ### + +## Xcode 8 and earlier + +### Xcode Patch ### +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno +**/xcshareddata/WorkspaceSettings.xcsettings + +### Gradle ### +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties + +# Cache of project +.gradletasknamecache + +# Eclipse Gradle plugin generated files +# Eclipse Core +# JDT-specific (Eclipse Java Development Tools) + +### Gradle Patch ### +# Java heap dump + +### AndroidStudio ### +# Covers files to be ignored for android development using Android Studio. + +# Built application files +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ + +# Gradle files + +# Signing files +.signing/ + +# Local configuration file (sdk path, etc) + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files + +# Android Studio +/*/build/ +/*/local.properties +/*/out +/*/*/build +/*/*/production +.navigation/ +*.ipr +*~ +*.swp + +# Keystore files + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Android Patch + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild + +# NDK +obj/ + +# IntelliJ IDEA +*.iws +/out/ + +# User-specific configurations +.idea/caches/ +.idea/libraries/ +.idea/shelf/ +.idea/workspace.xml +.idea/tasks.xml +.idea/.name +.idea/compiler.xml +.idea/copyright/profiles_settings.xml +.idea/encodings.xml +.idea/misc.xml +.idea/modules.xml +.idea/scopes/scope_settings.xml +.idea/dictionaries +.idea/vcs.xml +.idea/jsLibraryMappings.xml +.idea/datasources.xml +.idea/dataSources.ids +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml +.idea/assetWizardSettings.xml +.idea/gradle.xml +.idea/jarRepositories.xml +.idea/navEditor.xml + +# Legacy Eclipse project files +.cproject +.settings/ + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.war +*.ear + +# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml) +hs_err_pid* + +## Plugin-specific files: + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Mongo Explorer plugin +.idea/mongoSettings.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### AndroidStudio Patch ### + +!/gradle/wrapper/gradle-wrapper.jar + +# End of https://www.toptal.com/developers/gitignore/api/android,androidstudio,git,gradle,maven,macos,windows,firebase + +# Kotzilla +/composeApp/kotzilla.json +/composeApp/src/main/assets/kotzilla.key + +# Firebase on iOS +iosApp/iosApp/GoogleService-Info.plist +/shared/schemas/ + +# Secret files +gradle.properties +iosApp/Configuration/Config.xcconfig +iosApp/iosApp/Info.plist diff --git a/custom-login/build.gradle.kts b/custom-login/build.gradle.kts index 3dfb0f4..aa9f245 100644 --- a/custom-login/build.gradle.kts +++ b/custom-login/build.gradle.kts @@ -73,8 +73,21 @@ android { namespace = "com.apptolast.customlogin" compileSdk = libs.versions.android.compileSdk.get().toInt() + buildFeatures { + buildConfig = true + } + defaultConfig { minSdk = libs.versions.android.minSdk.get().toInt() + + val googleWebClientId: String = + project.findProperty("GOOGLE_WEB_CLIENT_ID") as String? ?: "" + + buildConfigField( + "String", + "GOOGLE_WEB_CLIENT_ID", + "\"$googleWebClientId\"" + ) } compileOptions { diff --git a/custom-login/custom_login.podspec b/custom-login/custom_login.podspec new file mode 100644 index 0000000..d20d0f6 --- /dev/null +++ b/custom-login/custom_login.podspec @@ -0,0 +1,55 @@ +Pod::Spec.new do |spec| + spec.name = 'custom_login' + spec.version = '1.0.0' + spec.homepage = 'https.apptolast.com' + spec.source = { :http=> ''} + spec.authors = '' + spec.license = '' + spec.summary = 'Login con firebase' + spec.vendored_frameworks = 'build/cocoapods/framework/composeApp.framework' + spec.libraries = 'c++' + spec.ios.deployment_target = '18.2' + spec.dependency 'FirebaseAuth' + spec.dependency 'FirebaseCore' + + if !Dir.exist?('build/cocoapods/framework/composeApp.framework') || Dir.empty?('build/cocoapods/framework/composeApp.framework') + raise " + + Kotlin framework 'composeApp' doesn't exist yet, so a proper Xcode project can't be generated. + 'pod install' should be executed after running ':generateDummyFramework' Gradle task: + + ./gradlew :custom-login:generateDummyFramework + + Alternatively, proper pod installation is performed during Gradle sync in the IDE (if Podfile location is set)" + end + + spec.xcconfig = { + 'ENABLE_USER_SCRIPT_SANDBOXING' => 'NO', + } + + spec.pod_target_xcconfig = { + 'KOTLIN_PROJECT_PATH' => ':custom-login', + 'PRODUCT_MODULE_NAME' => 'composeApp', + } + + spec.script_phases = [ + { + :name => 'Build custom_login', + :execution_position => :before_compile, + :shell_path => '/bin/sh', + :script => <<-SCRIPT + if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then + echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\"" + exit 0 + fi + set -ev + REPO_ROOT="$PODS_TARGET_SRCROOT" + "$REPO_ROOT/../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \ + -Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \ + -Pkotlin.native.cocoapods.archs="$ARCHS" \ + -Pkotlin.native.cocoapods.configuration="$CONFIGURATION" + SCRIPT + } + ] + spec.resources = ['build\compose\cocoapods\compose-resources'] +end \ No newline at end of file diff --git a/custom-login/src/androidMain/AndroidManifest.xml b/custom-login/src/androidMain/AndroidManifest.xml index 294dbaa..a880029 100644 --- a/custom-login/src/androidMain/AndroidManifest.xml +++ b/custom-login/src/androidMain/AndroidManifest.xml @@ -1,5 +1,4 @@ - \ No newline at end of file diff --git a/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/Platform.android.kt b/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/Platform.android.kt index 1cc9718..ea66a95 100644 --- a/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/Platform.android.kt +++ b/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/Platform.android.kt @@ -1,16 +1,8 @@ package com.apptolast.customlogin import android.content.Context -import com.apptolast.customlogin.config.GoogleSignInConfig import com.apptolast.customlogin.domain.model.IdentityProvider -import com.apptolast.customlogin.provider.GoogleSignInProviderAndroid -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject - -/** - * Android-specific implementation of the common `expect` declarations. - */ -actual fun platform(): String = "Android ${android.os.Build.VERSION.SDK_INT}" +import com.apptolast.customlogin.data.GoogleAuthProviderAndroid /** * The Android context, required for many platform-specific operations. @@ -18,45 +10,24 @@ actual fun platform(): String = "Android ${android.os.Build.VERSION.SDK_INT}" */ lateinit var appContext: Context -/** - * Helper object for Koin dependency injection in platform code. - */ -private object PlatformKoinHelper : KoinComponent { - val googleSignInConfig: GoogleSignInConfig? by lazy { - try { - val config: GoogleSignInConfig by inject() - config - } catch (e: Exception) { - null - } - } -} - /** * Actual implementation for getting a social ID token on Android. */ actual suspend fun getSocialIdToken(provider: IdentityProvider): String? { return when (provider) { is IdentityProvider.Google -> { - val config = PlatformKoinHelper.googleSignInConfig - if (config == null) { - println("Google Sign-In is not configured. Provide GoogleSignInConfig in LoginLibraryConfig.") - return null - } - - val googleProvider = GoogleSignInProviderAndroid( - config = config, - context = appContext - ) + val googleProvider = GoogleAuthProviderAndroid(appContext) googleProvider.signIn() } + is IdentityProvider.GitHub -> { // TODO: Implement GitHub OAuth flow for Android. println("GitHub Sign-In for Android is not implemented yet.") null } + else -> { - println("Social sign-in for ${provider.id} is not implemented on Android yet.") + println("Social sign-in for ${provider::class.simpleName} is not implemented on Android yet.") null } } diff --git a/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/data/AppleAuthProvider.kt b/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/data/AppleAuthProvider.kt new file mode 100644 index 0000000..3f982ed --- /dev/null +++ b/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/data/AppleAuthProvider.kt @@ -0,0 +1,22 @@ +package com.apptolast.customlogin.data + +import com.apptolast.customlogin.domain.model.AuthError +import com.apptolast.customlogin.domain.model.AuthRequest +import com.apptolast.customlogin.domain.model.AuthResult +import com.apptolast.customlogin.domain.model.IdentityProvider + +actual class AppleAuthProvider { + actual val supportedProviders: List = listOf(IdentityProvider.Apple) + + actual suspend fun signIn(request: AuthRequest): AuthResult { + // On Android, Apple Sign-In is typically handled via Firebase Auth, which opens a web view. + // This can be triggered from the FirebaseAuthProvider or a dedicated web flow. + // For now, we'll return a 'not implemented' error. + return AuthResult.Failure(AuthError.OperationNotAllowed("Apple Sign-In is not implemented on Android yet.")) + } + + actual suspend fun signOut(): AuthResult { + // No specific session to clear for Apple on Android in isolation. + return AuthResult.SignOutSuccess + } +} diff --git a/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/data/GoogleAuthProvider.kt b/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/data/GoogleAuthProvider.kt new file mode 100644 index 0000000..ef0cbce --- /dev/null +++ b/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/data/GoogleAuthProvider.kt @@ -0,0 +1,21 @@ +package com.apptolast.customlogin.data + +import com.apptolast.customlogin.domain.AuthProvider +import com.apptolast.customlogin.domain.model.AuthError +import com.apptolast.customlogin.domain.model.AuthRequest +import com.apptolast.customlogin.domain.model.AuthResult +import com.apptolast.customlogin.domain.model.IdentityProvider + +actual class GoogleAuthProvider : AuthProvider { + actual override val supportedProviders: List = listOf(IdentityProvider.Google) + + actual override suspend fun signIn(request: AuthRequest): AuthResult { + // Actual implementation will use the Google Sign-In SDK + return AuthResult.Failure(AuthError.OperationNotAllowed("Google Sign-In is not implemented on Android yet.")) + } + + actual override suspend fun signOut(): AuthResult { + // Actual implementation will sign out from the Google SDK + return AuthResult.SignOutSuccess + } +} \ No newline at end of file diff --git a/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/data/GoogleAuthProviderAndroid.kt b/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/data/GoogleAuthProviderAndroid.kt new file mode 100644 index 0000000..979d61c --- /dev/null +++ b/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/data/GoogleAuthProviderAndroid.kt @@ -0,0 +1,39 @@ +package com.apptolast.customlogin.data + +import android.content.Context +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.exceptions.GetCredentialException +import com.apptolast.customlogin.BuildConfig +import com.apptolast.customlogin.platform.ActivityHolder +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential + +/** + * Android-specific helper for Google Sign-In using the Credential Manager API. + * Returns the Google ID token upon successful authentication, or null on failure/cancellation. + */ +internal class GoogleAuthProviderAndroid(private val context: Context) { + + suspend fun signIn(): String? { + val credentialManager = CredentialManager.create(context) + val googleIdOption = GetGoogleIdOption.Builder() + .setFilterByAuthorizedAccounts(false) + .setServerClientId(BuildConfig.GOOGLE_WEB_CLIENT_ID) + .build() + val request = GetCredentialRequest.Builder() + .addCredentialOption(googleIdOption) + .build() + + return try { + val result = credentialManager.getCredential( + context = ActivityHolder.requireActivity(), + request = request, + ) + GoogleIdTokenCredential.createFrom(result.credential.data).idToken + } catch (e: GetCredentialException) { + println("Google Sign-In failed: ${e.message}") + null + } + } +} \ No newline at end of file diff --git a/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/data/PhoneAuthProviderAndroid.kt b/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/data/PhoneAuthProviderAndroid.kt new file mode 100644 index 0000000..75190f0 --- /dev/null +++ b/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/data/PhoneAuthProviderAndroid.kt @@ -0,0 +1,34 @@ +package com.apptolast.customlogin.data + +import com.apptolast.customlogin.domain.AuthProvider +import com.apptolast.customlogin.domain.model.AuthError +import com.apptolast.customlogin.domain.model.AuthRequest +import com.apptolast.customlogin.domain.model.AuthResult +import com.apptolast.customlogin.domain.model.IdentityProvider +import com.apptolast.customlogin.domain.model.PhoneAuthResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +actual class PhoneAuthProvider : AuthProvider { + actual override val supportedProviders: List = listOf(IdentityProvider.Phone) + + actual override suspend fun signIn(request: AuthRequest): AuthResult { + // This operation should be handled by signInWithVerification after the OTP code is received. + return AuthResult.Failure(AuthError.OperationNotAllowed("Use verifyPhoneNumber and signInWithVerification for phone auth.")) + } + + actual override suspend fun signOut(): AuthResult { + // Phone auth sign out is managed by the main Firebase session + return AuthResult.SignOutSuccess + } + + actual fun verifyPhoneNumber(phoneNumber: String): Flow { + // Actual implementation will call the Firebase SDK + TODO("Not yet implemented") + } + + actual suspend fun signInWithVerification(verificationId: String, code: String): AuthResult { + // Actual implementation will call the Firebase SDK + TODO("Not yet implemented") + } +} diff --git a/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/provider/GoogleSignInProviderAndroid.kt b/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/provider/GoogleSignInProviderAndroid.kt deleted file mode 100644 index abcc980..0000000 --- a/custom-login/src/androidMain/kotlin/com/apptolast/customlogin/provider/GoogleSignInProviderAndroid.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.apptolast.customlogin.provider - -import android.content.Context -import androidx.credentials.CredentialManager -import androidx.credentials.CustomCredential -import androidx.credentials.GetCredentialRequest -import androidx.credentials.GetCredentialResponse -import androidx.credentials.exceptions.GetCredentialCancellationException -import androidx.credentials.exceptions.GetCredentialException -import androidx.credentials.exceptions.NoCredentialException -import com.apptolast.customlogin.config.GoogleSignInConfig -import com.apptolast.customlogin.platform.ActivityHolder -import com.google.android.libraries.identity.googleid.GetGoogleIdOption -import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential - -/** - * Android implementation of Google Sign-In using Credential Manager API. - * - * @property config The Google Sign-In configuration containing the web client ID. - * @property context The Android application context. - */ -class GoogleSignInProviderAndroid( - private val config: GoogleSignInConfig, - private val context: Context -) { - private val credentialManager: CredentialManager by lazy { - CredentialManager.create(context) - } - - /** - * Initiates the Google Sign-In flow and returns the ID token. - * - * @return The Google ID token on success, or null if cancelled/failed. - */ - suspend fun signIn(): String? { - return try { - val activity = ActivityHolder.requireActivity() - - val googleIdOption = GetGoogleIdOption.Builder() - .setServerClientId(config.webClientId) - .setFilterByAuthorizedAccounts(false) - .setAutoSelectEnabled(true) - .build() - - val request = GetCredentialRequest.Builder() - .addCredentialOption(googleIdOption) - .build() - - val result: GetCredentialResponse = credentialManager.getCredential( - context = activity, - request = request - ) - - handleSignInResult(result) - } catch (e: GetCredentialCancellationException) { - // User cancelled the sign-in flow - println("Google Sign-In cancelled by user") - null - } catch (e: NoCredentialException) { - // No credentials available - println("No Google credentials available: ${e.message}") - null - } catch (e: GetCredentialException) { - // Other credential errors - println("Google Sign-In failed: ${e.message}") - null - } catch (e: IllegalStateException) { - // ActivityHolder doesn't have an activity - println("Google Sign-In failed: ${e.message}") - null - } - } - - /** - * Handles the credential response and extracts the ID token. - */ - private fun handleSignInResult(result: GetCredentialResponse): String? { - val credential = result.credential - - return when (credential) { - is CustomCredential -> { - if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) { - val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data) - googleIdTokenCredential.idToken - } else { - println("Unexpected credential type: ${credential.type}") - null - } - } - - else -> { - println("Unexpected credential class: ${credential.javaClass.name}") - null - } - } - } -} diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/Platform.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/Platform.kt index 68b9bf9..7f69037 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/Platform.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/Platform.kt @@ -2,8 +2,6 @@ package com.apptolast.customlogin import com.apptolast.customlogin.domain.model.IdentityProvider -expect fun platform(): String - /** * Initiates a platform-specific social sign-in flow to get an ID token. * This common `expect` function is implemented in each platform's `actual` function. diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/config/GoogleSignInConfig.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/config/GoogleSignInConfig.kt deleted file mode 100644 index 071be8d..0000000 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/config/GoogleSignInConfig.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.apptolast.customlogin.config - -/** - * Configuration for Google Sign-In. - * - * @property webClientId The OAuth 2.0 Web Client ID from Firebase/Google Cloud Console. - * This is required for both Android and iOS to get an ID token. - * @property iosClientId The iOS Client ID from Firebase/Google Cloud Console. - * Only required for iOS platform. - */ -data class GoogleSignInConfig( - val webClientId: String, - val iosClientId: String? = null -) diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/AppleAuthProvider.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/AppleAuthProvider.kt new file mode 100644 index 0000000..ff0a68b --- /dev/null +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/AppleAuthProvider.kt @@ -0,0 +1,12 @@ +package com.apptolast.customlogin.data + +import com.apptolast.customlogin.domain.AuthProvider +import com.apptolast.customlogin.domain.model.AuthRequest +import com.apptolast.customlogin.domain.model.AuthResult +import com.apptolast.customlogin.domain.model.IdentityProvider + +expect class AppleAuthProvider() : AuthProvider { + override val supportedProviders: List + override suspend fun signIn(request: AuthRequest): AuthResult + override suspend fun signOut(): AuthResult +} diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/AuthRepositoryImpl.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/AuthRepositoryImpl.kt index c334fa9..2e5caad 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/AuthRepositoryImpl.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/AuthRepositoryImpl.kt @@ -1,86 +1,72 @@ package com.apptolast.customlogin.data -import com.apptolast.customlogin.domain.AuthProvider import com.apptolast.customlogin.domain.AuthRepository +import com.apptolast.customlogin.domain.model.AuthError +import com.apptolast.customlogin.domain.model.AuthRequest import com.apptolast.customlogin.domain.model.AuthResult import com.apptolast.customlogin.domain.model.AuthState -import com.apptolast.customlogin.domain.model.Credentials -import com.apptolast.customlogin.domain.model.PasswordResetData -import com.apptolast.customlogin.domain.model.SignUpData +import com.apptolast.customlogin.domain.model.IdentityProvider import com.apptolast.customlogin.domain.model.UserSession import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf -/** - * Implementation of AuthRepository that delegates to an AuthProvider. - * This allows swapping between Firebase, Supabase, or custom backends. - */ class AuthRepositoryImpl( - private val authProvider: AuthProvider + private val firebaseProvider: FirebaseAuthProvider, + private val googleProvider: GoogleAuthProvider, + private val appleProvider: AppleAuthProvider, + private val phoneProvider: PhoneAuthProvider, ) : AuthRepository { - override val currentProviderId: String - get() = authProvider.id + // Holds the last successful provider for session management + private var activeProvider: com.apptolast.customlogin.domain.AuthProvider? = null - override fun observeAuthState(): Flow { - return authProvider.observeAuthState() - } + override suspend fun signIn(request: AuthRequest): AuthResult { + val provider = providerFor(request.provider) + ?: return AuthResult.Failure(AuthError.OperationNotAllowed("Provider not supported: ${request.provider}")) - override suspend fun getCurrentSession(): UserSession? { - return when (val result = authProvider.refreshSession()) { - is AuthResult.Success -> result.session - else -> null + return provider.signIn(request).also { result -> + if (result is AuthResult.Success) { + activeProvider = provider + } } } - override suspend fun signIn(credentials: Credentials): AuthResult { - return authProvider.signIn(credentials) - } - - override suspend fun signUp(data: SignUpData): AuthResult { - return authProvider.signUp(data) + override suspend fun signUp(request: AuthRequest): AuthResult { + // Sign up is currently only supported via Email/Password + return firebaseProvider.signUp(request) } - override suspend fun signOut(): Result { - return authProvider.signOut() + override suspend fun signOut(): AuthResult { + return activeProvider?.signOut() ?: AuthResult.SignOutSuccess } override suspend fun sendPasswordResetEmail(email: String): AuthResult { - return authProvider.sendPasswordResetEmail(email) - } - - override suspend fun confirmPasswordReset(data: PasswordResetData): AuthResult { - return authProvider.confirmPasswordReset(data.code, data.newPassword) - } - - override suspend fun refreshSession(): AuthResult { - return authProvider.refreshSession() + return firebaseProvider.sendPasswordResetEmail(email) } - override suspend fun isSignedIn(): Boolean { - return authProvider.isSignedIn() + override suspend fun confirmPasswordReset(code: String, newPassword: String): AuthResult { + return firebaseProvider.confirmPasswordReset(code, newPassword) } - override suspend fun getIdToken(forceRefresh: Boolean): String? { - return authProvider.getIdToken(forceRefresh) + override fun getCurrentSession(): UserSession? { + // This is a simplification. A real implementation might need to check + // the session status from the active provider. + return null } - override suspend fun deleteAccount(): Result { - return authProvider.deleteAccount() - } - - override suspend fun updateDisplayName(displayName: String): Result { - return authProvider.updateDisplayName(displayName) - } - - override suspend fun updateEmail(newEmail: String): Result { - return authProvider.updateEmail(newEmail) - } - - override suspend fun updatePassword(newPassword: String): Result { - return authProvider.updatePassword(newPassword) + override fun observeAuthState(): Flow { + // A real implementation would need to merge flows from all providers. + // For now, we only observe Firebase auth state. + return flowOf(AuthState.Unauthenticated) } - override suspend fun sendEmailVerification(): Result { - return authProvider.sendEmailVerification() + private fun providerFor(identityProvider: IdentityProvider): com.apptolast.customlogin.domain.AuthProvider? { + return when (identityProvider) { + is IdentityProvider.Email -> firebaseProvider + is IdentityProvider.Google -> googleProvider + is IdentityProvider.Apple -> appleProvider + is IdentityProvider.Phone -> phoneProvider + else -> null + } } -} \ No newline at end of file +} diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/DataMapper.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/DataMapper.kt index 39fd728..e9e5a3c 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/DataMapper.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/DataMapper.kt @@ -2,9 +2,13 @@ package com.apptolast.customlogin.data import com.apptolast.customlogin.data.FirebaseAuthProvider.Companion.PROVIDER_ID import com.apptolast.customlogin.domain.model.AuthError +import com.apptolast.customlogin.domain.model.IdentityProvider import com.apptolast.customlogin.domain.model.UserSession +import dev.gitlive.firebase.auth.AuthCredential import dev.gitlive.firebase.auth.FirebaseAuthException import dev.gitlive.firebase.auth.FirebaseUser +import dev.gitlive.firebase.auth.GithubAuthProvider +import dev.gitlive.firebase.auth.GoogleAuthProvider /** * Maps a [FirebaseUser] to a domain [UserSession] object. @@ -69,3 +73,22 @@ internal fun FirebaseAuthException.toAuthError(): AuthError { else -> AuthError.Unknown(errorMessage, this) } } + +/** + * Maps an [IdentityProvider] and its corresponding token data to a Firebase [AuthCredential]. + */ +internal fun IdentityProvider.toCredential(tokenData: String): AuthCredential? { + return when (this) { + is IdentityProvider.Google -> { + // For iOS, the tokenData might be "idToken|||accessToken|||accessTokenValue" + // For Android, it's just the idToken. + val parts = tokenData.split("|||accessToken|||") + val idToken = parts[0] + val accessToken = parts.getOrNull(1) + GoogleAuthProvider.credential(idToken, accessToken) + } + + is IdentityProvider.GitHub -> GithubAuthProvider.credential(tokenData) + else -> null // Other providers like Apple or Phone have different credential flows. + } +} diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/FirebaseAuthProvider.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/FirebaseAuthProvider.kt index 2a45c05..bc3ef9b 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/FirebaseAuthProvider.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/FirebaseAuthProvider.kt @@ -2,237 +2,83 @@ package com.apptolast.customlogin.data import com.apptolast.customlogin.domain.AuthProvider import com.apptolast.customlogin.domain.model.AuthError +import com.apptolast.customlogin.domain.model.AuthRequest import com.apptolast.customlogin.domain.model.AuthResult -import com.apptolast.customlogin.domain.model.AuthState -import com.apptolast.customlogin.domain.model.Credentials import com.apptolast.customlogin.domain.model.IdentityProvider -import com.apptolast.customlogin.domain.model.SignUpData -import com.apptolast.customlogin.getSocialIdToken -import dev.gitlive.firebase.auth.AuthCredential +import dev.gitlive.firebase.Firebase import dev.gitlive.firebase.auth.FirebaseAuth import dev.gitlive.firebase.auth.FirebaseAuthException -import dev.gitlive.firebase.auth.GithubAuthProvider -import dev.gitlive.firebase.auth.GoogleAuthProvider -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart +import dev.gitlive.firebase.auth.auth /** - * Firebase Authentication provider implementation. - * Uses GitLive Firebase SDK for multiplatform support. + * AuthProvider implementation for Firebase Email/Password authentication. */ -class FirebaseAuthProvider( - private val firebaseAuth: FirebaseAuth -) : AuthProvider { +class FirebaseAuthProvider : AuthProvider { - override val id: String = PROVIDER_ID - override val displayName: String = "Firebase" + private val firebaseAuth: FirebaseAuth = Firebase.auth - override suspend fun signIn(credentials: Credentials): AuthResult { - return when (credentials) { - is Credentials.EmailPassword -> signInWithEmail(credentials) - is Credentials.OAuthToken -> signInWithOAuth(credentials.provider) - is Credentials.RefreshToken -> refreshSession() - } - } + override val supportedProviders: List = listOf(IdentityProvider.Email) - private suspend fun signInWithEmail(credentials: Credentials.EmailPassword): AuthResult { - return try { - val result = firebaseAuth.signInWithEmailAndPassword( - credentials.email, - credentials.password - ) - result.user?.toUserSession()?.let { session -> - AuthResult.Success(session) - } ?: AuthResult.Failure(AuthError.Unknown("No user returned after sign in")) - } catch (e: FirebaseAuthException) { - AuthResult.Failure(e.toAuthError()) - } catch (e: Exception) { - AuthResult.Failure(AuthError.Unknown(e.message ?: "Sign in failed", e)) - } - } + override suspend fun signIn(request: AuthRequest): AuthResult { + val email = request.email ?: return AuthResult.Failure(AuthError.InvalidRequest("Email is required.")) + val password = request.password ?: return AuthResult.Failure(AuthError.InvalidRequest("Password is required.")) - private suspend fun signInWithOAuth(provider: IdentityProvider): AuthResult { return try { - val idToken = getSocialIdToken(provider) - ?: return AuthResult.Failure(AuthError.Unknown("Social sign-in cancelled or failed.")) - - val credential = provider.toCredential(idToken) - ?: return AuthResult.Failure(AuthError.OperationNotAllowed("Provider not supported: ${provider.id}")) - - val result = firebaseAuth.signInWithCredential(credential) - result.user?.toUserSession()?.let { session -> - AuthResult.Success(session) - } ?: AuthResult.Failure(AuthError.Unknown("No user returned after social sign in")) + val result = firebaseAuth.signInWithEmailAndPassword(email, password) + DataMapper.toUserSession(result.user, IdentityProvider.Email)?.let { + AuthResult.Success(it) + } ?: AuthResult.Failure(AuthError.Unknown("Firebase returned no user.")) } catch (e: FirebaseAuthException) { - AuthResult.Failure(e.toAuthError()) + AuthResult.Failure(DataMapper.toAuthError(e)) } catch (e: Exception) { - AuthResult.Failure(AuthError.Unknown(e.message ?: "OAuth sign in failed", e)) + AuthResult.Failure(AuthError.Unknown(e.message)) } } - override suspend fun signUp(data: SignUpData): AuthResult { + suspend fun signUp(request: AuthRequest): AuthResult { + val email = request.email ?: return AuthResult.Failure(AuthError.InvalidRequest("Email is required.")) + val password = request.password ?: return AuthResult.Failure(AuthError.InvalidRequest("Password is required.")) + return try { - val result = firebaseAuth.createUserWithEmailAndPassword( - data.email, - data.password - ) - result.user?.let { user -> - if (!data.displayName.isNullOrBlank()) { - user.updateProfile(displayName = data.displayName) - } - user.toUserSession()?.let { session -> - AuthResult.Success(session) - } ?: AuthResult.Failure(AuthError.Unknown("No user returned after registration")) - } ?: AuthResult.Failure(AuthError.Unknown("No user returned after registration")) + val result = firebaseAuth.createUserWithEmailAndPassword(email, password) + DataMapper.toUserSession(result.user, IdentityProvider.Email)?.let { + AuthResult.Success(it) + } ?: AuthResult.Failure(AuthError.Unknown("Firebase returned no user.")) } catch (e: FirebaseAuthException) { - AuthResult.Failure(e.toAuthError()) + AuthResult.Failure(DataMapper.toAuthError(e)) } catch (e: Exception) { - AuthResult.Failure(AuthError.Unknown(e.message ?: "Registration failed", e)) + AuthResult.Failure(AuthError.Unknown(e.message)) } } - override suspend fun signOut(): Result { + override suspend fun signOut(): AuthResult { return try { firebaseAuth.signOut() - Result.success(Unit) + AuthResult.SignOutSuccess } catch (e: Exception) { - Result.failure(e) + AuthResult.Failure(AuthError.Unknown(e.message)) } } - override suspend fun sendPasswordResetEmail(email: String): AuthResult { + suspend fun sendPasswordResetEmail(email: String): AuthResult { return try { firebaseAuth.sendPasswordResetEmail(email) AuthResult.PasswordResetSent } catch (e: FirebaseAuthException) { - AuthResult.Failure(e.toAuthError()) + AuthResult.Failure(DataMapper.toAuthError(e)) } catch (e: Exception) { - AuthResult.Failure(AuthError.Unknown(e.message ?: "Failed to send reset email", e)) + AuthResult.Failure(AuthError.Unknown(e.message)) } } - override suspend fun confirmPasswordReset(code: String, newPassword: String): AuthResult { + suspend fun confirmPasswordReset(code: String, newPassword: String): AuthResult { return try { firebaseAuth.confirmPasswordReset(code, newPassword) AuthResult.PasswordResetSuccess } catch (e: FirebaseAuthException) { - AuthResult.Failure(e.toAuthError()) - } catch (e: Exception) { - AuthResult.Failure(AuthError.Unknown(e.message ?: "Failed to reset password", e)) - } - } - - override fun observeAuthState(): Flow { - return firebaseAuth.authStateChanged - .map { user -> - user?.toUserSession()?.let { AuthState.Authenticated(it) } - ?: AuthState.Unauthenticated - } - .onStart { emit(AuthState.Loading) } - .catch { e -> - val error = (e as? FirebaseAuthException)?.toAuthError() ?: AuthError.Unknown( - e.message ?: "An unknown error occurred", e - ) - emit(AuthState.Error(error)) - } - } - - override suspend fun refreshSession(): AuthResult { - return try { - val user = firebaseAuth.currentUser - if (user != null) { - val token = user.getIdToken(true) - user.toUserSession(accessToken = token)?.let { session -> - AuthResult.Success(session) - } ?: AuthResult.Failure(AuthError.SessionExpired()) - } else { - AuthResult.Failure(AuthError.SessionExpired()) - } - } catch (e: Exception) { - AuthResult.Failure(AuthError.SessionExpired(e.message ?: "Session expired")) - } - } - - override suspend fun isSignedIn(): Boolean { - return firebaseAuth.currentUser != null - } - - override suspend fun getIdToken(forceRefresh: Boolean): String? { - return try { - firebaseAuth.currentUser?.getIdToken(forceRefresh) - } catch (e: Exception) { - null - } - } - - override suspend fun deleteAccount(): Result { - return try { - firebaseAuth.currentUser?.delete() - Result.success(Unit) + AuthResult.Failure(DataMapper.toAuthError(e)) } catch (e: Exception) { - Result.failure(e) + AuthResult.Failure(AuthError.Unknown(e.message)) } } - - override suspend fun updateDisplayName(displayName: String): Result { - return try { - firebaseAuth.currentUser?.updateProfile(displayName = displayName) - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } - - override suspend fun updateEmail(newEmail: String): Result { - return try { - firebaseAuth.currentUser?.updateEmail(newEmail) - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } - - override suspend fun updatePassword(newPassword: String): Result { - return try { - firebaseAuth.currentUser?.updatePassword(newPassword) - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } - - override suspend fun sendEmailVerification(): Result { - return try { - firebaseAuth.currentUser?.sendEmailVerification() - Result.success(Unit) - } catch (e: Exception) { - Result.failure(e) - } - } - - override suspend fun reauthenticate(credentials: Credentials): AuthResult { - return AuthResult.Failure(AuthError.OperationNotAllowed("Reauthentication not implemented in this provider")) - } - - private fun IdentityProvider.toCredential(tokenData: String): AuthCredential? { - return when (this) { - is IdentityProvider.Google -> { - // Parse combined tokens: "idToken|||accessToken|||accessTokenValue" - // or just idToken for Android (which doesn't need accessToken) - val parts = tokenData.split("|||accessToken|||") - val idToken = parts[0] - val accessToken = parts.getOrNull(1) - GoogleAuthProvider.credential(idToken, accessToken) - } - - is IdentityProvider.GitHub -> GithubAuthProvider.credential(tokenData) - else -> null // Apple, Facebook, Phone have different flows - } - } - - companion object { - const val PROVIDER_ID = "firebase" - } } diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/GoogleAuthProvider.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/GoogleAuthProvider.kt new file mode 100644 index 0000000..7b3c420 --- /dev/null +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/GoogleAuthProvider.kt @@ -0,0 +1,17 @@ +package com.apptolast.customlogin.data + +import com.apptolast.customlogin.domain.AuthProvider +import com.apptolast.customlogin.domain.model.AuthRequest +import com.apptolast.customlogin.domain.model.AuthResult +import com.apptolast.customlogin.domain.model.IdentityProvider + +/** + * `expect` class for handling Google Sign-In. + * Implements the [AuthProvider] interface. + * Platform-specific implementations will provide the concrete logic for interacting with Google's SDK. + */ +expect class GoogleAuthProvider() : AuthProvider { + override val supportedProviders: List + override suspend fun signIn(request: AuthRequest): AuthResult + override suspend fun signOut(): AuthResult +} diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/PhoneAuthProvider.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/PhoneAuthProvider.kt new file mode 100644 index 0000000..e8882d5 --- /dev/null +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/data/PhoneAuthProvider.kt @@ -0,0 +1,37 @@ +package com.apptolast.customlogin.data + +import com.apptolast.customlogin.domain.AuthProvider +import com.apptolast.customlogin.domain.model.AuthRequest +import com.apptolast.customlogin.domain.model.AuthResult +import com.apptolast.customlogin.domain.model.IdentityProvider +import com.apptolast.customlogin.domain.model.PhoneAuthResult +import kotlinx.coroutines.flow.Flow + +/** + * `expect` class for handling phone number authentication. + * This class implements the [AuthProvider] interface and adds specific methods for phone verification. + * Platform-specific implementations (`actual`) will provide the concrete logic. + */ +expect class PhoneAuthProvider() : AuthProvider { + + override val supportedProviders: List + + override suspend fun signIn(request: AuthRequest): AuthResult + + override suspend fun signOut(): AuthResult + + /** + * Starts the phone number verification process. + * @param phoneNumber The phone number to verify. + * @return A [Flow] emitting [PhoneAuthResult] states. + */ + fun verifyPhoneNumber(phoneNumber: String): Flow + + /** + * Signs in the user using the verification ID and the code sent to the user's phone. + * @param verificationId The ID of the verification process. + * @param code The one-time code received by the user. + * @return An [AuthResult] with the outcome of the sign-in operation. + */ + suspend fun signInWithVerification(verificationId: String, code: String): AuthResult +} diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/di/DataModule.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/di/DataModule.kt index d10deef..752b4af 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/di/DataModule.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/di/DataModule.kt @@ -1,33 +1,32 @@ package com.apptolast.customlogin.di -import com.apptolast.customlogin.data.FirebaseAuthProvider +import com.apptolast.customlogin.data.AppleAuthProvider import com.apptolast.customlogin.data.AuthRepositoryImpl -import com.apptolast.customlogin.domain.AuthProvider +import com.apptolast.customlogin.data.FirebaseAuthProvider +import com.apptolast.customlogin.data.GoogleAuthProvider +import com.apptolast.customlogin.data.PhoneAuthProvider import com.apptolast.customlogin.domain.AuthRepository -import dev.gitlive.firebase.Firebase -import dev.gitlive.firebase.auth.auth import org.koin.dsl.module /** - * Koin module for data layer dependencies. - * Provides repository implementations and their dependencies. + * Koin module for the data layer. + * It provides concrete implementations for the domain interfaces. */ internal val dataModule = module { - // Firebase Auth instance from GitLive - single { Firebase.auth } - - // Firebase Auth Provider - single { FirebaseAuthProvider(get()) } + // Concrete Auth Providers + single { FirebaseAuthProvider() } // For Email/Password + single { GoogleAuthProvider() } // expect/actual for Google Sign-In + single { AppleAuthProvider() } // expect/actual for Apple Sign-In + single { PhoneAuthProvider() } // expect/actual for Phone/OTP - // Auth Repository using the default provider - single { AuthRepositoryImpl(get()) } + // Auth Repository Implementation + single { + AuthRepositoryImpl( + // Inject all providers into the repository + firebaseProvider = get(), + googleProvider = get(), + appleProvider = get(), + phoneProvider = get() + ) + } } - -/** - * Alternative module for custom backend (Ktor-based). - * Use this instead of dataModule if you want to use a custom backend. - */ -// val customBackendModule = module { -// single { KtorAuthProvider(get(), config) } -// single { AuthRepositoryImpl(get()) } -// } diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/di/KoinInitializer.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/di/KoinInitializer.kt index 1433fae..12a0d1e 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/di/KoinInitializer.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/di/KoinInitializer.kt @@ -1,41 +1,19 @@ package com.apptolast.customlogin.di -import com.apptolast.customlogin.config.GoogleSignInConfig import org.koin.core.context.startKoin import org.koin.dsl.KoinAppDeclaration -import org.koin.dsl.module - -/** - * Configuration for the login library. - * - * @property googleSignInConfig Configuration for Google Sign-In. If null, Google Sign-In will not be available. - */ -data class LoginLibraryConfig( - val googleSignInConfig: GoogleSignInConfig? = null -) /** * Initializes Koin with custom LoginConfig. * - * @param config The library configuration including social sign-in providers. * @param appDeclaration Additional Koin configuration. */ fun initLoginKoin( - config: LoginLibraryConfig = LoginLibraryConfig(), - appDeclaration: KoinAppDeclaration? = null + appDeclaration: KoinAppDeclaration? = null, ) { startKoin { appDeclaration?.invoke(this) - - // Module for configuration - val configModule = module { - config.googleSignInConfig?.let { googleConfig -> - single { googleConfig } - } - } - modules( - configModule, dataModule, presentationModule, ) diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/di/PresentationModule.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/di/PresentationModule.kt index 61b5661..563a87b 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/di/PresentationModule.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/di/PresentationModule.kt @@ -2,6 +2,7 @@ package com.apptolast.customlogin.di import com.apptolast.customlogin.presentation.screens.forgotpassword.ForgotPasswordViewModel import com.apptolast.customlogin.presentation.screens.login.LoginViewModel +import com.apptolast.customlogin.presentation.screens.phone.PhoneAuthViewModel import com.apptolast.customlogin.presentation.screens.register.RegisterViewModel import com.apptolast.customlogin.presentation.screens.resetpassword.ResetPasswordViewModel import org.koin.core.module.dsl.viewModelOf @@ -15,4 +16,5 @@ internal val presentationModule = module { viewModelOf(::RegisterViewModel) viewModelOf(::ForgotPasswordViewModel) viewModelOf(::ResetPasswordViewModel) + viewModelOf(::PhoneAuthViewModel) } diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/AuthProvider.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/AuthProvider.kt index bb2b958..e0fce5f 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/AuthProvider.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/AuthProvider.kt @@ -1,99 +1,11 @@ package com.apptolast.customlogin.domain +import com.apptolast.customlogin.domain.model.AuthRequest import com.apptolast.customlogin.domain.model.AuthResult -import com.apptolast.customlogin.domain.model.AuthState -import com.apptolast.customlogin.domain.model.Credentials -import com.apptolast.customlogin.domain.model.SignUpData -import kotlinx.coroutines.flow.Flow +import com.apptolast.customlogin.domain.model.IdentityProvider -/** - * Interface for authentication providers. - * Implement this interface to add support for different auth backends - * (Firebase, Supabase, custom backend, etc.) - */ interface AuthProvider { - /** - * Unique identifier for this provider. - */ - val id: String - - /** - * Display name for UI purposes. - */ - val displayName: String - - /** - * Sign in with the given credentials. - */ - suspend fun signIn(credentials: Credentials): AuthResult - - /** - * Create a new user account. - */ - suspend fun signUp(data: SignUpData): AuthResult - - /** - * Sign out the current user. - */ - suspend fun signOut(): Result - - /** - * Send password reset email. - */ - suspend fun sendPasswordResetEmail(email: String): AuthResult - - /** - * Confirm password reset with code and new password. - */ - suspend fun confirmPasswordReset(code: String, newPassword: String): AuthResult - - /** - * Observe authentication state changes. - */ - fun observeAuthState(): Flow - - /** - * Refresh the current session. - */ - suspend fun refreshSession(): AuthResult - - /** - * Check if a user is currently signed in. - */ - suspend fun isSignedIn(): Boolean - - /** - * Get the current user's ID token (for backend verification). - */ - suspend fun getIdToken(forceRefresh: Boolean = false): String? - - /** - * Delete the current user account. - */ - suspend fun deleteAccount(): Result - - /** - * Update the user's display name. - */ - suspend fun updateDisplayName(displayName: String): Result - - /** - * Update the user's email. - */ - suspend fun updateEmail(newEmail: String): Result - - /** - * Update the user's password. - */ - suspend fun updatePassword(newPassword: String): Result - - /** - * Send email verification. - */ - suspend fun sendEmailVerification(): Result - - /** - * Re-authenticate the user (required before sensitive operations). - */ - suspend fun reauthenticate(credentials: Credentials): AuthResult -} \ No newline at end of file + val supportedProviders: List + suspend fun signIn(request: AuthRequest): AuthResult + suspend fun signOut(): AuthResult +} diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/AuthRepository.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/AuthRepository.kt index 7a859c4..bd86b86 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/AuthRepository.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/AuthRepository.kt @@ -1,95 +1,17 @@ package com.apptolast.customlogin.domain +import com.apptolast.customlogin.domain.model.AuthRequest import com.apptolast.customlogin.domain.model.AuthResult import com.apptolast.customlogin.domain.model.AuthState -import com.apptolast.customlogin.domain.model.Credentials -import com.apptolast.customlogin.domain.model.PasswordResetData -import com.apptolast.customlogin.domain.model.SignUpData import com.apptolast.customlogin.domain.model.UserSession import kotlinx.coroutines.flow.Flow -/** - * Repository interface for authentication operations. - * This abstraction allows the library to work with any authentication provider. - */ interface AuthRepository { - /** - * Current provider ID being used. - */ - val currentProviderId: String - - /** - * Observe authentication state changes. - */ - fun observeAuthState(): Flow - - /** - * Get the current user session, if authenticated. - */ - suspend fun getCurrentSession(): UserSession? - - /** - * Sign in with any supported credentials. - */ - suspend fun signIn(credentials: Credentials): AuthResult - - /** - * Create a new user account. - */ - suspend fun signUp(data: SignUpData): AuthResult - - /** - * Sign out the current user. - */ - suspend fun signOut(): Result - - /** - * Send password reset email. - */ + suspend fun signIn(request: AuthRequest): AuthResult + suspend fun signUp(request: AuthRequest): AuthResult + suspend fun signOut(): AuthResult suspend fun sendPasswordResetEmail(email: String): AuthResult - - /** - * Confirm password reset with verification code. - */ - suspend fun confirmPasswordReset(data: PasswordResetData): AuthResult - - /** - * Refresh the current session. - */ - suspend fun refreshSession(): AuthResult - - /** - * Check if user is currently signed in. - */ - suspend fun isSignedIn(): Boolean - - /** - * Get the current user's ID token for backend verification. - */ - suspend fun getIdToken(forceRefresh: Boolean = false): String? - - /** - * Delete the current user's account. - */ - suspend fun deleteAccount(): Result - - /** - * Update the user's display name. - */ - suspend fun updateDisplayName(displayName: String): Result - - /** - * Update the user's email. - */ - suspend fun updateEmail(newEmail: String): Result - - /** - * Update the user's password. - */ - suspend fun updatePassword(newPassword: String): Result - - /** - * Send email verification to the current user. - */ - suspend fun sendEmailVerification(): Result + suspend fun confirmPasswordReset(code: String, newPassword: String): AuthResult + fun getCurrentSession(): UserSession? + fun observeAuthState(): Flow } \ No newline at end of file diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/model/AuthResult.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/model/AuthResult.kt index 8506354..6df20f7 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/model/AuthResult.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/model/AuthResult.kt @@ -11,6 +11,11 @@ sealed interface AuthResult { */ data class Success(val session: UserSession) : AuthResult + /** + * Represents a successful sign-out. + */ + data object SignOutSuccess : AuthResult + /** * Represents a failed authentication. * @property error The typed authentication error. diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/model/IdentityProvider.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/model/IdentityProvider.kt index 511727e..ecce75f 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/model/IdentityProvider.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/model/IdentityProvider.kt @@ -2,13 +2,14 @@ package com.apptolast.customlogin.domain.model /** * Represents an identity provider for authentication. - * Each provider has a unique ID that often corresponds to the Firebase provider ID. + * This is a sealed interface to represent a closed set of supported providers. */ -sealed class IdentityProvider(val id: String) { - data object Google : IdentityProvider("google.com") - data object Apple : IdentityProvider("apple.com") - data object Facebook : IdentityProvider("facebook.com") - data object GitHub : IdentityProvider("github.com") - data object Phone : IdentityProvider("phone") - data class Custom(val customId: String) : IdentityProvider(customId) +sealed interface IdentityProvider { + data object Email : IdentityProvider + data object Google : IdentityProvider + data object Apple : IdentityProvider + data object Facebook : IdentityProvider + data object GitHub : IdentityProvider + data object Phone : IdentityProvider + data class Custom(val customId: String) : IdentityProvider } diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/model/PhoneAuthResult.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/model/PhoneAuthResult.kt new file mode 100644 index 0000000..86b7022 --- /dev/null +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/domain/model/PhoneAuthResult.kt @@ -0,0 +1,15 @@ +package com.apptolast.customlogin.domain.model + +/** + * Represents the result of a phone number verification flow. + */ +sealed interface PhoneAuthResult { + /** Indicates that the verification code has been sent successfully. */ + data class CodeSent(val verificationId: String) : PhoneAuthResult + + /** Indicates that the phone number has been verified and sign-in is complete. */ + data class VerificationCompleted(val session: UserSession) : PhoneAuthResult + + /** Indicates that the verification flow failed. */ + data class Failure(val error: AuthError) : PhoneAuthResult +} diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/navigation/RootNavGraph.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/navigation/RootNavGraph.kt index 01853da..355ce92 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/navigation/RootNavGraph.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/navigation/RootNavGraph.kt @@ -7,6 +7,7 @@ import androidx.navigation.compose.navigation import androidx.navigation.toRoute import com.apptolast.customlogin.presentation.screens.forgotpassword.ForgotPasswordScreen import com.apptolast.customlogin.presentation.screens.login.LoginScreen +import com.apptolast.customlogin.presentation.screens.phone.PhoneAuthScreen import com.apptolast.customlogin.presentation.screens.register.RegisterScreen import com.apptolast.customlogin.presentation.screens.resetpassword.ResetPasswordScreen import com.apptolast.customlogin.presentation.screens.welcome.WelcomeScreen @@ -59,11 +60,23 @@ fun NavGraphBuilder.authRoutesFlow( } }, onNavigateToResetPassword = { - // Navigate to the new Forgot Password screen navController.navigate(ForgotPasswordRoute) { launchSingleTop = true } }, + onNavigateToPhoneAuth = { + navController.navigate(PhoneAuthRoute) { + launchSingleTop = true + } + } + ) + } + + // ---------- PHONE AUTH SCREEN ---------- + composable { + PhoneAuthScreen( + onBack = { navController.popBackStack() }, + onNavigateToHome = onNavigateToHome ) } diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/navigation/Routes.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/navigation/Routes.kt index 986ef45..7b69664 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/navigation/Routes.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/navigation/Routes.kt @@ -19,3 +19,6 @@ data object ForgotPasswordRoute @Serializable data class ResetPasswordRoute(val resetCode: String) + +@Serializable +data object PhoneAuthRoute diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/forgotpassword/ForgotPasswordScreen.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/forgotpassword/ForgotPasswordScreen.kt index be90372..543e70b 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/forgotpassword/ForgotPasswordScreen.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/forgotpassword/ForgotPasswordScreen.kt @@ -95,7 +95,6 @@ fun ForgotPasswordScreen( ) } }, - modifier = Modifier.consumeWindowInsets(TopAppBarDefaults.windowInsets) ) { paddingValues -> ForgotPasswordContent( slots = slots, diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginAction.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginAction.kt index f742273..005b2a4 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginAction.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginAction.kt @@ -3,15 +3,11 @@ package com.apptolast.customlogin.presentation.screens.login import com.apptolast.customlogin.domain.model.IdentityProvider /** - * Defines the contract for UI events and one-time side effects in the Login screen. - */ - -/** - * User actions from the UI. + * Defines all possible user actions that can be triggered from the Login screen. + * This sealed interface is part of the MVI pattern, ensuring a unidirectional data flow. */ sealed interface LoginAction { data class EmailChanged(val email: String) : LoginAction data class PasswordChanged(val password: String) : LoginAction - data class SocialSignInClicked(val provider: IdentityProvider) : LoginAction - data object SignInClicked : LoginAction + data class SignIn(val provider: IdentityProvider) : LoginAction } diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginEffect.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginEffect.kt index 5a6c182..17b3e7b 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginEffect.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginEffect.kt @@ -4,5 +4,6 @@ package com.apptolast.customlogin.presentation.screens.login */ sealed interface LoginEffect { data object NavigateToHome : LoginEffect + data object NavigateToPhoneAuth : LoginEffect data class ShowError(val message: String) : LoginEffect } \ No newline at end of file diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginLoadingState.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginLoadingState.kt new file mode 100644 index 0000000..0b48206 --- /dev/null +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginLoadingState.kt @@ -0,0 +1,18 @@ +package com.apptolast.customlogin.presentation.screens.login + +import com.apptolast.customlogin.domain.model.IdentityProvider + +/** + * Represents the different loading states of the login screen. + * It's a sealed interface to ensure type safety and explicit state handling. + */ +sealed interface LoginLoadingState { + /** The screen is not in a loading state. */ + data object Idle : LoginLoadingState + + /** The email and password sign-in process is in progress. */ + data object EmailSignIn : LoginLoadingState + + /** A social sign-in process is in progress. */ + data class SocialSignIn(val provider: IdentityProvider) : LoginLoadingState +} diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginScreen.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginScreen.kt index 9f01921..2c4fd8c 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginScreen.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.apptolast.customlogin.domain.model.IdentityProvider import com.apptolast.customlogin.presentation.screens.components.CustomSnackBar import com.apptolast.customlogin.presentation.screens.components.DefaultAuthContainer import com.apptolast.customlogin.presentation.slots.LoginScreenSlots @@ -41,6 +42,7 @@ fun LoginScreen( onNavigateToHome: () -> Unit, onNavigateToRegister: () -> Unit, onNavigateToResetPassword: () -> Unit = {}, + onNavigateToPhoneAuth: () -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsState() val snackBarHostState = remember { SnackbarHostState() } @@ -49,6 +51,7 @@ fun LoginScreen( viewModel.effect.collectLatest { effect -> when (effect) { is LoginEffect.NavigateToHome -> onNavigateToHome() + is LoginEffect.NavigateToPhoneAuth -> onNavigateToPhoneAuth() is LoginEffect.ShowError -> { snackBarHostState.showSnackbar( message = effect.message, @@ -100,6 +103,8 @@ private fun LoginContent( onNavigateToRegister: () -> Unit = {}, onNavigateToForgotPassword: () -> Unit = {}, ) { + val isLoading = state.loadingState !is LoginLoadingState.Idle + DefaultAuthContainer(modifier = modifier) { slots.header() @@ -109,7 +114,7 @@ private fun LoginContent( state.email, { onAction(LoginAction.EmailChanged(it)) }, state.emailError, - !state.isLoading + !isLoading ) Spacer(modifier = Modifier.height(8.dp)) @@ -118,7 +123,7 @@ private fun LoginContent( state.password, { onAction(LoginAction.PasswordChanged(it)) }, state.passwordError, - !state.isLoading + !isLoading ) slots.forgotPasswordLink(onNavigateToForgotPassword) @@ -129,15 +134,15 @@ private fun LoginContent( slots.submitButton( stringResource(Res.string.login_screen_sign_in_button), - isFormValid && !state.isLoading, - state.isLoading, - ) { onAction(LoginAction.SignInClicked) } + isFormValid && !isLoading, + state.loadingState is LoginLoadingState.EmailSignIn, + ) { onAction(LoginAction.SignIn(IdentityProvider.Email)) } Spacer(Modifier.height(8.dp)) slots.socialProviders?.let { socialProviders -> - socialProviders { provider -> - onAction(LoginAction.SocialSignInClicked(provider)) + socialProviders(state.loadingState) { provider -> + onAction(LoginAction.SignIn(provider)) } } @@ -153,7 +158,7 @@ private fun LoginScreenPreview() { state = LoginUiState( email = "test@apptolast.com", password = "Password123", - isLoading = false + loadingState = LoginLoadingState.Idle ) ) } @@ -167,7 +172,7 @@ private fun LoginScreenLoadingPreview() { state = LoginUiState( email = "test@apptolast.com", password = "Password123", - isLoading = true + loadingState = LoginLoadingState.EmailSignIn ) ) } diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginUiState.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginUiState.kt index c9220ff..92c7147 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginUiState.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginUiState.kt @@ -1,5 +1,7 @@ package com.apptolast.customlogin.presentation.screens.login +import com.apptolast.customlogin.domain.model.IdentityProvider + /** * Represents the state of the Login screen. * @@ -7,12 +9,12 @@ package com.apptolast.customlogin.presentation.screens.login * @property password The password entered by the user. * @property emailError An optional error message for the email field. * @property passwordError An optional error message for the password field. - * @property isLoading Indicates if a login operation is in progress. + * @property loadingState Indicates the current loading state of the login process. */ data class LoginUiState( val email: String = "", val password: String = "", val emailError: String? = null, val passwordError: String? = null, - val isLoading: Boolean = false + val loadingState: LoginLoadingState = LoginLoadingState.Idle ) diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginViewModel.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginViewModel.kt index 8ab2f54..bad596d 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginViewModel.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/login/LoginViewModel.kt @@ -3,8 +3,8 @@ package com.apptolast.customlogin.presentation.screens.login import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.apptolast.customlogin.domain.AuthRepository +import com.apptolast.customlogin.domain.model.AuthRequest import com.apptolast.customlogin.domain.model.AuthResult -import com.apptolast.customlogin.domain.model.Credentials import com.apptolast.customlogin.domain.model.IdentityProvider import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -13,13 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -/** - * ViewModel for the Login screen using MVI pattern. - * Handles business logic and exposes state and effects to the UI. - */ -class LoginViewModel( - private val authRepository: AuthRepository -) : ViewModel() { +class LoginViewModel(private val authRepository: AuthRepository) : ViewModel() { private val _uiState = MutableStateFlow(LoginUiState()) val uiState = _uiState.asStateFlow() @@ -27,15 +21,11 @@ class LoginViewModel( private val _effect = MutableSharedFlow() val effect = _effect.asSharedFlow() - /** - * The single entry point for all user actions from the UI. - */ fun onAction(action: LoginAction) { when (action) { is LoginAction.EmailChanged -> onEmailChange(action.email) is LoginAction.PasswordChanged -> onPasswordChange(action.password) - is LoginAction.SocialSignInClicked -> onSocialSignIn(action.provider) - is LoginAction.SignInClicked -> onSignInClicked() + is LoginAction.SignIn -> onSignIn(action.provider) } } @@ -47,79 +37,54 @@ class LoginViewModel( _uiState.update { it.copy(password = password, passwordError = null) } } - private fun onSocialSignIn(provider: IdentityProvider) { + private fun onSignIn(provider: IdentityProvider) { viewModelScope.launch { - _uiState.update { it.copy(isLoading = true) } - - val credentials = Credentials.OAuthToken(provider = provider) - when (val result = authRepository.signIn(credentials)) { - is AuthResult.Success -> { - _uiState.update { it.copy(isLoading = false) } - _effect.emit(LoginEffect.NavigateToHome) - } - - is AuthResult.Failure -> { - _uiState.update { it.copy(isLoading = false) } - _effect.emit(LoginEffect.ShowError("$provider: ${result.error.message}")) - } - - else -> { - _uiState.update { it.copy(isLoading = false) } - _effect.emit(LoginEffect.ShowError("An unexpected error occurred")) - } + val state = _uiState.value + val request = createAuthRequest(provider, state) + + if (request == null) { // Validation failed for email + _uiState.update { it.copy(loadingState = LoginLoadingState.Idle) } + return@launch } - } - } - private fun onSignInClicked() { - val state = _uiState.value - val (emailError, passwordError) = validate(state) + val loadingState = if (provider is IdentityProvider.Email) LoginLoadingState.EmailSignIn else LoginLoadingState.SocialSignIn(provider) + _uiState.update { it.copy(loadingState = loadingState) } - _uiState.update { - it.copy( - emailError = emailError, - passwordError = passwordError - ) + handleAuthResult(authRepository.signIn(request)) } + } - if (emailError == null && passwordError == null) { - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true) } - - when (val result = authRepository.signIn(state.toEmailPasswordCredentials())) { - is AuthResult.Success -> { - _uiState.update { it.copy(isLoading = false) } - _effect.emit(LoginEffect.NavigateToHome) - } - - is AuthResult.Failure -> { - _uiState.update { it.copy(isLoading = false) } - _effect.emit(LoginEffect.ShowError(result.error.message)) - } - - else -> { - _uiState.update { it.copy(isLoading = false) } - _effect.emit(LoginEffect.ShowError("An unexpected error occurred")) - } - } + private fun createAuthRequest(provider: IdentityProvider, state: LoginUiState): AuthRequest? { + return when (provider) { + is IdentityProvider.Email -> { + if (!validate(state.email, state.password)) return null + AuthRequest(provider, email = state.email, password = state.password) } + else -> AuthRequest(provider) } } - private fun validate(state: LoginUiState): Pair { - val emailError = when { - state.email.isBlank() -> "Email cannot be empty" - !"^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$".toRegex() - .matches(state.email) -> "Invalid email format" - - else -> null + private suspend fun handleAuthResult(result: AuthResult) { + when (result) { + is AuthResult.Success -> { + _effect.emit(LoginEffect.NavigateToHome) + } + is AuthResult.Failure -> { + _effect.emit(LoginEffect.ShowError(result.error.message)) + } + else -> { + _effect.emit(LoginEffect.ShowError("An unexpected result was returned: $result")) + } } + _uiState.update { it.copy(loadingState = LoginLoadingState.Idle) } + } - val passwordError = when { - state.password.isBlank() -> "Password cannot be empty" - else -> null - } + private fun validate(email: String, password: String): Boolean { + val emailError = if (email.isBlank()) "Email can't be empty" else null + val passwordError = if (password.isBlank()) "Password can't be empty" else null + + _uiState.update { it.copy(emailError = emailError, passwordError = passwordError) } - return emailError to passwordError + return emailError == null && passwordError == null } } diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/phone/PhoneAuthAction.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/phone/PhoneAuthAction.kt new file mode 100644 index 0000000..2407eea --- /dev/null +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/phone/PhoneAuthAction.kt @@ -0,0 +1,11 @@ +package com.apptolast.customlogin.presentation.screens.phone + +/** + * Defines the user actions for the PhoneAuth screen. + */ +sealed interface PhoneAuthAction { + data class PhoneNumberChanged(val phoneNumber: String) : PhoneAuthAction + data class VerificationCodeChanged(val code: String) : PhoneAuthAction + data object SendVerificationCode : PhoneAuthAction + data object SignInWithCode : PhoneAuthAction +} diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/phone/PhoneAuthEffect.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/phone/PhoneAuthEffect.kt new file mode 100644 index 0000000..a6657ad --- /dev/null +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/phone/PhoneAuthEffect.kt @@ -0,0 +1,9 @@ +package com.apptolast.customlogin.presentation.screens.phone + +/** + * Defines the one-time side effects for the PhoneAuth screen. + */ +sealed interface PhoneAuthEffect { + data object NavigateToHome : PhoneAuthEffect + data class ShowError(val message: String) : PhoneAuthEffect +} diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/phone/PhoneAuthScreen.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/phone/PhoneAuthScreen.kt new file mode 100644 index 0000000..5d1bb7a --- /dev/null +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/phone/PhoneAuthScreen.kt @@ -0,0 +1,127 @@ +package com.apptolast.customlogin.presentation.screens.phone + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PhoneAuthScreen( + viewModel: PhoneAuthViewModel = koinViewModel(), + onBack: () -> Unit, + onNavigateToHome: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsState() + + // TODO: Handle effects for navigation and errors + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Sign in with Phone") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + + if (!uiState.isCodeSent) { + PhoneNumberInput(uiState, viewModel::onAction) + } else { + VerificationCodeInput(uiState, viewModel::onAction) + } + + uiState.error?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error) + } + + if (uiState.isLoading) { + Spacer(Modifier.height(16.dp)) + CircularProgressIndicator() + } + } + } +} + +@Composable +private fun PhoneNumberInput(uiState: PhoneAuthUiState, onAction: (PhoneAuthAction) -> Unit) { + OutlinedTextField( + value = uiState.phoneNumber, + onValueChange = { onAction(PhoneAuthAction.PhoneNumberChanged(it)) }, + label = { Text("Phone Number") }, + modifier = Modifier.fillMaxWidth(), + isError = uiState.error != null, + enabled = !uiState.isLoading + ) + + Spacer(Modifier.height(16.dp)) + + Button( + onClick = { onAction(PhoneAuthAction.SendVerificationCode) }, + modifier = Modifier.fillMaxWidth(), + enabled = uiState.phoneNumber.isNotBlank() && !uiState.isLoading + ) { + Text("Send Verification Code") + } +} + +@Composable +private fun VerificationCodeInput(uiState: PhoneAuthUiState, onAction: (PhoneAuthAction) -> Unit) { + Text("Enter the code sent to ${uiState.phoneNumber}") + + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = uiState.verificationCode, + onValueChange = { onAction(PhoneAuthAction.VerificationCodeChanged(it)) }, + label = { Text("Verification Code") }, + modifier = Modifier.fillMaxWidth(), + isError = uiState.error != null, + enabled = !uiState.isLoading + ) + + Spacer(Modifier.height(16.dp)) + + Button( + onClick = { onAction(PhoneAuthAction.SignInWithCode) }, + modifier = Modifier.fillMaxWidth(), + enabled = uiState.verificationCode.isNotBlank() && !uiState.isLoading + ) { + Text("Sign In") + } +} diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/phone/PhoneAuthUiState.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/phone/PhoneAuthUiState.kt new file mode 100644 index 0000000..54fe28c --- /dev/null +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/phone/PhoneAuthUiState.kt @@ -0,0 +1,12 @@ +package com.apptolast.customlogin.presentation.screens.phone + +/** + * Represents the state of the PhoneAuth screen. + */ +data class PhoneAuthUiState( + val phoneNumber: String = "", + val verificationCode: String = "", + val error: String? = null, + val isLoading: Boolean = false, + val isCodeSent: Boolean = false +) diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/phone/PhoneAuthViewModel.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/phone/PhoneAuthViewModel.kt new file mode 100644 index 0000000..ecb5d9e --- /dev/null +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/screens/phone/PhoneAuthViewModel.kt @@ -0,0 +1,91 @@ +package com.apptolast.customlogin.presentation.screens.phone + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.apptolast.customlogin.domain.AuthRepository +import com.apptolast.customlogin.domain.model.AuthResult +import com.apptolast.customlogin.domain.model.PhoneAuthResult +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class PhoneAuthViewModel( + private val authRepository: AuthRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(PhoneAuthUiState()) + val uiState = _uiState.asStateFlow() + + private val _effect = MutableSharedFlow() + val effect = _effect.asSharedFlow() + + private var verificationId: String? = null + + fun onAction(action: PhoneAuthAction) { + when (action) { + is PhoneAuthAction.PhoneNumberChanged -> _uiState.update { it.copy(phoneNumber = action.phoneNumber, error = null) } + is PhoneAuthAction.VerificationCodeChanged -> _uiState.update { it.copy(verificationCode = action.code, error = null) } + PhoneAuthAction.SendVerificationCode -> sendVerificationCode() + PhoneAuthAction.SignInWithCode -> signInWithCode() + } + } + + private fun sendVerificationCode() { + _uiState.update { it.copy(isLoading = true, error = null) } + authRepository.verifyPhoneNumber(_uiState.value.phoneNumber) + .onEach { result -> + when (result) { + is PhoneAuthResult.CodeSent -> { + this.verificationId = result.verificationId + _uiState.update { it.copy(isLoading = false, isCodeSent = true) } + } + + is PhoneAuthResult.Failure -> { + _uiState.update { it.copy(isLoading = false, error = result.error.message) } + } + + is PhoneAuthResult.VerificationCompleted -> { + // This case is for auto-retrieval, which we can handle directly + // For simplicity, we are letting the user enter the code manually + // but a full implementation could sign in automatically here. + } + } + } + .catch { e -> + _uiState.update { it.copy(isLoading = false, error = e.message ?: "An unknown error occurred") } + } + .launchIn(viewModelScope) + } + + private fun signInWithCode() { + val verificationId = this.verificationId ?: return + val code = _uiState.value.verificationCode + + if (code.isBlank()) { + _uiState.update { it.copy(error = "Verification code cannot be empty") } + return + } + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + when (val result = authRepository.signInWithPhoneCredential(verificationId, code)) { + is AuthResult.Success -> { + _uiState.update { it.copy(isLoading = false) } + _effect.emit(PhoneAuthEffect.NavigateToHome) + } + is AuthResult.Failure -> { + _uiState.update { it.copy(isLoading = false, error = result.error.message) } + } + else -> { + _uiState.update { it.copy(isLoading = false, error = "An unexpected error occurred") } + } + } + } + } +} diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/slots/AuthSlots.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/slots/AuthSlots.kt index f281b51..14e6929 100644 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/slots/AuthSlots.kt +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/slots/AuthSlots.kt @@ -3,6 +3,7 @@ package com.apptolast.customlogin.presentation.slots import androidx.compose.foundation.layout.Arrangement import androidx.compose.runtime.Composable import com.apptolast.customlogin.domain.model.IdentityProvider +import com.apptolast.customlogin.presentation.screens.login.LoginLoadingState import com.apptolast.customlogin.presentation.slots.defaultslots.DefaultEmailField import com.apptolast.customlogin.presentation.slots.defaultslots.DefaultForgotPasswordDescription import com.apptolast.customlogin.presentation.slots.defaultslots.DefaultForgotPasswordHeader @@ -70,8 +71,8 @@ data class LoginScreenSlots( onClick = onClick, ) }, - val socialProviders: (@Composable (onProviderClick: (IdentityProvider) -> Unit) -> Unit)? = { onProviderClick -> - SocialLoginButtonsSection(onProviderClick = onProviderClick) + val socialProviders: (@Composable (loadingState: LoginLoadingState, onProviderClick: (IdentityProvider) -> Unit) -> Unit)? = { loadingState, onProviderClick -> + SocialLoginButtonsSection(loadingState = loadingState, onProviderClick = onProviderClick) }, val forgotPasswordLink: @Composable (onClick: () -> Unit) -> Unit = { onClick -> DefaultForgotPasswordLink(onForgotPasswordClick = onClick) diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/slots/defaultslots/DefaultProviders.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/slots/defaultslots/DefaultProviders.kt new file mode 100644 index 0000000..f155c0f --- /dev/null +++ b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/slots/defaultslots/DefaultProviders.kt @@ -0,0 +1,120 @@ +package com.apptolast.customlogin.presentation.slots.defaultslots + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Phone +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.unit.dp +import com.apptolast.customlogin.domain.model.IdentityProvider +import com.apptolast.customlogin.presentation.screens.login.LoginLoadingState +import login.custom_login.generated.resources.Res +import login.custom_login.generated.resources.google_icon +import org.jetbrains.compose.resources.painterResource + +/** + * A composable that arranges multiple social login buttons, handling loading states. + * This is the default implementation for the `socialProviders` slot. + */ +@Composable +fun SocialLoginButtonsSection( + loadingState: LoginLoadingState, + onProviderClick: (IdentityProvider) -> Unit, + providers: List = listOf(IdentityProvider.Google, IdentityProvider.Apple, IdentityProvider.Phone) +) { + val isAnyLoading = loadingState !is LoginLoadingState.Idle + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + providers.forEach { provider -> + val isLoading = loadingState is LoginLoadingState.SocialSignIn && loadingState.provider == provider + + when (provider) { + is IdentityProvider.Google -> DefaultSocialButton( + text = "Sign in with Google", + icon = painterResource(Res.drawable.google_icon), + onClick = { onProviderClick(provider) }, + isLoading = isLoading, + enabled = !isAnyLoading + ) + is IdentityProvider.Apple -> DefaultSocialButton( + text = "Sign in with Apple", + // TODO: Replace with a proper Apple icon resource + icon = rememberVectorPainter(image = Icons.Default.Phone), + onClick = { onProviderClick(provider) }, + isLoading = isLoading, + enabled = !isAnyLoading, + tint = MaterialTheme.colorScheme.onSurface + ) + is IdentityProvider.Phone -> DefaultSocialButton( + text = "Sign in with Phone", + icon = rememberVectorPainter(image = Icons.Default.Phone), + onClick = { onProviderClick(provider) }, + isLoading = isLoading, + enabled = !isAnyLoading, + tint = MaterialTheme.colorScheme.onSurface + ) + // TODO: Add other buttons like GitHub, etc. here following the same pattern + else -> {} + } + } + } +} + +/** + * A generic, styled button for social login providers. + * It now supports a loading state. + */ +@Composable +internal fun DefaultSocialButton( + text: String, + icon: Painter, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isLoading: Boolean = false, + enabled: Boolean = true, + tint: Color = Color.Unspecified, +) { + OutlinedButton( + onClick = onClick, + modifier = modifier.fillMaxWidth().height(56.dp), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface + ), + enabled = enabled && !isLoading + ) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } else { + Icon( + painter = icon, + contentDescription = text, + modifier = Modifier.size(24.dp), + tint = tint + ) + Spacer(modifier = Modifier.width(16.dp)) + Text(text = text) + } + } +} diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/slots/defaultslots/DefaultSocialButton.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/slots/defaultslots/DefaultSocialButton.kt deleted file mode 100644 index facd7f5..0000000 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/slots/defaultslots/DefaultSocialButton.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.apptolast.customlogin.presentation.slots.defaultslots - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Phone -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.unit.dp -import com.apptolast.customlogin.domain.model.IdentityProvider -import login.custom_login.generated.resources.Res -import login.custom_login.generated.resources.google_icon -import org.jetbrains.compose.resources.painterResource - -/** - * A generic, styled button for social login providers. - * Takes an optional tint parameter. Defaults to Unspecified to support multi-color icons. - */ -@Composable -internal fun DefaultSocialButton( - text: String, - icon: Painter, - onClick: () -> Unit, - modifier: Modifier = Modifier, - tint: Color = Color.Unspecified, -) { - OutlinedButton( - onClick = onClick, - modifier = modifier.fillMaxWidth().height(56.dp), - shape = RoundedCornerShape(16.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.onSurface - ) - ) { - Icon( - painter = icon, - contentDescription = text, - modifier = Modifier.size(24.dp), - tint = tint // Use the tint parameter - ) - Spacer(modifier = Modifier.width(16.dp)) - Text(text = text) - } -} - -/** - * A specific social login button for Google. Uses a painter resource for the multi-color icon. - */ -@Composable -fun GoogleSocialButton(onClick: () -> Unit) { - DefaultSocialButton( - text = "Sign in with Google", - icon = painterResource(Res.drawable.google_icon), - onClick = onClick, - // Tint is defaulted to Unspecified, which is correct for this multi-color icon - ) -} - -/** - * A specific social login button for Phone. Uses a Material Vector Icon. - */ -@Composable -fun PhoneSocialButton(onClick: () -> Unit) { - DefaultSocialButton( - text = "Sign in with Phone", - icon = rememberVectorPainter(image = Icons.Default.Phone), - onClick = onClick, - // We provide a tint color, so the vector icon is colored correctly. - tint = MaterialTheme.colorScheme.onSurface - ) -} - -/** - * A composable that arranges multiple social login buttons. - * This is the default implementation for the `socialProviders` slot. - */ -@Composable -fun SocialLoginButtonsSection(onProviderClick: (IdentityProvider) -> Unit) { - Column(modifier = Modifier.fillMaxWidth()) { - GoogleSocialButton { onProviderClick(IdentityProvider.Google) } - Spacer(Modifier.height(8.dp)) - PhoneSocialButton { onProviderClick(IdentityProvider.Phone) } - // TODO: Add other buttons like GitHub, Apple, etc. here - } -} diff --git a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/slots/defaultslots/SocialLoginButtons.kt b/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/slots/defaultslots/SocialLoginButtons.kt deleted file mode 100644 index 38bf9e0..0000000 --- a/custom-login/src/commonMain/kotlin/com/apptolast/customlogin/presentation/slots/defaultslots/SocialLoginButtons.kt +++ /dev/null @@ -1,147 +0,0 @@ -/* -package com.apptolast.customlogin.presentation.slots.defaultslots - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -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.painter.Painter -import androidx.compose.ui.unit.dp -import com.apptolast.customlogin.domain.model.IdentityProvider -import login.custom_login.generated.resources.Res -import login.custom_login.generated.resources.google_icon -import login.custom_login.generated.resources.login_google_button -import login.custom_login.generated.resources.login_loading_text -import org.jetbrains.compose.resources.painterResource -import org.jetbrains.compose.resources.stringResource - -*/ -/** - * Represents a social login button configuration. - * - * @param provider The social provider associated with this button. - * @param content The composable content of the button. - *//* - -data class SocialLoginButton( - val provider: IdentityProvider, - val content: @Composable (onClick: () -> Unit) -> Unit -) - -*/ -/** - * A default section that displays social login buttons in a lazy list. - * This is an optional composable that you can use in your `socialProviders` slot. - * - * @param onProviderClick A callback invoked when a social provider button is clicked. - *//* - -@Composable -fun SocialLoginButtonsSection(onProviderClick: (IdentityProvider) -> Unit) { - val socialButtons = defaultSocialLoginButtons() - - LazyColumn( - modifier = Modifier.padding(vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(socialButtons) { button -> - button.content { - onProviderClick(button.provider) - } - } - } -} - -*/ -/** - * Provides a default list of social login buttons (Google and Phone). - *//* - -@Composable -fun defaultSocialLoginButtons(): List = listOf( - SocialLoginButton( - provider = IdentityProvider.Google, - content = { onClick -> GoogleSocialButton(onClick = onClick) } - ), - SocialLoginButton( - provider = IdentityProvider.Phone, - content = { onClick -> PhoneSocialButton(onClick = onClick) } - ) -) - -*/ -/** - * Google Sign-In button following Google's branding guidelines. - *//* - -@Composable -fun GoogleSocialButton( - text: String = stringResource(Res.string.login_google_button), - loadingText: String = stringResource(Res.string.login_loading_text), - icon: Painter = painterResource(Res.drawable.google_icon), - isLoading: Boolean = false, - onClick: () -> Unit, -) { - Surface( - modifier = Modifier - .fillMaxWidth(0.7f) - .clickable( - enabled = !isLoading, - onClick = onClick, - ), - shape = MaterialTheme.shapes.medium, - border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.outline), - color = MaterialTheme.colorScheme.surface, - ) { - Row( - modifier = Modifier.padding( - start = 12.dp, - end = 16.dp, - top = 12.dp, - bottom = 12.dp, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Image( - painter = icon, - contentDescription = "Google Button", - modifier = Modifier.size(24.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = if (isLoading) loadingText else text, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } -} - -*/ -/** - * A default implementation of a phone social login button. - *//* - -@Composable -fun PhoneSocialButton(onClick: () -> Unit) { - Button(onClick = onClick) { - Text("Sign in with Phone") - } -} -*/ diff --git a/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/Platform.ios.kt b/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/Platform.ios.kt index 178e499..ff58968 100644 --- a/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/Platform.ios.kt +++ b/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/Platform.ios.kt @@ -1,30 +1,7 @@ package com.apptolast.customlogin -import com.apptolast.customlogin.config.GoogleSignInConfig import com.apptolast.customlogin.domain.model.IdentityProvider -import com.apptolast.customlogin.provider.GoogleSignInProviderIOS -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import platform.UIKit.UIDevice - -/** - * iOS-specific implementation of the common `expect` declarations. - */ -actual fun platform(): String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion - -/** - * Helper object for Koin dependency injection in platform code. - */ -private object PlatformKoinHelper : KoinComponent { - val googleSignInConfig: GoogleSignInConfig? by lazy { - try { - val config: GoogleSignInConfig by inject() - config - } catch (e: Exception) { - null - } - } -} +import com.apptolast.customlogin.data.GoogleAuthProviderIOS /** * Actual implementation for getting a social ID token on iOS. @@ -32,22 +9,18 @@ private object PlatformKoinHelper : KoinComponent { actual suspend fun getSocialIdToken(provider: IdentityProvider): String? { return when (provider) { is IdentityProvider.Google -> { - val config = PlatformKoinHelper.googleSignInConfig - if (config == null) { - println("Google Sign-In is not configured. Provide GoogleSignInConfig in LoginLibraryConfig.") - return null - } - - val googleProvider = GoogleSignInProviderIOS(config = config) + val googleProvider = GoogleAuthProviderIOS() googleProvider.signIn() } + is IdentityProvider.GitHub -> { // TODO: Implement GitHub OAuth flow for iOS. println("GitHub Sign-In for iOS is not implemented yet.") null } + else -> { - println("Social sign-in for ${provider.id} is not implemented on iOS yet.") + println("Social sign-in for ${provider::class.simpleName} is not implemented on iOS yet.") null } } diff --git a/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/data/AppleAuthProvider.kt b/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/data/AppleAuthProvider.kt new file mode 100644 index 0000000..26f7df0 --- /dev/null +++ b/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/data/AppleAuthProvider.kt @@ -0,0 +1,21 @@ +package com.apptolast.customlogin.data + +import com.apptolast.customlogin.domain.model.AuthError +import com.apptolast.customlogin.domain.model.AuthRequest +import com.apptolast.customlogin.domain.model.AuthResult +import com.apptolast.customlogin.domain.model.IdentityProvider + +actual class AppleAuthProvider { + actual val supportedProviders: List = listOf(IdentityProvider.Apple) + + actual suspend fun signIn(request: AuthRequest): AuthResult { + // This is where the native iOS Sign in with Apple logic will be implemented. + // For now, we'll return a 'not implemented' error. + return AuthResult.Failure(AuthError.OperationNotAllowed("Apple Sign-In is not implemented on iOS yet.")) + } + + actual suspend fun signOut(): AuthResult { + // This would clear any local state related to the Apple session. + return AuthResult.SignOutSuccess + } +} diff --git a/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/data/GoogleAuthProvider.kt b/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/data/GoogleAuthProvider.kt new file mode 100644 index 0000000..e69de29 diff --git a/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/data/GoogleAuthProviderIOS.kt b/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/data/GoogleAuthProviderIOS.kt new file mode 100644 index 0000000..60fab61 --- /dev/null +++ b/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/data/GoogleAuthProviderIOS.kt @@ -0,0 +1,21 @@ +package com.apptolast.customlogin.data + +import com.apptolast.customlogin.domain.AuthProvider +import com.apptolast.customlogin.domain.model.AuthError +import com.apptolast.customlogin.domain.model.AuthRequest +import com.apptolast.customlogin.domain.model.AuthResult +import com.apptolast.customlogin.domain.model.IdentityProvider + +actual class GoogleAuthProvider : AuthProvider { + actual override val supportedProviders: List = listOf(IdentityProvider.Google) + + actual override suspend fun signIn(request: AuthRequest): AuthResult { + // Actual implementation will use the Google Sign-In SDK for iOS + return AuthResult.Failure(AuthError.OperationNotAllowed("Google Sign-In is not implemented on iOS yet.")) + } + + actual override suspend fun signOut(): AuthResult { + // Actual implementation will sign out from the Google SDK for iOS + return AuthResult.SignOutSuccess + } +} diff --git a/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/data/PhoneAuthProviderIOS.kt b/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/data/PhoneAuthProviderIOS.kt new file mode 100644 index 0000000..b50d9b2 --- /dev/null +++ b/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/data/PhoneAuthProviderIOS.kt @@ -0,0 +1,31 @@ +package com.apptolast.customlogin.data + +import com.apptolast.customlogin.domain.AuthProvider +import com.apptolast.customlogin.domain.model.AuthError +import com.apptolast.customlogin.domain.model.AuthRequest +import com.apptolast.customlogin.domain.model.AuthResult +import com.apptolast.customlogin.domain.model.IdentityProvider +import com.apptolast.customlogin.domain.model.PhoneAuthResult +import kotlinx.coroutines.flow.Flow + +actual class PhoneAuthProvider : AuthProvider { + actual override val supportedProviders: List = listOf(IdentityProvider.Phone) + + actual override suspend fun signIn(request: AuthRequest): AuthResult { + return AuthResult.Failure(AuthError.OperationNotAllowed("Use verifyPhoneNumber and signInWithVerification for phone auth.")) + } + + actual override suspend fun signOut(): AuthResult { + return AuthResult.SignOutSuccess + } + + actual fun verifyPhoneNumber(phoneNumber: String): Flow { + // Actual implementation will call the Firebase SDK + TODO("Not yet implemented") + } + + actual suspend fun signInWithVerification(verificationId: String, code: String): AuthResult { + // Actual implementation will call the Firebase SDK + TODO("Not yet implemented") + } +} diff --git a/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/provider/GoogleSignInProviderIOS.kt b/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/provider/GoogleSignInProviderIOS.kt deleted file mode 100644 index 3faa018..0000000 --- a/custom-login/src/iosMain/kotlin/com/apptolast/customlogin/provider/GoogleSignInProviderIOS.kt +++ /dev/null @@ -1,102 +0,0 @@ -package com.apptolast.customlogin.provider - -import com.apptolast.customlogin.config.GoogleSignInConfig -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.coroutines.suspendCancellableCoroutine -import platform.UIKit.UIApplication -import platform.UIKit.UIViewController -import platform.UIKit.UIWindow -import kotlin.coroutines.resume - -/** - * iOS implementation of Google Sign-In. - * - * This provider uses a callback mechanism to integrate with Swift. - * The hosting app should: - * 1. Configure GoogleSignIn in Swift AppDelegate - * 2. Call [signInFromSwift] to trigger the sign-in flow - * 3. The result will be passed back via the callback - * - * @property config The Google Sign-In configuration containing client IDs. - */ -class GoogleSignInProviderIOS( - private val config: GoogleSignInConfig -) { - companion object { - /** - * Callback to be set from Swift to perform the actual sign-in. - * Swift should set this and call GIDSignIn.sharedInstance.signIn(). - */ - var signInHandler: ((String?, (String?) -> Unit) -> Unit)? = null - - /** - * Called from Swift to complete the sign-in with the ID token. - */ - private var pendingCallback: ((String?) -> Unit)? = null - - /** - * Called from Swift to provide the sign-in result. - */ - fun onSignInResult(idToken: String?) { - pendingCallback?.invoke(idToken) - pendingCallback = null - } - } - - /** - * Initiates the Google Sign-In flow and returns the ID token. - * - * @return The Google ID token on success, or null if cancelled/failed. - */ - @OptIn(ExperimentalForeignApi::class) - suspend fun signIn(): String? = suspendCancellableCoroutine { continuation -> - val handler = signInHandler - if (handler == null) { - println("Google Sign-In handler not configured. Set GoogleSignInProviderIOS.signInHandler from Swift.") - continuation.resume(null) - return@suspendCancellableCoroutine - } - - val clientId = config.iosClientId ?: config.webClientId - - // Set up continuation callback - pendingCallback = { token -> - if (continuation.isActive) { - continuation.resume(token) - } - } - - continuation.invokeOnCancellation { - pendingCallback = null - } - - // Call Swift handler with the client ID - handler(clientId) { token -> - if (continuation.isActive) { - continuation.resume(token) - } - pendingCallback = null - } - } - - /** - * Gets the top-most view controller for presenting the sign-in UI. - */ - @OptIn(ExperimentalForeignApi::class) - fun getTopViewController(): UIViewController? { - val keyWindow = UIApplication.sharedApplication.windows - .filterIsInstance() - .firstOrNull { it.isKeyWindow() } - - var topController = keyWindow?.rootViewController - while (topController?.presentedViewController != null) { - topController = topController.presentedViewController - } - return topController - } - - /** - * Returns the iOS client ID for configuration. - */ - fun getClientId(): String? = config.iosClientId ?: config.webClientId -} diff --git a/gradle.properties b/gradle.properties index 56b8912..558fb82 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,3 +16,4 @@ android.useAndroidX=true kotlin.native.cocoapods.fastPodspec=false kotlin.apple.xcodeCompatibility.nowarn=true #kotlin.ios.disabledSimulators=false +GOOGLE_WEB_CLIENT_ID=218717255604-h57da28qm4s2ed0f8js5a9q54gnbett5.apps.googleusercontent.com diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index cc6ab95..45fdc2f 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -4,4 +4,8 @@ PRODUCT_NAME=Login PRODUCT_BUNDLE_IDENTIFIER=com.apptolast.login.Login$(TEAM_ID) CURRENT_PROJECT_VERSION=1 -MARKETING_VERSION=1.0 \ No newline at end of file +MARKETING_VERSION=1.0 + +GOOGLE_WEB_CLIENT_ID=218717255604-h57da28qm4s2ed0f8js5a9q54gnbett5.apps.googleusercontent.com +GOOGLE_IOS_CLIENT_ID=495458702268-1ekoub6nmp7hmkhinuasdlup1rke9kg4.apps.googleusercontent.com +GOOGLE_IOS_REVERSED_CLIENT_ID=com.googleusercontent.apps.495458702268-1ekoub6nmp7hmkhinuasdlup1rke9kg4 diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index d648fa9..c9f68aa 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -2,8 +2,10 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleURLTypes @@ -11,11 +13,13 @@ Editor CFBundleURLSchemes - com.googleusercontent.apps.495458702268-1ekoub6nmp7hmkhinuasdlup1rke9kg4 + $(GOOGLE_IOS_REVERSED_CLIENT_ID) + GIDClientID - 495458702268-1ekoub6nmp7hmkhinuasdlup1rke9kg4.apps.googleusercontent.com + $(GOOGLE_IOS_CLIENT_ID) +