Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,19 @@ Always extend the Arkitekt base classes — never use plain `suspend` functions

```kotlin
// UseCase<ARGS, RESULT> — single async operation
class SignInUseCase @Inject constructor(...) : UseCase<Unit, Unit>() {
// Always define a nested Args data class, even for a single parameter; never use the raw type directly.
class SetUserLoggedInUseCase @Inject constructor(...) : UseCase<SetUserLoggedInUseCase.Args, Unit> {
override suspend fun build(args: Args) { /* business logic */ }
data class Args(val isLoggedIn: Boolean)
}

// Use Unit only when there are truly no inputs
class SignInUseCase @Inject constructor(...) : UseCase<Unit, Unit> {
override suspend fun build(args: Unit) { /* business logic */ }
}

// FlowUseCase<ARGS, T> — streaming operation
class ObserveSomethingUseCase @Inject constructor(...) : FlowUseCase<Unit, MyModel>() {
// FlowUseCase<ARGS, T> — streaming operation (same Args convention applies)
class ObserveSomethingUseCase @Inject constructor(...) : FlowUseCase<Unit, MyModel> {
override fun build(args: Unit): Flow<MyModel> = /* … */
}
```
Expand Down Expand Up @@ -89,12 +96,55 @@ EventsEffect {
}
```

## Navigation

Uses **Navigation 3** (`androidx.navigation3`), not standard Navigation Compose.

- **Routes** — `MainRoute.kt`: `@Serializable sealed interface MainRoute : NavKey`. Use `data class` for routes with args, `data object` for routes without:
```kotlin
data object Home : MainRoute
data class Detail(val args: DetailScreenArgs) : MainRoute
```
- **Screen registration** — `NavGraph.kt`: add an `entry<MainRoute.Foo> { ... }` block inside `entryProvider { }` on `NavDisplay` for every new screen.
- **Triggering navigation** — `NavRouter` interface + `NavRouterImpl`. `NavRouterImpl` holds a `NavBackStack<NavKey>` and mutates it:
- `backStack.add(route)` — push
- `backStack.removeLastOrNull()` — pop (`popBackStack()`)
- `backStack.clear(); backStack.add(route)` — replace all (e.g. `navigateToLogin()`)

Add new `navigateTo*` methods to both `NavRouter` and `NavRouterImpl` when adding a new screen.
- **Screen args** — Each screen that needs input has a `@Serializable data class *ScreenArgs` in its own package, embedded in the route. The **calling** ViewModel constructs the args when firing a navigation event; `NavGraph` only threads `entry.args` to the screen; the destination ViewModel receives them via `@AssistedInject`:
```kotlin
// Calling ViewModel (e.g. HomeViewModel)
sendEvent(NavigateToDetailEvent(DetailScreenArgs(title = "Demo")))

// NavGraph.kt — just pass through
entry<MainRoute.Detail> {
DetailScreen(it.args, navigation = backStackNavigator)
}

// Destination ViewModel
@HiltViewModel(assistedFactory = DetailViewModel.Factory::class)
class DetailViewModel @AssistedInject constructor(
@Assisted val args: DetailScreenArgs, override val viewState: DetailViewState,
) : BaseViewModel<DetailViewState>(), Detail.Actions {
@AssistedFactory interface Factory { fun create(args: DetailScreenArgs): DetailViewModel }
}
```
- **Bottom sheets** — `BottomSheetSceneStrategy` is registered as a `sceneStrategy` on `NavDisplay`.
- **Inter-screen results** — `ResultStore` + `LocalResultStore` composition local; use `navigateBackWithResult` / `setCurrentResult` on `NavRouterImpl`. Read results with `consume(key)`, which removes the value so it doesn't re-trigger on recomposition.

## Networking

**Stack:** Ktor 3 (CIO engine) + Ktorfit for type-safe API interfaces (KSP-generated).

### API interface
- `ApiService.kt` — suspend functions annotated with Ktorfit `@GET`/`@POST`/etc. The Ktorfit instance is built with `NetworkResultConverterFactory`, so functions may return either the model directly or `NetworkResult<T>`.
## Dependency Injection

**Hilt** throughout:
- `@HiltAndroidApp` on `App`, `@AndroidEntryPoint` on `AppActivity`
- `@HiltViewModel` on ViewModels, `@ViewModelScoped` on ViewState
- Modules: `ApplicationModule` (singletons), `NetworkModule` (Retrofit/OkHttp)
- Modules: `ApplicationModule` (singletons), `NetworkModule` (Ktor/Ktorfit)

## Build Flavors

Expand Down
1 change: 0 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:enableOnBackInvokedCallback="true"
android:theme="@style/Theme.App"
android:supportsRtl="true"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package app.futured.androidprojecttemplate

import android.app.Application
import android.graphics.Color
import android.os.Bundle
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
Expand All @@ -24,7 +26,12 @@ class App : Application() {
class AppActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
enableEdgeToEdge(
navigationBarStyle = SystemBarStyle.light(
scrim = Color.TRANSPARENT,
darkScrim = getColor(R.color.foreground),
),
)
setContent {
AppUI()
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package app.futured.androidprojecttemplate.data.remote

import app.futured.androidprojecttemplate.data.remote.result.NetworkResult
import de.jensklingenberg.ktorfit.http.GET
import de.jensklingenberg.ktorfit.http.Path
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

interface StarWarsApi {

@GET("people/{id}")
suspend fun getPerson(
@Path("id") personId: Int,
): NetworkResult<Person>

@Serializable
data class Person(
@SerialName("name") val name: String? = null,
@SerialName("homeworld") val homeworld: String? = null,
@SerialName("gender") val gender: String? = null,
@SerialName("url") val url: String? = null,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package app.futured.androidprojecttemplate.domain.usecase

import app.futured.arkitekt.crusecases.FlowUseCase
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import javax.inject.Inject
import kotlin.time.Duration

class CounterUseCase @Inject constructor() : FlowUseCase<CounterUseCase.Args, Long> {

override fun build(args: Args): Flow<Long> = flow {
var counter = 0L
while (currentCoroutineContext().isActive) {
emit(counter++)
delay(args.interval)
}
}

data class Args(val interval: Duration)
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ import javax.inject.Inject
class GetInitialDestinationUseCase @Inject constructor(private val userPersistence: UserPersistence) : UseCase<Unit, MainRoute> {

override suspend fun build(args: Unit): MainRoute =
if (userPersistence.isUserLoggedIn()) MainRoute.Home else MainRoute.Login
if (userPersistence.isUserLoggedIn()) MainRoute.First else MainRoute.Login
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package app.futured.androidprojecttemplate.domain.usecase

import app.futured.androidprojecttemplate.data.remote.StarWarsApi
import app.futured.androidprojecttemplate.data.remote.result.getOrThrow
import app.futured.arkitekt.crusecases.UseCase
import javax.inject.Inject
import kotlin.random.Random

class GetPersonUseCase @Inject constructor(private val starWarsApi: StarWarsApi) : UseCase<Unit, StarWarsApi.Person> {

override suspend fun build(args: Unit): StarWarsApi.Person =
starWarsApi.getPerson(Random.nextInt(until = 100)).getOrThrow()
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package app.futured.androidprojecttemplate.injection.modules

import app.futured.androidprojecttemplate.data.remote.ApiService
import app.futured.androidprojecttemplate.data.remote.createApiService
import app.futured.androidprojecttemplate.data.remote.StarWarsApi
import app.futured.androidprojecttemplate.data.remote.createStarWarsApi
import app.futured.androidprojecttemplate.data.remote.plugins.ContentNegotiationPlugin
import app.futured.androidprojecttemplate.data.remote.plugins.HttpTimeoutPlugin
import app.futured.androidprojecttemplate.data.remote.plugins.LoggingPlugin
Expand Down Expand Up @@ -55,5 +55,5 @@ class NetworkModule {

@Provides
@Singleton
fun provideApiService(ktorfit: Ktorfit): ApiService = ktorfit.createApiService()
fun provideStarWarsApi(ktorfit: Ktorfit): StarWarsApi = ktorfit.createStarWarsApi()
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package app.futured.androidprojecttemplate.navigation

import androidx.navigation3.runtime.NavKey
import app.futured.androidprojecttemplate.ui.screens.picker.PickerScreenArgs
import app.futured.androidprojecttemplate.ui.screens.third.ThirdScreenArgs
import kotlinx.serialization.Serializable

@Serializable
sealed interface MainRoute : NavKey {
@Serializable
data object Login : MainRoute

@Serializable
data object First : MainRoute

@Serializable
data object Second : MainRoute

@Serializable
data class Third(val args: ThirdScreenArgs) : MainRoute

@Serializable
data object Profile : MainRoute

@Serializable
data class Picker(val args: PickerScreenArgs) : MainRoute
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
package app.futured.androidprojecttemplate.navigation

import app.futured.androidprojecttemplate.ui.screens.detail.DetailScreenArgs
import app.futured.androidprojecttemplate.ui.screens.picker.PickerScreenArgs
import app.futured.androidprojecttemplate.ui.screens.third.ThirdScreenArgs

interface NavRouter {
fun popBackStack()
fun navigateBack(popUpToDestination: MainRoute, inclusive: Boolean = false)

fun navigateToHome()
fun navigateToFirst()

fun navigateToDetail(args: DetailScreenArgs)
fun navigateToSecond()

fun navigateToThird(args: ThirdScreenArgs)

fun navigateToPicker(args: PickerScreenArgs)

fun navigateToLogin()

fun selectHomeTab()

fun selectProfileTab()

fun <T : Any> navigateBackWithResult(key: String, value: T)
fun <T : Any> setCurrentResult(key: String, value: T)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ package app.futured.androidprojecttemplate.navigation

import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import app.futured.androidprojecttemplate.ui.screens.detail.DetailScreenArgs
import app.futured.androidprojecttemplate.ui.screens.picker.PickerScreenArgs
import app.futured.androidprojecttemplate.ui.screens.third.ThirdScreenArgs

/**
* Class that triggers navigation actions on the provided [backStack].
Expand All @@ -22,19 +23,40 @@ class NavRouterImpl(private val backStack: NavBackStack<NavKey>, private val res
}
}

override fun navigateToHome() {
backStack.add(MainRoute.Home)
override fun navigateToFirst() {
backStack.clear()
backStack.add(MainRoute.First)
}

override fun navigateToSecond() {
backStack.add(MainRoute.Second)
}

override fun navigateToDetail(args: DetailScreenArgs) {
backStack.add(MainRoute.Detail(args))
override fun navigateToThird(args: ThirdScreenArgs) {
backStack.add(MainRoute.Third(args))
}

override fun navigateToPicker(args: PickerScreenArgs) {
backStack.add(MainRoute.Picker(args))
}

override fun navigateToLogin() {
backStack.clear()
backStack.add(MainRoute.Login)
}

override fun selectHomeTab() {
while (backStack.isNotEmpty() && backStack.last() != MainRoute.First) {
backStack.removeLastOrNull()
}
}

override fun selectProfileTab() {
if (backStack.lastOrNull() != MainRoute.Profile) {
backStack.add(MainRoute.Profile)
}
}

override fun <T : Any> navigateBackWithResult(key: String, value: T) {
resultStore.put(key, value)
backStack.removeLastOrNull()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ package app.futured.androidprojecttemplate.tools
interface Constants {

object Api {
const val BASE_PROD_URL = "https://reqres.in/"
const val TIMEOUT_IN_SECONDS = 30L
const val BASE_PROD_URL = "https://swapi.info/api/"
}

object DataStore {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ class AppViewModel @Inject constructor(
private fun getInitialDestination() {
getInitialDestinationUseCase.execute {
onSuccess { destination ->
if (destination == MainRoute.Home) {
sendEvent(NavigateToHomeEvent)
if (destination == MainRoute.First) {
sendEvent(NavigateToFirstEvent)
}
}
}
Expand All @@ -36,4 +36,4 @@ class AppViewState @Inject constructor() : ViewState

sealed class AppEvent : Event<AppViewState>()

data object NavigateToHomeEvent : AppEvent()
data object NavigateToFirstEvent : AppEvent()
Loading
Loading