diff --git a/.claude/skills/create-new-feature/SKILL.md b/.claude/skills/create-new-feature/SKILL.md index e3089de..b87a852 100644 --- a/.claude/skills/create-new-feature/SKILL.md +++ b/.claude/skills/create-new-feature/SKILL.md @@ -3,20 +3,131 @@ name: create-new-feature description: This skill is to instruct you how to go about implementing a new feature --- -# How to teach me or explain to me - -## Instructions -- The structure of my project is a multi module KMP project -- No single `:shared` module, instead I have multiple shared KMP modules -- Among the shared KMP modules, there are 2 variants. `:lib` and `:feature`. -- `:lib` modules (eg. `:lib:auth`) is for core functionality or I/O of a certain data. -- `:lib` modules will always consist of two child modules. `:api` and `:impl` -- `:feature` modules (eg. `:feature:history`) would be for consumer facing functionalities -- `:feature` modules will always consist of three child modules. `:ui`, `:data` and `:domain` -- `:feature:someFeature:ui` will, however, live in the native codebases since that's where it's implemented -- The feature's data and ui child modules, will depend on domain -- When developing, ensure that you get my approval before each step. I need to be architecturally involved to ensure best quality -- Once a feature/functionality or tech stack is implemented, I want you to update the README for the - project to keep it up to date with our architecture and tech stack choice. - -## Examples \ No newline at end of file +# How to create a new feature + +## Module structure rules + +Multi-module KMP project. No single `:shared` module. + +### `:feature` modules (e.g. `:feature:onboarding`) +User-facing features. Structure: + +``` +:feature: ← parent, thin proxy. No source. Just api(:api). + :api ← REQUIRED. KMP. Public surface: routes, use case interfaces, domain models, api Koin module. + :impl ← OPTIONAL. Only create when there's shared commonMain implementation to hide behind :api. + :android ← pure Android library. Nav entries, ViewModels, Compose, FeatureNavigation impl, Android Koin module. +``` + +Rules: +- `:api` is mandatory. +- `:impl` is case-by-case — add only when there's actual shared Kotlin implementation that warrants hiding. Don't create empty shells. +- `:android` depends on `:impl` when one exists, otherwise depends on `:api` directly. +- Parent has **no source**. It only exists to re-export `:api` via `api(projects.feature..api)`, so consumers depending on the parent get `:api` transitively without needing to know about the internal split. +- iOS presentation lives in the Xcode/SwiftUI project — **not** as a Gradle module. iOS consumes the umbrella `:libs:di` framework for use cases. + +### `:lib` modules (e.g. `:lib:auth`) +Core functionality or I/O. Structure is `:api` + `:impl` + parent aggregator (see existing `libs/auth` and `libs/user` for the pattern). Parent `api`-depends on `:api`, `implementation`-depends on `:impl`. + +## Always get architectural approval before each step. After any new feature lands, update the project README. + +--- + +## Step-by-step: adding a new `:feature:` + +### 1. Create `:feature::api` +File: `feature//api/build.gradle.kts` — applies `yaseyo.kmp.library`, namespace `dev.yaseyo..api`, commonMain deps for what the public API needs. + +Place in `commonMain`: +- `dev.yaseyo..api.navigation.Routes` — `sealed interface Routes : AppRoute` +- `dev.yaseyo..api.model.*` — domain models +- `dev.yaseyo..api.*` — use case classes/interfaces +- `dev.yaseyo..api.di.Module.kt` — `val Module = module { ... }` with bindings that are platform-agnostic + +### 2. Create `:feature:` (parent proxy) +File: `feature//build.gradle.kts`: +```kotlin +plugins { + id("yaseyo.kmp.library") +} + +kotlin { + androidLibrary { namespace = "dev.yaseyo." } + + sourceSets { + commonMain.dependencies { + api(projects.feature..api) + } + } +} +``` +No `src/` directory. Nothing else. + +### 3. Create `:feature::android` +File: `feature//android/build.gradle.kts` — applies `yaseyo.android.library` (and `libs.plugins.composeCompiler` if the module uses Compose). Namespace `dev.yaseyo..android`. Depends on `projects.feature..api` (or `projects.feature..impl` if one exists). + +Place in `src/main/kotlin/dev/yaseyo//android/`: +- `FeatureNavigation.kt` — `internal class FeatureNavigation : FeatureNavigation { ... }` with nav entries +- `FeatureAndroidModule.kt` — `val FeatureAndroidModule = module { factory { FeatureNavigation() } }` + +Do **not** wrap in `activityRetainedScope`. Register in the root scope (see `wiring-up-koin` skill). + +### 4. Register modules in `settings.gradle.kts` +```kotlin +include(":feature:") +include(":feature::api") +include(":feature::android") +// include(":feature::impl") only if :impl was created +``` + +### 5. Wire up in `:libs:di` +`:libs:di` is the DI composition root — the ONE module allowed to depend on `:android` from outside its feature tree. + +In `libs/di/build.gradle.kts`: +```kotlin +commonMain.dependencies { + api(projects.feature.) // transitive :api exposure +} +androidMain.dependencies { + implementation(projects.feature..android) +} +``` + +In `libs/di/src/commonMain/.../AppModules.kt`: +```kotlin +import dev.yaseyo..api.di.Module +// add to appModules list +``` + +In `libs/di/src/androidMain/.../KoinInitializer.kt`: +```kotlin +import dev.yaseyo..android.FeatureAndroidModule +// add to startKoin { modules(...) } call +``` + +### 6. iOS framework export (if the feature is consumed from Swift) +In `libs/di/build.gradle.kts` iOS target block: +```kotlin +export(projects.feature.) +``` +Exports the parent, which transitively exposes `:api` types to Swift. + +--- + +## How `App.kt` collects all Android nav entries (do not change this) + +`App.kt` uses `getKoin().getAll()` to collect every `FeatureNavigation` binding, then wires them all into the Nav3 `entryProvider` via the `registerAll()` extension. Each `:android` module only needs to register its own `factory` — `App.kt` picks it up automatically. + +--- + +## Checklist + +- [ ] `:api` created (routes, use cases, common Koin module) +- [ ] Parent proxy created (no source, just `api(:api)`) +- [ ] `:android` created (nav impl, android Koin module) — depends on `:api` or `:impl` +- [ ] `:impl` added only if there's actual shared implementation to justify it +- [ ] `settings.gradle.kts` updated +- [ ] `:libs:di` wired up: commonMain imports `:api`'s module, androidMain imports `:android`'s module +- [ ] iOS export added if Swift consumption expected +- [ ] User approved architecture at each step +- [ ] README updated diff --git a/.claude/skills/creating-use-case/SKILL.md b/.claude/skills/creating-use-case/SKILL.md new file mode 100644 index 0000000..e2e7546 --- /dev/null +++ b/.claude/skills/creating-use-case/SKILL.md @@ -0,0 +1,16 @@ +--- +name: working-with-use-cases +description: This skill is to instruct you how to go creating or working with UseCase classes +--- + +# How to teach me or explain to me + +## Instructions +- Should only be made when 2 or more repositories/data sources are needed +- Should have 1 function, `execute` in them +- Usages of the UseCase should have useCase as a suffix +- Ensure unit tests are updated or implemented when working with UseCases +- Should be platform-agnostic +- Should have KDocs written for them to better explain + +## Examples \ No newline at end of file diff --git a/.claude/skills/presentation-layer-design/SKILL.md b/.claude/skills/presentation-layer-design/SKILL.md new file mode 100644 index 0000000..8289472 --- /dev/null +++ b/.claude/skills/presentation-layer-design/SKILL.md @@ -0,0 +1,16 @@ +--- +name: presentation-layer-design +description: This skill is to instruct you how to go about designing and implementing the presentation layer +--- + +# How to teach me or explain to me + +## Instructions +- The project may have instances of shared Presenters or opt to go for platform specific presenters. This is decided on case by case basis +- The presentation layer should always consist of the Presenter, UiState, EventHandler interface (which is implemented by the ViewModel/Presenter) and UiAction for side effects triggered by Presenter. This has one exception, and that is for cases where the logic does not have enough complexity to warrant this. Eg. just one side effect is not enough to create a UiAction or just one state is not enough to create a UiState +- Presentation components should be in different files +- Where possible, data sources should be owned by the repository or UseCase layer, not sourced from the Presenter +- For init logic, always go for Flow.onStart for KMP Presenters +- Inject Dispatchers from the Coroutines module to ensure testability + +## Examples \ No newline at end of file diff --git a/.claude/skills/repository-layer-design/SKILL.md b/.claude/skills/repository-layer-design/SKILL.md new file mode 100644 index 0000000..de72841 --- /dev/null +++ b/.claude/skills/repository-layer-design/SKILL.md @@ -0,0 +1,15 @@ +--- +name: repository-layer-design +description: This skill is to instruct you how to create or work with Repository layer classes +--- + +# How to teach me or explain to me + +## Instructions +- Should be framework and platform-agnostic +- Implementations should have `Impl` as a suffix +- Ensure getters are made for iOS in iOS main as these are Koin components +- Ensure test coverage is sufficient +- KDoc the contract for better understanding, the KDoc should not explicitly point to implementation details, but rather just simply explain the purpose of the various APIs + +## Examples \ No newline at end of file diff --git a/.claude/skills/wiring-up-koin/SKILL.md b/.claude/skills/wiring-up-koin/SKILL.md index 61ec073..7156731 100644 --- a/.claude/skills/wiring-up-koin/SKILL.md +++ b/.claude/skills/wiring-up-koin/SKILL.md @@ -3,21 +3,64 @@ name: wiring-up-koin description: This skill is to instruct you how to go about setting up the wiring of Koin --- -# How to teach me or explain to me - -## Instructions - -- Among the shared KMP modules, there are 2 variants. `:lib` and `:feature`. -- `:lib` modules (eg. `:lib:auth`) is for core functionality or I/O of a certain data. -- `:lib` modules will always consist of two child modules. `:api` and `:impl` -- `:feature` modules (eg. `:feature:history`) would be for consumer facing functionalities -- `:feature` modules will always consist of three child modules. `:ui`, `:data` and `:domain` -- `:feature:someFeature:ui` will, however, live in the native codebases since that's where it's - implemented -- The feature's data and ui child modules, will depend on domain -- Each parent module will have an aggregator DI Koin module, every child module will have its own - public Koin module -- which then gets included in the parent module's Koin module. -- The parent's Koin module is then added to the :lib:di module's Koin module. - -## Examples \ No newline at end of file +# How to wire up Koin + +## Composition-root model + +`:libs:di` is the single composition root for Dependency Injection. It: +- Assembles all Koin modules in `AppModules.kt` (commonMain) and `KoinInitializer.kt` (androidMain) +- Is the **only** module outside of a feature tree allowed to reference `:android` / `:impl` directly (enforced by the `yaseyo.kmp.library` / `yaseyo.android.library` convention plugin linter) +- No aggregator/umbrella Koin module per feature. `:libs:di` references the common and platform-specific Koin modules directly. + +## Module ownership per module type + +### `:feature::api` (KMP, commonMain) +```kotlin +val Module = module { + factoryOf(::SomeUseCase) + // platform-agnostic bindings only +} +``` + +### `:feature::impl` (KMP, commonMain) — optional +If an `:impl` exists, it hosts bindings for implementations that should not leak via `:api`. Define a separate `val ImplModule = module { ... }` if needed. + +### `:feature::android` (pure Android library) +```kotlin +val FeatureAndroidModule = module { + factory { FeatureNavigation() } + // Android-specific bindings: ViewModels, platform services, etc. +} +``` + +### `:libs:di` +`AppModules.kt` (commonMain): +```kotlin +internal val appModules = listOf( + // ... + Module, // from :api +) +``` + +`KoinInitializer.kt` (androidMain): +```kotlin +startKoin { + androidContext(context) + modules(appModules + navigationAndroidModule + FeatureAndroidModule) +} +``` + +## Scope rules + +- Register definitions in the **root scope** (`module { factory { ... } }`) unless there is a specific reason to scope them. +- Do **not** wrap definitions in `activityRetainedScope { }` unless the definition must be tied to an activity's retained lifecycle. Scoped definitions are invisible to root-scope lookups like `getKoin().getAll()`. + +## Multi-binding with `getAll` + +When multiple `:android` modules contribute implementations of the same interface (e.g. `FeatureNavigation`), each registers its own `factory`. Call `getKoin().getAll()` at the call site to collect all of them. This is how `App.kt` aggregates nav entries across features. + +## What NOT to do + +- **Don't create a per-feature aggregator Koin module** in the parent. The parent has no source. Aggregation happens at the `:libs:di` composition root. +- **Don't have the parent depend on `:android`** — it would create a project cycle (`:android` depends on `:api`/`:impl`, which live under parent). +- **Don't include `:api`'s Koin module from inside `:android`'s module**. `:libs:di` registers both separately. Using `includes(...)` double-registers. diff --git a/.claude/skills/working-with-gradle/SKILL.md b/.claude/skills/working-with-gradle/SKILL.md index 51e3b56..9bf4e1f 100644 --- a/.claude/skills/working-with-gradle/SKILL.md +++ b/.claude/skills/working-with-gradle/SKILL.md @@ -3,13 +3,64 @@ name: working-with-gradle description: This skill is to instruct you how to go about configuring a new gradle module or altering the gradle configuration of this project by adding/removing/updating dependencies. --- -# How to teach me or explain to me +# Working with Gradle -## Instructions +## General rules -- When adding a new KMP module, use the convention plugin/s defined in the build-logic module -- When adding a plugin or configuring a block in build.gradle.kts, consider adding it into either an - existing convention plugin or suggesting the creation of a new one. -- When adding a plugin, don't forget to add the plugin to the project level build.gradle.kts too! +- When adding a new module, pick a convention plugin from `build-logic` — do not hand-roll `android { }` / `kotlin { }` config. +- When adding a new plugin to the catalog, also add it to the project-level `build.gradle.kts` if it needs to be on the classpath. +- When you find yourself repeating config across modules, promote it into an existing convention plugin (or suggest creating a new one) rather than duplicating. -## Examples \ No newline at end of file +## Convention plugins + +Two convention plugins cover all library modules: + +### `yaseyo.kmp.library` +KMP modules. Applies: +- `org.jetbrains.kotlin.multiplatform` +- `com.android.kotlin.multiplatform.library` +- `com.android.lint` +- `org.jetbrains.kotlin.plugin.serialization` +- iOS targets (`iosArm64`, `iosSimulatorArm64`) + +Exposes a `kmpLibrary { }` DSL for iOS framework production (`iosFrameworkName`, `enableSkie`). Framework production is restricted to `:libs:di` only — attempting to set `iosFrameworkName` in any other module fails the build. Reason: iOS can load at most one K/N framework per process. + +### `yaseyo.android.library` +Pure Android libraries (typically `:feature:*:android`). Applies: +- `com.android.library` (includes lint) +- `org.jetbrains.kotlin.android` + +Sets min/compile SDK from the version catalog, Java 11 source/target compatibility, and Kotlin JVM 11 target to match the rest of the project. + +## Module structure patterns + +### `:feature:` +See `create-new-feature` skill. Structure: +- Parent (`yaseyo.kmp.library`) — no source, just `api(:api)` in commonMain +- `:api` (`yaseyo.kmp.library`) — required, commonMain public surface +- `:impl` (`yaseyo.kmp.library`) — optional +- `:android` (`yaseyo.android.library`) — pure Android, depends on `:impl` if exists else `:api` + +### `:lib:` +Parent (`yaseyo.kmp.library`) with commonMain aggregating `api(:api)` + `implementation(:impl)`. See `libs/auth` and `libs/user` for the pattern. + +## Internal module boundary linter + +`ModuleBoundaryEnforcement.kt` (invoked automatically by both convention plugins) enforces that `:impl` and `:android` modules are internal: + +- **Allowed** to depend on `:feature:onboarding:impl` / `:feature:onboarding:android`: + - Any module within the same feature tree (e.g. `:feature:onboarding` or `:feature:onboarding:android`) + - `:libs:di` (DI composition root, whitelisted) +- **Not allowed**: any other module. The build fails with a "DESIGN BREACH" message showing the exact line to change. + +To extend the rule (e.g. add another internal suffix, or whitelist another module), edit: +`build-logic/convention/src/main/kotlin/dev/yaseyo/ModuleBoundaryEnforcement.kt` + +## iOS framework export (for Swift consumption) + +In `libs/di/build.gradle.kts`, inside the iOS target block: +```kotlin +export(projects.feature.) // always the parent, never :api/:impl/:android +``` + +And `api(projects.feature.)` in commonMain dependencies so that the exported types are visible at compile time. diff --git a/.editorconfig b/.editorconfig index 2b1f9cd..da065b5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,5 @@ +root = true + [*.kt] ktlint_function_naming_ignore_when_annotated_with = Composable ktlint_standard_multiline-expression-wrapping = disabled diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index bba5527..ae85e57 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -20,5 +20,9 @@ gradlePlugin { id = "yaseyo.kmp.library" implementationClass = "dev.yaseyo.KmpLibraryConventionPlugin" } + register("androidLibrary") { + id = "yaseyo.android.library" + implementationClass = "dev.yaseyo.AndroidLibraryConventionPlugin" + } } } diff --git a/build-logic/convention/src/main/kotlin/dev/yaseyo/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/dev/yaseyo/AndroidLibraryConventionPlugin.kt new file mode 100644 index 0000000..5ff4333 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/dev/yaseyo/AndroidLibraryConventionPlugin.kt @@ -0,0 +1,48 @@ +package dev.yaseyo + +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.JavaVersion +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +class AndroidLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + with(pluginManager) { + apply("com.android.library") + apply("org.jetbrains.kotlin.android") + } + } + + target.enforceInternalModuleBoundaries() + + val libs = + target + .extensions + .getByType(VersionCatalogsExtension::class.java) + .named("libs") + + val android = target.extensions.getByType(LibraryExtension::class.java) + android.compileSdk = + libs + .findVersion("android-compileSdk") + .get() + .requiredVersion + .toInt() + android.defaultConfig.minSdk = + libs + .findVersion("android-minSdk") + .get() + .requiredVersion + .toInt() + android.compileOptions.sourceCompatibility = JavaVersion.VERSION_11 + android.compileOptions.targetCompatibility = JavaVersion.VERSION_11 + + target.tasks.withType(KotlinCompile::class.java).configureEach { + compilerOptions.jvmTarget.set(JvmTarget.JVM_11) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/dev/yaseyo/KmpLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/dev/yaseyo/KmpLibraryConventionPlugin.kt index 2e1ba36..e561f3f 100644 --- a/build-logic/convention/src/main/kotlin/dev/yaseyo/KmpLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/dev/yaseyo/KmpLibraryConventionPlugin.kt @@ -43,6 +43,8 @@ class KmpLibraryConventionPlugin : Plugin { ) } + target.enforceInternalModuleBoundaries() + target.afterEvaluate { val frameworkName = extension.iosFrameworkName.orNull diff --git a/build-logic/convention/src/main/kotlin/dev/yaseyo/ModuleBoundaryEnforcement.kt b/build-logic/convention/src/main/kotlin/dev/yaseyo/ModuleBoundaryEnforcement.kt new file mode 100644 index 0000000..4e37244 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/dev/yaseyo/ModuleBoundaryEnforcement.kt @@ -0,0 +1,46 @@ +package dev.yaseyo + +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.artifacts.ProjectDependency + +private val INTERNAL_MODULE_SUFFIXES = setOf(":impl", ":android") +private val INTERNAL_MODULE_CONSUMER_ALLOWLIST = setOf(":libs:di") + +fun Project.enforceInternalModuleBoundaries() { + afterEvaluate { + if (path in INTERNAL_MODULE_CONSUMER_ALLOWLIST) return@afterEvaluate + + configurations.forEach { configuration -> + configuration.dependencies + .withType(ProjectDependency::class.java) + .forEach { dep -> + val depPath = dep.dependencyProject.path + if (depPath == path) return@forEach + val suffix = INTERNAL_MODULE_SUFFIXES.firstOrNull { depPath.endsWith(it) } ?: return@forEach + val expectedParent = depPath.removeSuffix(suffix) + val isSameTree = path == expectedParent || path.startsWith("$expectedParent:") + if (!isSameTree) { + throw GradleException( + buildString { + appendLine() + appendLine("DESIGN BREACH ───────────────────────────────────────────────────") + appendLine(" Module : $path") + appendLine(" Depends on : $depPath ← $suffix is internal") + appendLine() + appendLine(" $suffix modules are internal to their parent. No module outside") + appendLine(" the parent ('$expectedParent') is allowed to depend on them.") + appendLine(" The parent re-exports the public API and aggregates Koin modules.") + appendLine() + appendLine(" In $path/build.gradle.kts, replace:") + appendLine(" implementation(projects${depPath.replace(":", ".")})") + appendLine(" with:") + appendLine(" implementation(projects${expectedParent.replace(":", ".")})") + appendLine("─────────────────────────────────────────────────────────────────") + }, + ) + } + } + } + } +} diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ce94afc..34e0785 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -17,13 +17,11 @@ kotlin { sourceSets { androidMain.dependencies { - implementation(libs.compose.uiToolingPreview) - implementation(libs.androidx.activity.compose) - implementation(libs.koin.android) - implementation(libs.koin.compose) + // Libs implementation(projects.libs.di) - } - commonMain.dependencies { + + // Dependencies + implementation(libs.androidx.startup.runtime) implementation(libs.koin.core) implementation(libs.compose.runtime) implementation(libs.compose.foundation) @@ -33,8 +31,17 @@ kotlin { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.koin.compose.nav3) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) + implementation(libs.compose.uiToolingPreview) + implementation(libs.androidx.activity.compose) + implementation(libs.koin.android) + implementation(libs.koin.compose) + implementation(libs.androidx.splashscreen) } - commonTest.dependencies { + androidUnitTest.dependencies { implementation(libs.kotlin.test) } } diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 8bbe7bd..de2b44a 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -1,5 +1,6 @@ - + + android:theme="@style/Theme.Yaseyo"> + android:name=".MainActivity" + android:theme="@style/Theme.Yaseyo.Starting"> + + + + diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt index 3a1b7d5..b678bda 100644 --- a/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/App.kt @@ -1,47 +1,45 @@ package dev.yaseyo -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.safeContentPadding -import androidx.compose.material3.Button +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import dev.yaseyo.navigation.AppRoute +import dev.yaseyo.navigation.FeatureNavigation +import dev.yaseyo.navigation.Navigator +import org.koin.compose.getKoin +import org.koin.core.annotation.KoinExperimentalAPI +@OptIn(KoinExperimentalAPI::class) @Composable -@Preview -fun App() { +fun App(navigator: Navigator) { + val allFeatureNavigation = getKoin().getAll() + MaterialTheme { - var showContent by remember { mutableStateOf(false) } - Column( - modifier = - Modifier - .background(MaterialTheme.colorScheme.primaryContainer) - .safeContentPadding() - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Button(onClick = { showContent = !showContent }) { - Text("Click me!") - } - AnimatedVisibility(showContent) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text("Compose: KMP") - } - } + Scaffold { padding -> + NavDisplay( + backStack = navigator.backStack, + modifier = Modifier.padding(padding), + onBack = { navigator.goBack() }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = allFeatureNavigation.registerAll(), + ) } } } + +private fun List.registerAll(): (AppRoute) -> NavEntry = + entryProvider { + forEach { + it.navEntryProvider(this) + } + } diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt index 7858cb9..6e3b7f8 100644 --- a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainActivity.kt @@ -4,22 +4,55 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.yaseyo.navigation.Navigator +import org.koin.android.scope.AndroidScopeComponent +import org.koin.androidx.scope.activityRetainedScope +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.scope.Scope + +class MainActivity : + ComponentActivity(), + AndroidScopeComponent { + override val scope: Scope by activityRetainedScope() + + private val viewModel: MainViewModel by viewModel() + private val navigator: Navigator by lazy { scope.get() } -class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() enableEdgeToEdge() super.onCreate(savedInstanceState) + splashScreen.setKeepOnScreenCondition { + viewModel.startDestination.value == null + } + setContent { - App() + val startDestination by viewModel.startDestination.collectAsStateWithLifecycle() + + var showApp by remember { + mutableStateOf(false) + } + + LaunchedEffect(startDestination) { + if (!showApp) { + startDestination?.let { startDestination -> + navigator.goTo(startDestination) + showApp = true + } + } + } + + if (showApp) { + App(navigator = navigator) + } } } } - -@Preview -@Composable -fun AppAndroidPreview() { - App() -} diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt new file mode 100644 index 0000000..166fcca --- /dev/null +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/MainViewModel.kt @@ -0,0 +1,37 @@ +package dev.yaseyo + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.yaseyo.navigation.AppRoute +import dev.yaseyo.navigation.Home +import dev.yaseyo.onboarding.api.ResolveAppStateUseCase +import dev.yaseyo.onboarding.api.model.AppState +import dev.yaseyo.onboarding.api.navigation.OnboardingRoutes +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn + +class MainViewModel( + private val resolveAppStateUseCase: ResolveAppStateUseCase, +) : ViewModel() { + val startDestination: StateFlow = flow { + val appState = resolveAppStateUseCase.execute() + + emit( + value = appState.let { + when (it) { + AppState.FullySetup -> Home + + AppState.RequiresLogin -> OnboardingRoutes.Auth + + AppState.RequiresProfileSetup -> OnboardingRoutes.ProfileSetup + } + }, + ) + }.stateIn( + scope = viewModelScope, + initialValue = null, + started = SharingStarted.Eagerly, + ) +} diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/di/MainModule.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/di/MainModule.kt new file mode 100644 index 0000000..6571d4f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/di/MainModule.kt @@ -0,0 +1,9 @@ +package dev.yaseyo.di + +import dev.yaseyo.MainViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val mainModule = module { + viewModel { MainViewModel(get()) } +} diff --git a/composeApp/src/androidMain/kotlin/dev/yaseyo/di/MainModuleInitializer.kt b/composeApp/src/androidMain/kotlin/dev/yaseyo/di/MainModuleInitializer.kt new file mode 100644 index 0000000..badab17 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/dev/yaseyo/di/MainModuleInitializer.kt @@ -0,0 +1,13 @@ +package dev.yaseyo.di + +import android.content.Context +import androidx.startup.Initializer +import org.koin.core.context.loadKoinModules + +class MainModuleInitializer : Initializer { + override fun create(context: Context) { + loadKoinModules(mainModule) + } + + override fun dependencies(): List>> = listOf(KoinInitializer::class.java) +} diff --git a/composeApp/src/androidMain/res/values/themes.xml b/composeApp/src/androidMain/res/values/themes.xml new file mode 100644 index 0000000..32f3672 --- /dev/null +++ b/composeApp/src/androidMain/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/feature/onboarding/android/build.gradle.kts b/feature/onboarding/android/build.gradle.kts new file mode 100644 index 0000000..317d496 --- /dev/null +++ b/feature/onboarding/android/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("yaseyo.android.library") + alias(libs.plugins.composeCompiler) +} + +android { + namespace = "dev.yaseyo.onboarding.android" +} + +dependencies { + implementation(projects.feature.onboarding.api) + + implementation(libs.kotlin.stdlib) + implementation(libs.koin.core) + implementation(libs.koin.android) + implementation(libs.koin.compose.nav3) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) +} diff --git a/feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureAndroidModule.kt b/feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureAndroidModule.kt new file mode 100644 index 0000000..4173cec --- /dev/null +++ b/feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureAndroidModule.kt @@ -0,0 +1,8 @@ +package dev.yaseyo.onboarding.android + +import dev.yaseyo.navigation.FeatureNavigation +import org.koin.dsl.module + +val onboardingFeatureAndroidModule = module { + factory { OnboardingFeatureNavigation() } +} diff --git a/feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureNavigation.kt b/feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureNavigation.kt new file mode 100644 index 0000000..69e3fbf --- /dev/null +++ b/feature/onboarding/android/src/main/kotlin/dev/yaseyo/onboarding/android/OnboardingFeatureNavigation.kt @@ -0,0 +1,19 @@ +package dev.yaseyo.onboarding.android + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.navigation3.runtime.EntryProviderScope +import dev.yaseyo.navigation.AppRoute +import dev.yaseyo.navigation.FeatureNavigation +import dev.yaseyo.onboarding.api.navigation.OnboardingRoutes + +internal class OnboardingFeatureNavigation : FeatureNavigation { + override val navEntryProvider: EntryProviderScope.() -> Unit = { + entry { + Box(modifier = Modifier.fillMaxSize().background(Color.Red)) + } + } +} diff --git a/feature/onboarding/api/build.gradle.kts b/feature/onboarding/api/build.gradle.kts new file mode 100644 index 0000000..f4dfe60 --- /dev/null +++ b/feature/onboarding/api/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + id("yaseyo.kmp.library") +} + +kotlin { + androidLibrary { + namespace = "dev.yaseyo.onboarding.api" + + withHostTestBuilder { + } + + withDeviceTestBuilder { + sourceSetTreeName = "test" + }.configure { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + implementation(libs.koin.core) + api(libs.kotlinx.coroutines.core) + + implementation(projects.libs.auth) + implementation(projects.libs.user) + api(projects.libs.navigation) + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + } + } + + getByName("androidDeviceTest") { + dependencies { + implementation(libs.androidx.runner) + implementation(libs.androidx.core) + implementation(libs.androidx.testExt.junit) + } + } + + iosMain { + dependencies { + } + } + } +} diff --git a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/ResolveAppStateUseCase.kt b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/ResolveAppStateUseCase.kt new file mode 100644 index 0000000..06ea145 --- /dev/null +++ b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/ResolveAppStateUseCase.kt @@ -0,0 +1,25 @@ +package dev.yaseyo.onboarding.api + +import dev.yaseyo.auth.api.AuthRepository +import dev.yaseyo.auth.api.AuthState +import dev.yaseyo.onboarding.api.model.AppState +import dev.yaseyo.user.api.UserRepository + +class ResolveAppStateUseCase( + private val authRepository: AuthRepository, + private val userRepository: UserRepository, +) { + suspend fun execute(): AppState = + when (authRepository.authState.value) { + is AuthState.SignedOut -> AppState.RequiresLogin + + is AuthState.SignedIn -> { + val user = userRepository.getCurrentUser() + if (user.isSuccess) { + AppState.FullySetup + } else { + AppState.RequiresProfileSetup + } + } + } +} diff --git a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/di/OnboardingModule.kt b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/di/OnboardingModule.kt new file mode 100644 index 0000000..2c0d265 --- /dev/null +++ b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/di/OnboardingModule.kt @@ -0,0 +1,9 @@ +package dev.yaseyo.onboarding.api.di + +import dev.yaseyo.onboarding.api.ResolveAppStateUseCase +import org.koin.core.module.dsl.factoryOf +import org.koin.dsl.module + +val onboardingModule = module { + factoryOf(::ResolveAppStateUseCase) +} diff --git a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/model/AppState.kt b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/model/AppState.kt new file mode 100644 index 0000000..ef17749 --- /dev/null +++ b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/model/AppState.kt @@ -0,0 +1,9 @@ +package dev.yaseyo.onboarding.api.model + +sealed interface AppState { + data object FullySetup : AppState + + data object RequiresProfileSetup : AppState + + data object RequiresLogin : AppState +} diff --git a/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/navigation/OnboardingRoutes.kt b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/navigation/OnboardingRoutes.kt new file mode 100644 index 0000000..2baac56 --- /dev/null +++ b/feature/onboarding/api/src/commonMain/kotlin/dev/yaseyo/onboarding/api/navigation/OnboardingRoutes.kt @@ -0,0 +1,9 @@ +package dev.yaseyo.onboarding.api.navigation + +import dev.yaseyo.navigation.AppRoute + +sealed interface OnboardingRoutes : AppRoute { + data object Auth : OnboardingRoutes + + data object ProfileSetup : OnboardingRoutes +} diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts new file mode 100644 index 0000000..41fe8ea --- /dev/null +++ b/feature/onboarding/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("yaseyo.kmp.library") +} + +kotlin { + androidLibrary { + namespace = "dev.yaseyo.onboarding" + } + + sourceSets { + commonMain.dependencies { + api(projects.feature.onboarding.api) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 171d4d3..6bed243 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ material3 = "1.10.0-alpha05" kotlinStdlib = "2.3.20" koin = "4.2.1" androidx-startup = "1.2.0" +androidx-splashscreen = "1.0.1" runner = "1.7.0" core = "1.7.0" skie = "0.10.11" @@ -28,6 +29,8 @@ ktlint = "1.5.0" ktor = "3.4.2" kotlinx-serialization = "1.11.0" kotlinx-datetime = "0.7.1" +nav3Core = "1.1.0" +lifecycleViewmodelNav3 = "2.11.0-alpha03" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -39,6 +42,7 @@ androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", vers androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } +androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } @@ -51,9 +55,12 @@ firebase-auth = { module = "dev.gitlive:firebase-auth", version.ref = "firebase- firebase-android-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-android-bom" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } +koin-core-viewmodel = { module = "io.insert-koin:koin-core-viewmodel", version.ref = "koin" } androidx-startup-runtime = { module = "androidx.startup:startup-runtime", version.ref = "androidx-startup" } +androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } +koin-compose-nav3 = { module = "io.insert-koin:koin-compose-navigation3", version.ref = "koin" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlinStdlib" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } @@ -69,6 +76,9 @@ ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx ktor-testing = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/iosApp/iosApp/KmpObservable.swift b/iosApp/iosApp/KmpObservable.swift new file mode 100644 index 0000000..4266f2f --- /dev/null +++ b/iosApp/iosApp/KmpObservable.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Wraps a KMP ViewModel-like object as a SwiftUI ObservableObject. +/// The onDeinit closure is called when this holder is deallocated, +/// which is where you call kmp.onCleared() to cancel the coroutine scope. +@MainActor +final class KmpObservable: ObservableObject { + let kmp: T + private let onDeinit: () -> Void + + init(_ kmp: T, onDeinit: @escaping () -> Void) { + self.kmp = kmp + self.onDeinit = onDeinit + } + + deinit { + onDeinit() + } +} diff --git a/libs/auth/api/src/commonMain/kotlin/dev/yaseyo/auth/api/AuthRepository.kt b/libs/auth/api/src/commonMain/kotlin/dev/yaseyo/auth/api/AuthRepository.kt index 0b239de..9f70327 100644 --- a/libs/auth/api/src/commonMain/kotlin/dev/yaseyo/auth/api/AuthRepository.kt +++ b/libs/auth/api/src/commonMain/kotlin/dev/yaseyo/auth/api/AuthRepository.kt @@ -12,5 +12,10 @@ interface AuthRepository { password: String, ): Result + suspend fun signUpWithEmailAndPassword( + email: String, + password: String, + ): Result + suspend fun signOut() } diff --git a/libs/auth/impl/src/commonMain/kotlin/dev/yaseyo/auth/impl/AuthRepositoryImpl.kt b/libs/auth/impl/src/commonMain/kotlin/dev/yaseyo/auth/impl/AuthRepositoryImpl.kt index 28d1d41..cbf4a6c 100644 --- a/libs/auth/impl/src/commonMain/kotlin/dev/yaseyo/auth/impl/AuthRepositoryImpl.kt +++ b/libs/auth/impl/src/commonMain/kotlin/dev/yaseyo/auth/impl/AuthRepositoryImpl.kt @@ -50,6 +50,17 @@ internal class AuthRepositoryImpl( ) } + override suspend fun signUpWithEmailAndPassword( + email: String, + password: String, + ): Result = + runCatching { + firebaseAuth.createUserWithEmailAndPassword( + email = email, + password = password, + ) + } + override suspend fun signOut() { firebaseAuth.signOut() } diff --git a/libs/design/build.gradle.kts b/libs/design/build.gradle.kts new file mode 100644 index 0000000..564cc9d --- /dev/null +++ b/libs/design/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id("yaseyo.kmp.library") + alias(libs.plugins.composeCompiler) +} + +kotlin { + androidLibrary { + namespace = "dev.yaseyo.design" + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + } + } + + androidMain { + dependencies { + implementation(libs.compose.ui) + implementation(libs.compose.material3) + implementation(libs.compose.foundation) + } + } + } +} diff --git a/libs/design/src/androidMain/kotlin/dev/yaseyo/design/YaseyoTheme.kt b/libs/design/src/androidMain/kotlin/dev/yaseyo/design/YaseyoTheme.kt new file mode 100644 index 0000000..0819429 --- /dev/null +++ b/libs/design/src/androidMain/kotlin/dev/yaseyo/design/YaseyoTheme.kt @@ -0,0 +1,29 @@ +package dev.yaseyo.design + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf + +private val LocalAppColors = staticCompositionLocalOf { + error("No AppColors provided — wrap your UI in YaseyoTheme { }") +} + +@Composable +fun YaseyoTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalAppColors provides if (darkTheme) DarkAppColors else LightAppColors, + content = content, + ) +} + +object YaseyoTheme { + val colors: AppColors + @Composable + @ReadOnlyComposable + get() = LocalAppColors.current +} diff --git a/libs/design/src/commonMain/kotlin/dev/yaseyo/design/AppColors.kt b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/AppColors.kt new file mode 100644 index 0000000..d93be2b --- /dev/null +++ b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/AppColors.kt @@ -0,0 +1,82 @@ +package dev.yaseyo.design + +data class AppColors( + // ── Backgrounds ────────────────────────────────────────────────────────── + val backgroundBase: Long, // app root background + val backgroundSubtle: Long, // recessed areas, input fills + val backgroundElevated: Long, // cards, sheets, dialogs + // ── Content (text & icons) ─────────────────────────────────────────────── + val contentPrimary: Long, // body text, primary icons + val contentSecondary: Long, // labels, captions + val contentTertiary: Long, // placeholder, hints + val contentDisabled: Long, // disabled state + val contentOnAccent: Long, // text / icons sitting on accent fill + // ── Accent (brand action) ──────────────────────────────────────────────── + val accentDefault: Long, // buttons, active controls, links + val accentHover: Long, // accentDefault darkened for hover/press + val accentSubtle: Long, // tinted backgrounds, selected rows + val accentMuted: Long, // very light tint, chips, tags + // ── Borders ─────────────────────────────────────────────────────────────── + val borderSubtle: Long, // dividers, hairlines + val borderDefault: Long, // input rings, card edges + val borderStrong: Long, // focused input, emphasis + // ── Feedback ────────────────────────────────────────────────────────────── + val feedbackPositive: Long, + val feedbackPositiveSubtle: Long, + val feedbackCritical: Long, + val feedbackCriticalSubtle: Long, + val feedbackWarning: Long, + val feedbackWarningSubtle: Long, + // ── Scrim ───────────────────────────────────────────────────────────────── + val scrim: Long, +) + +val LightAppColors = AppColors( + backgroundBase = 0xFFFBFDF9L, + backgroundSubtle = 0xFFEEF2EFL, + backgroundElevated = 0xFFFFFFFFL, + contentPrimary = 0xFF191C1AL, + contentSecondary = 0xFF404943L, + contentTertiary = 0xFF707973L, + contentDisabled = 0xFFC0C9C2L, + contentOnAccent = 0xFFFFFFFFL, + accentDefault = 0xFF1B7A5EL, + accentHover = 0xFF145C46L, + accentSubtle = 0xFFA5F2D6L, + accentMuted = 0xFFDCE5DEL, + borderSubtle = 0xFFDCE5DEL, + borderDefault = 0xFFC0C9C2L, + borderStrong = 0xFF707973L, + feedbackPositive = 0xFF1B7A5EL, + feedbackPositiveSubtle = 0xFFA5F2D6L, + feedbackCritical = 0xFFBA1A1AL, + feedbackCriticalSubtle = 0xFFFFDAD6L, + feedbackWarning = 0xFF7D5700L, + feedbackWarningSubtle = 0xFFFFDFA3L, + scrim = 0x99000000L, +) + +val DarkAppColors = AppColors( + backgroundBase = 0xFF191C1AL, + backgroundSubtle = 0xFF1F2421L, + backgroundElevated = 0xFF2E3130L, + contentPrimary = 0xFFE1E3DFL, + contentSecondary = 0xFFC0C9C2L, + contentTertiary = 0xFF8A938CL, + contentDisabled = 0xFF404943L, + contentOnAccent = 0xFF00382AL, + accentDefault = 0xFF89D5B5L, + accentHover = 0xFFA5E8C9L, + accentSubtle = 0xFF005140L, + accentMuted = 0xFF404943L, + borderSubtle = 0xFF2E3130L, + borderDefault = 0xFF404943L, + borderStrong = 0xFF8A938CL, + feedbackPositive = 0xFF89D5B5L, + feedbackPositiveSubtle = 0xFF005140L, + feedbackCritical = 0xFFFFB4ABL, + feedbackCriticalSubtle = 0xFF93000AL, + feedbackWarning = 0xFFF5BE48L, + feedbackWarningSubtle = 0xFF4C3800L, + scrim = 0xCC000000L, +) diff --git a/libs/di/build.gradle.kts b/libs/di/build.gradle.kts index 9f0afc5..1f92ceb 100644 --- a/libs/di/build.gradle.kts +++ b/libs/di/build.gradle.kts @@ -20,6 +20,8 @@ kotlin { ).forEach { target -> target.binaries.withType().configureEach { export(projects.libs.auth.api) + export(projects.libs.user) + export(projects.feature.onboarding) } } @@ -34,6 +36,11 @@ kotlin { api(projects.libs.auth.api) implementation(projects.libs.auth) implementation(projects.libs.network) + api(projects.libs.user) + implementation(projects.libs.navigation) + + // Features + api(projects.feature.onboarding) } } @@ -41,6 +48,8 @@ kotlin { dependencies { implementation(libs.koin.android) implementation(libs.androidx.startup.runtime) + + implementation(projects.feature.onboarding.android) } } diff --git a/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt b/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt index edfbe32..24a1147 100644 --- a/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt +++ b/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt @@ -2,6 +2,8 @@ package dev.yaseyo.di import android.content.Context import androidx.startup.Initializer +import dev.yaseyo.navigation.navigationAndroidModule +import dev.yaseyo.onboarding.android.onboardingFeatureAndroidModule import org.koin.android.ext.koin.androidContext import org.koin.core.KoinApplication import org.koin.core.context.startKoin @@ -10,7 +12,7 @@ class KoinInitializer : Initializer { override fun create(context: Context): KoinApplication = startKoin { androidContext(context) - modules(appModules) // Add modules here + modules(appModules + navigationAndroidModule + onboardingFeatureAndroidModule) } override fun dependencies(): List>> = emptyList() diff --git a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt index 7eec342..7faaca3 100644 --- a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt +++ b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt @@ -3,11 +3,15 @@ package dev.yaseyo.di import dev.yaseyo.auth.di.authModule import dev.yaseyo.coroutines.coroutinesModule import dev.yaseyo.network.networkModule +import dev.yaseyo.onboarding.api.di.onboardingModule +import dev.yaseyo.user.di.userModule import org.koin.dsl.module internal val appModules = listOf( coroutinesModule, authModule, + userModule, module { single { networkConfig } }, networkModule, + onboardingModule, ) diff --git a/libs/navigation/build.gradle.kts b/libs/navigation/build.gradle.kts new file mode 100644 index 0000000..780653e --- /dev/null +++ b/libs/navigation/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + id("yaseyo.kmp.library") +} + +kotlin { + androidLibrary { + namespace = "dev.yaseyo.navigation" + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + } + } + + androidMain { + dependencies { + api(libs.androidx.navigation3.runtime) + implementation(libs.koin.core) + implementation(libs.koin.android) + } + } + } +} diff --git a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/AppRoute.android.kt b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/AppRoute.android.kt new file mode 100644 index 0000000..db64fe0 --- /dev/null +++ b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/AppRoute.android.kt @@ -0,0 +1,5 @@ +package dev.yaseyo.navigation + +import androidx.navigation3.runtime.NavKey + +actual interface AppRoute : NavKey diff --git a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/FeatureNavigation.kt b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/FeatureNavigation.kt new file mode 100644 index 0000000..aaab96d --- /dev/null +++ b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/FeatureNavigation.kt @@ -0,0 +1,7 @@ +package dev.yaseyo.navigation + +import androidx.navigation3.runtime.EntryProviderScope + +interface FeatureNavigation { + val navEntryProvider: EntryProviderScope.() -> Unit +} diff --git a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/NavigationModule.kt b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/NavigationModule.kt new file mode 100644 index 0000000..230ecf8 --- /dev/null +++ b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/NavigationModule.kt @@ -0,0 +1,11 @@ +package dev.yaseyo.navigation + +import org.koin.androidx.scope.dsl.activityRetainedScope +import org.koin.core.module.dsl.scopedOf +import org.koin.dsl.module + +val navigationAndroidModule = module { + activityRetainedScope { + scopedOf(::Navigator) + } +} diff --git a/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/Navigator.kt b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/Navigator.kt new file mode 100644 index 0000000..d6f2530 --- /dev/null +++ b/libs/navigation/src/androidMain/kotlin/dev/yaseyo/navigation/Navigator.kt @@ -0,0 +1,15 @@ +package dev.yaseyo.navigation + +import androidx.compose.runtime.mutableStateListOf + +class Navigator { + val backStack = mutableStateListOf() + + fun goTo(destination: AppRoute) { + backStack.add(destination) + } + + fun goBack() { + backStack.removeLastOrNull() + } +} diff --git a/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRoute.kt b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRoute.kt new file mode 100644 index 0000000..7023c38 --- /dev/null +++ b/libs/navigation/src/commonMain/kotlin/dev/yaseyo/navigation/AppRoute.kt @@ -0,0 +1,5 @@ +package dev.yaseyo.navigation + +expect interface AppRoute + +data object Home : AppRoute diff --git a/libs/navigation/src/iosMain/kotlin/dev/yaseyo/navigation/AppRoute.ios.kt b/libs/navigation/src/iosMain/kotlin/dev/yaseyo/navigation/AppRoute.ios.kt new file mode 100644 index 0000000..3eed979 --- /dev/null +++ b/libs/navigation/src/iosMain/kotlin/dev/yaseyo/navigation/AppRoute.ios.kt @@ -0,0 +1,3 @@ +package dev.yaseyo.navigation + +actual interface AppRoute diff --git a/libs/user/api/src/commonMain/kotlin/dev/yaseyo/user/api/User.kt b/libs/user/api/src/commonMain/kotlin/dev/yaseyo/user/api/User.kt index d830433..6028aee 100644 --- a/libs/user/api/src/commonMain/kotlin/dev/yaseyo/user/api/User.kt +++ b/libs/user/api/src/commonMain/kotlin/dev/yaseyo/user/api/User.kt @@ -1,7 +1,7 @@ package dev.yaseyo.user.api -import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate +import kotlin.time.Instant data class User( val id: String, diff --git a/libs/user/src/commonMain/kotlin/dev/yaseyo/user/di/UserModule.kt b/libs/user/src/commonMain/kotlin/dev/yaseyo/user/di/UserModule.kt new file mode 100644 index 0000000..e040b3a --- /dev/null +++ b/libs/user/src/commonMain/kotlin/dev/yaseyo/user/di/UserModule.kt @@ -0,0 +1,8 @@ +package dev.yaseyo.user.di + +import dev.yaseyo.user.impl.userImplModule +import org.koin.dsl.module + +val userModule = module { + includes(userImplModule) +} diff --git a/libs/user/src/iosMain/kotlin/dev/yaseyo/user/UserDependencies.kt b/libs/user/src/iosMain/kotlin/dev/yaseyo/user/UserDependencies.kt new file mode 100644 index 0000000..b850894 --- /dev/null +++ b/libs/user/src/iosMain/kotlin/dev/yaseyo/user/UserDependencies.kt @@ -0,0 +1,6 @@ +package dev.yaseyo.user + +import dev.yaseyo.user.api.UserRepository +import org.koin.mp.KoinPlatform + +fun getUserRepository(): UserRepository = KoinPlatform.getKoin().get() diff --git a/settings.gradle.kts b/settings.gradle.kts index bf29ce8..0234004 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -40,5 +40,10 @@ include(":libs:user:impl") include(":libs:auth:api") include(":libs:auth:impl") include(":libs:coroutines") +include(":libs:navigation") +include(":libs:design") include(":libs:network") include(":libs:di") +include(":feature:onboarding") +include(":feature:onboarding:api") +include(":feature:onboarding:android")