diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 3a68b63b..510cdbb4 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -125,5 +125,12 @@ gradlePlugin { description = "Configures Room for the project" } + // Supabase Config Plugin + register("supabaseConfig") { + id = "org.convention.kmp.supabase.config" + implementationClass = "SupabaseConfigConventionPlugin" + description = "Generates Supabase credentials from secrets file" + } + } } diff --git a/build-logic/convention/src/main/kotlin/SupabaseConfigConventionPlugin.kt b/build-logic/convention/src/main/kotlin/SupabaseConfigConventionPlugin.kt new file mode 100644 index 00000000..c43c9f09 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/SupabaseConfigConventionPlugin.kt @@ -0,0 +1,180 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/kmp-project-template/blob/main/LICENSE + */ + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.convention.libs + +/** + * Convention plugin that generates Supabase credentials from a JSON secrets file. + * + * This plugin: + * 1. Reads credentials from `secrets/supabaseCredentialsFile.json` + * 2. Generates a `SupabaseCredentials` object implementing `template.core.base.network.SupabaseCredentials` + * 3. Adds the generated source to the commonMain source set + * 4. Adds the supabase-postgrest dependency + * + * ## Usage + * + * Apply the plugin to your module's build.gradle.kts: + * ```kotlin + * plugins { + * alias(libs.plugins.kmp.supabase.config) + * } + * + * // Optional: Configure the package name (defaults to module namespace + ".config") + * supabaseConfig { + * packageName = "com.example.myapp.network.config" + * } + * ``` + * + * ## Secrets File Format + * + * Create `secrets/supabaseCredentialsFile.json` in your project root: + * ```json + * { + * "url": "https://your-project.supabase.co", + * "anonKey": "your-anon-key" + * } + * ``` + * + * ## Generated Code + * + * The plugin generates: + * ```kotlin + * object SupabaseCredentials : template.core.base.network.SupabaseCredentials { + * override val url = "https://your-project.supabase.co" + * override val anonKey = "your-anon-key" + * } + * ``` + */ +class SupabaseConfigConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + // Create extension for configuration + val extension = extensions.create("supabaseConfig", SupabaseConfigExtension::class.java) + + val generatedDir = layout.buildDirectory.dir("generated/supabase") + val secretsFile = rootProject.file("secrets/supabaseCredentialsFile.json") + + // Register the code generation task + val generateTask = tasks.register("generateSupabaseConfig") { + // Determine package name from extension or derive from namespace + val packageName = extension.packageName + ?: project.findProperty("android.namespace")?.toString()?.let { "$it.config" } + ?: "${project.group}.config" + + val packagePath = packageName.replace(".", "/") + val outputFile = generatedDir.get().asFile + .resolve("$packagePath/SupabaseCredentials.kt") + + // Track file content as input property for proper up-to-date checking + val fileContent = if (secretsFile.exists() && secretsFile.length() > 0) { + secretsFile.readText() + } else { + "" + } + inputs.property("credentialsContent", fileContent) + inputs.property("packageName", packageName) + outputs.file(outputFile) + + doLast { + val currentContent = inputs.properties["credentialsContent"] as String + val currentPackage = inputs.properties["packageName"] as String + val (url, anonKey) = if (currentContent.isNotEmpty()) { + try { + parseCredentials(currentContent) + } catch (e: Exception) { + logger.warn("Failed to parse Supabase credentials file: ${e.message}") + Pair("", "") + } + } else { + logger.warn("Supabase credentials file not found at: ${secretsFile.absolutePath}") + logger.warn("Create secrets/supabaseCredentialsFile.json with 'url' and 'anonKey' fields") + Pair("", "") + } + + outputFile.parentFile.mkdirs() + + outputFile.writeText( + """ + |/* + | * Copyright 2025 Mifos Initiative + | * + | * This Source Code Form is subject to the terms of the Mozilla Public + | * License, v. 2.0. If a copy of the MPL was not distributed with this + | * file, You can obtain one at https://mozilla.org/MPL/2.0/. + | * + | * See https://github.com/openMF/kmp-project-template/blob/main/LICENSE + | */ + |package $currentPackage + | + |import template.core.base.network.SupabaseCredentials as BaseSupabaseCredentials + | + |/** + | * Generated Supabase credentials from secrets/supabaseCredentialsFile.json + | * DO NOT EDIT - This file is generated by SupabaseConfigConventionPlugin + | */ + |object SupabaseCredentials : BaseSupabaseCredentials { + | override val url: String = "$url" + | override val anonKey: String = "$anonKey" + |} + """.trimMargin() + ) + } + } + + // Configure the Kotlin Multiplatform extension + extensions.configure { + sourceSets.named("commonMain") { + kotlin.srcDir(generatedDir) + dependencies { + implementation(libs.findLibrary("supabase-postgrest").get()) + } + } + } + + // Make compile and KSP tasks depend on the generate task + tasks.matching { + it.name.startsWith("compileKotlin") || it.name.startsWith("ksp") + }.configureEach { + dependsOn(generateTask) + } + } + } + + /** + * Simple JSON parser for extracting url and anonKey fields. + * Avoids dependency on kotlinx-serialization in build-logic. + */ + private fun parseCredentials(jsonContent: String): Pair { + val urlPattern = """"url"\s*:\s*"([^"]*)"""".toRegex() + val anonKeyPattern = """"anonKey"\s*:\s*"([^"]*)"""".toRegex() + + val url = urlPattern.find(jsonContent)?.groupValues?.getOrNull(1) ?: "" + val anonKey = anonKeyPattern.find(jsonContent)?.groupValues?.getOrNull(1) ?: "" + + return Pair(url, anonKey) + } +} + +/** + * Extension for configuring SupabaseConfigConventionPlugin. + */ +open class SupabaseConfigExtension { + /** + * The package name for the generated SupabaseCredentials object. + * If not set, defaults to the module's android namespace + ".config" + */ + var packageName: String? = null +} diff --git a/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt b/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt index ced4fadb..d17703f8 100644 --- a/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt +++ b/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt @@ -2074,6 +2074,35 @@ | | | +--- io.ktor:ktor-client-auth:3.3.3 (*) | | | +--- de.jensklingenberg.ktorfit:ktorfit-lib:2.7.1 (*) | | | +--- co.touchlab:kermit:2.0.8 (*) +| | | +--- io.github.jan-tennert.supabase:postgrest-kt:3.1.1 +| | | | \--- io.github.jan-tennert.supabase:postgrest-kt-android:3.1.1 +| | | | +--- io.github.jan-tennert.supabase:auth-kt:3.1.1 +| | | | | \--- io.github.jan-tennert.supabase:auth-kt-android:3.1.1 +| | | | | +--- androidx.startup:startup-runtime:1.2.0 (*) +| | | | | +--- androidx.browser:browser:1.8.0 -> 1.9.0 (*) +| | | | | +--- io.github.jan-tennert.supabase:supabase-kt:3.1.1 +| | | | | | \--- io.github.jan-tennert.supabase:supabase-kt-android:3.1.1 +| | | | | | +--- androidx.lifecycle:lifecycle-process:2.8.7 -> 2.9.4 (*) +| | | | | | +--- org.jetbrains.kotlinx:kotlinx-datetime:0.6.1 -> 0.7.1 (*) +| | | | | | +--- org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1 -> 1.10.2 (*) +| | | | | | +--- co.touchlab:kermit:2.0.5 -> 2.0.8 (*) +| | | | | | +--- io.ktor:ktor-client-core:3.0.3 -> 3.3.3 (*) +| | | | | | +--- io.ktor:ktor-client-content-negotiation:3.0.3 -> 3.3.3 (*) +| | | | | | +--- io.ktor:ktor-serialization-kotlinx-json:3.0.3 -> 3.3.3 (*) +| | | | | | +--- org.jetbrains.kotlinx:atomicfu:0.27.0 +| | | | | | | \--- org.jetbrains.kotlinx:atomicfu-jvm:0.27.0 +| | | | | | | \--- org.jetbrains.kotlin:kotlin-stdlib:{prefer 2.1.0} -> 2.2.21 (*) +| | | | | | \--- org.jetbrains.kotlin:kotlin-stdlib:2.1.10 -> 2.2.21 (*) +| | | | | +--- com.squareup.okio:okio:3.10.2 -> 3.16.4 (*) +| | | | | +--- org.jetbrains.kotlin:kotlin-stdlib:2.1.10 -> 2.2.21 (*) +| | | | | +--- com.russhwolf:multiplatform-settings-no-arg:1.3.0 (*) +| | | | | +--- com.russhwolf:multiplatform-settings-coroutines:1.3.0 (*) +| | | | | \--- org.kotlincrypto:secure-random:0.3.2 +| | | | | \--- org.kotlincrypto:secure-random-jvm:0.3.2 +| | | | | \--- org.jetbrains.kotlin:kotlin-stdlib:1.9.24 -> 2.2.21 (*) +| | | | +--- org.jetbrains.kotlin:kotlin-reflect:2.1.10 +| | | | | \--- org.jetbrains.kotlin:kotlin-stdlib:2.1.10 -> 2.2.21 (*) +| | | | \--- org.jetbrains.kotlin:kotlin-stdlib:2.1.10 -> 2.2.21 (*) | | | +--- org.jetbrains.kotlin:kotlin-stdlib:2.2.21 (*) | | | +--- io.insert-koin:koin-bom:4.1.1 (*) | | | +--- io.insert-koin:koin-core:4.1.1 (*) diff --git a/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt b/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt index 51a73f2d..fa8d3da8 100644 --- a/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt +++ b/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt @@ -281,6 +281,12 @@ io.coil-kt.coil3:coil-compose:3.3.0 io.coil-kt.coil3:coil-core-android:3.3.0 io.coil-kt.coil3:coil-core:3.3.0 io.coil-kt.coil3:coil:3.3.0 +io.github.jan-tennert.supabase:auth-kt-android:3.1.1 +io.github.jan-tennert.supabase:auth-kt:3.1.1 +io.github.jan-tennert.supabase:postgrest-kt-android:3.1.1 +io.github.jan-tennert.supabase:postgrest-kt:3.1.1 +io.github.jan-tennert.supabase:supabase-kt-android:3.1.1 +io.github.jan-tennert.supabase:supabase-kt:3.1.1 io.github.vinceglb:filekit-coil-android:0.12.0 io.github.vinceglb:filekit-coil:0.12.0 io.github.vinceglb:filekit-core-android:0.12.0 @@ -389,9 +395,12 @@ org.jetbrains.compose.ui:ui-unit:1.9.3 org.jetbrains.compose.ui:ui-util:1.9.3 org.jetbrains.compose.ui:ui:1.9.3 org.jetbrains.kotlin:kotlin-parcelize-runtime:2.2.21 +org.jetbrains.kotlin:kotlin-reflect:2.1.10 org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22 org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22 org.jetbrains.kotlin:kotlin-stdlib:2.2.21 +org.jetbrains.kotlinx:atomicfu-jvm:0.27.0 +org.jetbrains.kotlinx:atomicfu:0.27.0 org.jetbrains.kotlinx:kotlinx-collections-immutable-jvm:0.4.0 org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2 @@ -415,4 +424,6 @@ org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.9.0 org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0 org.jetbrains:annotations:23.0.0 org.jspecify:jspecify:1.0.0 +org.kotlincrypto:secure-random-jvm:0.3.2 +org.kotlincrypto:secure-random:0.3.2 org.slf4j:slf4j-api:2.0.17 diff --git a/core-base/network/build.gradle.kts b/core-base/network/build.gradle.kts index 84d6f298..b25455d8 100644 --- a/core-base/network/build.gradle.kts +++ b/core-base/network/build.gradle.kts @@ -25,6 +25,7 @@ kotlin { api(libs.ktor.client.auth) api(libs.ktorfit.lib) api(libs.kermit.logging) + api(libs.supabase.postgrest) } androidMain.dependencies { diff --git a/core-base/network/src/commonMain/kotlin/template/core/base/network/DynamicBaseUrlPlugin.kt b/core-base/network/src/commonMain/kotlin/template/core/base/network/DynamicBaseUrlPlugin.kt new file mode 100644 index 00000000..48bcedc4 --- /dev/null +++ b/core-base/network/src/commonMain/kotlin/template/core/base/network/DynamicBaseUrlPlugin.kt @@ -0,0 +1,181 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE + */ +package template.core.base.network + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpClientPlugin +import io.ktor.client.request.HttpRequestPipeline +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.http.encodedPath +import io.ktor.http.takeFrom +import io.ktor.util.AttributeKey + +/** + * Ktor plugin that dynamically sets the base URL for each request based on + * a [DynamicUrlConfigProvider] or [MultiUrlConfigProvider]. + * + * This allows the app to switch between different server instances at runtime + * without recreating the HTTP client. + * + * ## Usage with DynamicUrlConfigProvider + * + * ```kotlin + * val client = httpClient { + * install(DynamicBaseUrlPlugin) { + * configProvider = myConfigProvider + * } + * } + * ``` + * + * ## Usage with MultiUrlConfigProvider + * + * ```kotlin + * // For self-service API client + * val selfServiceClient = httpClient { + * install(DynamicBaseUrlPlugin) { + * multiConfigProvider = myMultiConfigProvider + * urlType = MultiUrlConfigProvider.UrlType.SELF_SERVICE + * } + * } + * + * // For interbank API client + * val interbankClient = httpClient { + * install(DynamicBaseUrlPlugin) { + * multiConfigProvider = myMultiConfigProvider + * urlType = MultiUrlConfigProvider.UrlType.INTERBANK + * } + * } + * ``` + */ +class DynamicBaseUrlPlugin private constructor( + private val configProvider: DynamicUrlConfigProvider?, + private val multiConfigProvider: MultiUrlConfigProvider?, + private val urlType: MultiUrlConfigProvider.UrlType, +) { + companion object Plugin : HttpClientPlugin { + override val key: AttributeKey = + AttributeKey("DynamicBaseUrlPlugin") + + override fun prepare(block: DynamicBaseUrlConfig.() -> Unit): DynamicBaseUrlPlugin { + val config = DynamicBaseUrlConfig().apply(block) + return DynamicBaseUrlPlugin( + configProvider = config.configProvider, + multiConfigProvider = config.multiConfigProvider, + urlType = config.urlType, + ) + } + + override fun install(plugin: DynamicBaseUrlPlugin, scope: HttpClient) { + scope.requestPipeline.intercept(HttpRequestPipeline.Before) { + val currentBaseUrl = when { + plugin.multiConfigProvider != null -> { + plugin.multiConfigProvider.getBaseUrl(plugin.urlType) + } + plugin.configProvider != null -> { + plugin.configProvider.getBaseUrl() + } + else -> return@intercept // No provider configured, skip + } + + val originalUrl = context.url.build() + val newUrl = rebuildUrl(originalUrl, currentBaseUrl) + + context.url.takeFrom(newUrl) + } + } + + /** + * Rebuilds the request URL using the dynamic base URL while preserving + * the original path and query parameters. + */ + private fun rebuildUrl(originalUrl: Url, baseUrl: String): Url { + val baseUrlParsed = Url(baseUrl) + + return URLBuilder().apply { + protocol = baseUrlParsed.protocol + host = baseUrlParsed.host + port = baseUrlParsed.port + + // Combine base path with original path + val basePath = baseUrlParsed.encodedPath.trimEnd('/') + val originalPath = originalUrl.encodedPath.trimStart('/') + + encodedPath = if (originalPath.isNotEmpty()) { + "$basePath/$originalPath" + } else { + basePath + } + + // Preserve query parameters + originalUrl.parameters.forEach { name, values -> + parameters.appendAll(name, values) + } + + // Preserve fragment + fragment = originalUrl.fragment + }.build() + } + } +} + +/** + * Configuration class for [DynamicBaseUrlPlugin]. + */ +class DynamicBaseUrlConfig { + /** + * Simple config provider for single URL type applications. + */ + var configProvider: DynamicUrlConfigProvider? = null + + /** + * Multi-URL config provider for applications with multiple API endpoints. + * Takes precedence over [configProvider] if both are set. + */ + var multiConfigProvider: MultiUrlConfigProvider? = null + + /** + * The URL type to use when [multiConfigProvider] is set. + * Defaults to [MultiUrlConfigProvider.UrlType.MAIN]. + */ + var urlType: MultiUrlConfigProvider.UrlType = MultiUrlConfigProvider.UrlType.MAIN +} + +/** + * A dynamic list implementation that provides loggable hosts from a [DynamicUrlConfigProvider]. + * + * This list is evaluated each time it's iterated, so it reflects the currently + * configured loggable hosts. This is useful for Ktor's Logging plugin filter. + * + * ## Usage + * + * ```kotlin + * val client = httpClient( + * setupDefaultHttpClient( + * baseUrl = "https://placeholder.local/", + * loggableHosts = DynamicLoggableHosts(myConfigProvider), + * ) + * ) { + * install(DynamicBaseUrlPlugin) { + * configProvider = myConfigProvider + * } + * } + * ``` + */ +class DynamicLoggableHosts( + private val configProvider: DynamicUrlConfigProvider, +) : AbstractList() { + + override val size: Int + get() = configProvider.getLoggableHosts().size + + override fun get(index: Int): String = + configProvider.getLoggableHosts()[index] +} diff --git a/core-base/network/src/commonMain/kotlin/template/core/base/network/DynamicUrlConfigProvider.kt b/core-base/network/src/commonMain/kotlin/template/core/base/network/DynamicUrlConfigProvider.kt new file mode 100644 index 00000000..a20b4c68 --- /dev/null +++ b/core-base/network/src/commonMain/kotlin/template/core/base/network/DynamicUrlConfigProvider.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE + */ +package template.core.base.network + +/** + * Interface for providing dynamic URL configuration at runtime. + * + * Implementations of this interface allow the HTTP client to dynamically + * switch between different server endpoints without recreating the client. + * + * This is useful for: + * - Multi-tenant applications where users can switch between servers + * - Development/staging/production environment switching + * - White-label apps with different backend endpoints + * + * Example implementation: + * ```kotlin + * class MyConfigProvider( + * private val preferencesRepository: UserPreferencesRepository + * ) : DynamicUrlConfigProvider { + * override fun getBaseUrl(): String = preferencesRepository.selectedServer.value?.url + * ?: "https://default.api.com" + * + * override fun getLoggableHosts(): List = listOf( + * preferencesRepository.selectedServer.value?.host ?: "default.api.com" + * ) + * } + * ``` + */ +interface DynamicUrlConfigProvider { + /** + * Returns the current base URL to use for API requests. + * This is called for each request, allowing runtime URL switching. + */ + fun getBaseUrl(): String + + /** + * Returns the list of hostnames that should have HTTP logging enabled. + * This is called dynamically, allowing the logging filter to adapt + * to the currently selected server. + */ + fun getLoggableHosts(): List +} + +/** + * Extension of [DynamicUrlConfigProvider] for applications with multiple + * URL types (e.g., main API, self-service API, interbank API). + */ +interface MultiUrlConfigProvider : DynamicUrlConfigProvider { + /** + * URL type identifier for different API endpoints. + */ + enum class UrlType { + /** Main API endpoint */ + MAIN, + /** Self-service API endpoint */ + SELF_SERVICE, + /** Interbank/third-party API endpoint */ + INTERBANK, + } + + /** + * Returns the base URL for the specified URL type. + */ + fun getBaseUrl(type: UrlType): String + + /** + * Default implementation returns MAIN URL type. + */ + override fun getBaseUrl(): String = getBaseUrl(UrlType.MAIN) +} diff --git a/core-base/network/src/commonMain/kotlin/template/core/base/network/SupabaseConfigClient.kt b/core-base/network/src/commonMain/kotlin/template/core/base/network/SupabaseConfigClient.kt new file mode 100644 index 00000000..eefd69d7 --- /dev/null +++ b/core-base/network/src/commonMain/kotlin/template/core/base/network/SupabaseConfigClient.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE + */ +package template.core.base.network + +import io.github.jan.supabase.SupabaseClient +import io.github.jan.supabase.createSupabaseClient +import io.github.jan.supabase.logging.LogLevel +import io.github.jan.supabase.postgrest.Postgrest +import io.github.jan.supabase.postgrest.postgrest + +/** + * Generic Supabase client wrapper for KMP projects. + * + * This class provides a reusable Supabase client setup with: + * - Lazy initialization of Supabase client + * - Configurable logging level + * - Direct access to Postgrest for queries + * + * ## Usage + * + * ```kotlin + * // Create credentials (typically from generated SupabaseCredentials object) + * val configClient = SupabaseConfigClient( + * credentials = SupabaseCredentials, // Generated by SupabaseConfigConventionPlugin + * logLevel = LogLevel.DEBUG, + * ) + * + * // Use the client for queries + * if (configClient.isConfigured) { + * val result = configClient.postgrest + * .from("app_config") + * .select() + * .decodeSingle() + * } + * ``` + * + * @param credentials The Supabase credentials (URL and anon key). + * @param logLevel The logging level for Supabase client. Defaults to INFO. + */ +class SupabaseConfigClient( + private val credentials: SupabaseCredentials, + private val logLevel: LogLevel = LogLevel.INFO, +) { + /** + * Lazily initialized Supabase client. + * Only created when first accessed. + */ + val client: SupabaseClient by lazy { + createSupabaseClient( + supabaseUrl = credentials.url, + supabaseKey = credentials.anonKey, + ) { + defaultLogLevel = logLevel + install(Postgrest) + } + } + + /** + * Direct access to Postgrest for database operations. + */ + val postgrest get() = client.postgrest + + /** + * Checks if Supabase is properly configured. + * + * @return true if credentials are valid, false otherwise. + */ + val isConfigured: Boolean + get() = credentials.isConfigured +} + +/** + * Interface for Supabase credentials. + * + * Implement this interface to provide Supabase URL and anon key + * from your project's build configuration or secrets. + * + * ## Generated Implementation + * + * If using the SupabaseConfigConventionPlugin, credentials are + * automatically generated from `secrets/supabaseCredentialsFile.json`: + * + * ```kotlin + * // Auto-generated SupabaseCredentials object + * object SupabaseCredentials : template.core.base.network.SupabaseCredentials { + * override val url = "https://your-project.supabase.co" + * override val anonKey = "your-anon-key" + * } + * ``` + * + * ## Manual Implementation + * + * ```kotlin + * object MySupabaseCredentials : SupabaseCredentials { + * override val url: String = BuildConfig.SUPABASE_URL + * override val anonKey: String = BuildConfig.SUPABASE_ANON_KEY + * } + * ``` + */ +interface SupabaseCredentials { + /** + * The Supabase project URL. + * Example: "https://your-project.supabase.co" + */ + val url: String + + /** + * The Supabase anon (public) key. + * This key is safe to use in client-side code. + */ + val anonKey: String + + /** + * Checks if the credentials are properly configured. + * Returns false if URL or key contain placeholder values. + */ + val isConfigured: Boolean + get() = url.isNotBlank() && + anonKey.isNotBlank() && + !url.contains("YOUR_") && + !anonKey.contains("YOUR_") && + url.startsWith("https://") +} diff --git a/core-base/platform/src/androidMain/kotlin/template/core/base/platform/PlatformBuildConfig.android.kt b/core-base/platform/src/androidMain/kotlin/template/core/base/platform/PlatformBuildConfig.android.kt new file mode 100644 index 00000000..2c44c457 --- /dev/null +++ b/core-base/platform/src/androidMain/kotlin/template/core/base/platform/PlatformBuildConfig.android.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/kmp-project-template/blob/master/LICENSE.md + */ +package template.core.base.platform + +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +/** + * Android implementation of PlatformBuildConfig. + * Uses Koin to get the Android context and check if the app is debuggable. + */ +actual object PlatformBuildConfig : KoinComponent { + private val context: android.content.Context by inject() + + actual val isDebug: Boolean + get() = (context.applicationInfo.flags and + android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 +} diff --git a/core-base/platform/src/commonMain/kotlin/template/core/base/platform/PlatformBuildConfig.kt b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/PlatformBuildConfig.kt new file mode 100644 index 00000000..38626bcc --- /dev/null +++ b/core-base/platform/src/commonMain/kotlin/template/core/base/platform/PlatformBuildConfig.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/kmp-project-template/blob/master/LICENSE.md + */ +package template.core.base.platform + +/** + * Provides build configuration information across all platforms. + * Named PlatformBuildConfig to avoid conflicts with Android's generated BuildConfig. + */ +expect object PlatformBuildConfig { + /** + * Returns true if the app is running in debug mode. + */ + val isDebug: Boolean +} diff --git a/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/PlatformBuildConfig.nonAndroid.kt b/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/PlatformBuildConfig.nonAndroid.kt new file mode 100644 index 00000000..dc3ce498 --- /dev/null +++ b/core-base/platform/src/nonAndroidMain/kotlin/template/core/base/platform/PlatformBuildConfig.nonAndroid.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/kmp-project-template/blob/master/LICENSE.md + */ +package template.core.base.platform + +/** + * Non-Android implementation of PlatformBuildConfig. + * + * For Desktop/iOS/Web platforms, we check if assertions are enabled to determine debug mode. + * In release builds, assertions are typically disabled for performance. + */ +actual object PlatformBuildConfig { + actual val isDebug: Boolean = checkAssertionsEnabled() + + private fun checkAssertionsEnabled(): Boolean { + var assertionsEnabled = false + // This assignment will only happen if assertions are enabled + @Suppress("KotlinConstantConditions", "AssertionInFunctionCall") + assert(true.also { assertionsEnabled = true }) + return assertionsEnabled + } +} diff --git a/core-base/ui/src/commonMain/kotlin/template/core/base/ui/GestureDetector.kt b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/GestureDetector.kt new file mode 100644 index 00000000..3915cb7f --- /dev/null +++ b/core-base/ui/src/commonMain/kotlin/template/core/base/ui/GestureDetector.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See See https://github.com/openMF/kmp-project-template/blob/main/LICENSE + */ +package template.core.base.ui + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.input.pointer.pointerInput +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +/** + * Modifier that detects a multi-tap gesture within a configurable timeout window. + * + * This is useful for revealing hidden developer/debug features, such as: + * - Server instance selector + * - Debug menu + * - Developer options + * + * ## Usage + * + * ```kotlin + * Text( + * text = "Tap me 5 times!", + * modifier = Modifier + * .detectMultiTapGesture { + * // Show hidden feature + * showDebugMenu = true + * } + * ) + * ``` + * + * ## Custom Configuration + * + * ```kotlin + * Text( + * text = "Tap me 3 times quickly!", + * modifier = Modifier + * .detectMultiTapGesture( + * tapCount = 3, + * tapTimeoutMs = 500L, + * ) { + * // Show hidden feature + * } + * ) + * ``` + * + * @param tapCount Number of taps required to trigger the gesture. Defaults to 5. + * @param tapTimeoutMs Time window in milliseconds within which taps must occur. Defaults to 1000ms. + * @param onGestureDetected Callback invoked when the required number of taps is detected within the timeout. + */ +@OptIn(ExperimentalTime::class) +fun Modifier.detectMultiTapGesture( + tapCount: Int = 5, + tapTimeoutMs: Long = 1000L, + onGestureDetected: () -> Unit, +): Modifier = composed { + var currentTapCount by remember { mutableIntStateOf(0) } + var lastTapTime by remember { mutableLongStateOf(0L) } + + this.pointerInput(Unit) { + detectTapGestures( + onTap = { + val currentTime = Clock.System.now().toEpochMilliseconds() + if (currentTime - lastTapTime > tapTimeoutMs) { + currentTapCount = 0 + } + currentTapCount++ + lastTapTime = currentTime + + if (currentTapCount >= tapCount) { + onGestureDetected() + currentTapCount = 0 + } + }, + ) + } +} + +/** + * Modifier that detects a long press gesture. + * + * ## Usage + * + * ```kotlin + * Box( + * modifier = Modifier + * .detectLongPressGesture { + * // Handle long press + * } + * ) + * ``` + * + * @param onLongPress Callback invoked when a long press is detected. + */ +fun Modifier.detectLongPressGesture( + onLongPress: () -> Unit, +): Modifier = composed { + this.pointerInput(Unit) { + detectTapGestures( + onLongPress = { onLongPress() }, + ) + } +} + +/** + * Modifier that detects a double tap gesture. + * + * ## Usage + * + * ```kotlin + * Image( + * modifier = Modifier + * .detectDoubleTapGesture { + * // Handle double tap (e.g., like action) + * } + * ) + * ``` + * + * @param onDoubleTap Callback invoked when a double tap is detected. + */ +fun Modifier.detectDoubleTapGesture( + onDoubleTap: () -> Unit, +): Modifier = composed { + this.pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { onDoubleTap() }, + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b2db3143..88d310a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -68,6 +68,9 @@ ksp = "2.2.21-2.0.4" ktorVersion = "3.3.3" ktorfit = "2.7.1" +# Supabase +supabase = "3.1.1" + # Koin CMP Dependencies koin = "4.1.1" koinAnnotationsVersion = "2.1.0" @@ -272,6 +275,8 @@ ktorfit-ksp = { group = "de.jensklingenberg.ktorfit", name = "ktorfit-ksp", vers ktorfit-converters-flow = { group = "de.jensklingenberg.ktorfit", name = "ktorfit-converters-flow", version.ref = "ktorfit" } ktorfit-lib = { group = "de.jensklingenberg.ktorfit", name = "ktorfit-lib", version.ref = "ktorfit" } +supabase-postgrest = { group = "io.github.jan-tennert.supabase", name = "postgrest-kt", version.ref = "supabase" } + coil-core = { group = "io.coil-kt.coil3", name = "coil-core", version.ref = "coil" } coil-kt = { group = "io.coil-kt.coil3", name = "coil", version.ref = "coil" } coil-kt-compose = { group = "io.coil-kt.coil3", name = "coil-compose-core", version.ref = "coil" } @@ -388,3 +393,6 @@ spotless = { id = "com.diffplug.spotless", version.ref = "spotlessVersion" } #Room Plugin room = { id = "androidx.room", version.ref = "room" } mifos-kmp-room = { id = "mifos.kmp.room", version = "unspecified" } + +# Supabase Config Plugin +kmp-supabase-config = { id = "org.convention.kmp.supabase.config", version = "unspecified" }