Skip to content

Commit e8cbd79

Browse files
authored
Merge branch 'main' into settings-to-profile
2 parents 2318eab + 0bd9abc commit e8cbd79

19 files changed

Lines changed: 763 additions & 30 deletions

File tree

CLAUDE.md

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,36 +30,45 @@ Package: `zed.rainxch.githubstore`
3030
## Project Structure
3131

3232
```
33-
composeApp/ # Main app module
33+
composeApp/ # Main app module (entry points, navigation, DI wiring)
3434
src/commonMain/ # Shared UI & app wiring
3535
src/androidMain/ # Android entry point (MainActivity)
3636
src/jvmMain/ # Desktop entry point (DesktopApp.kt)
3737
core/
38-
domain/ # Shared interfaces, models, utils
39-
data/ # Shared repos, networking, database, DI
40-
presentation/ # Shared theming & UI utilities
38+
domain/ # Shared interfaces, models, use cases (no framework deps)
39+
data/ # Shared repos, networking (Ktor), database (Room), DI
40+
presentation/ # Shared theming (Material 3) & reusable UI components
4141
feature/
42-
{apps,auth,details,dev-profile, # Each feature has 3 sub-modules:
43-
favourites,home,search, domain/ - interfaces & models
44-
settings,starred}/ data/ - implementations & DI
45-
presentation/ - screens & ViewModels
42+
apps/ # Installed applications management
43+
auth/ # GitHub OAuth device flow authentication
44+
details/ # Repository details, releases, readme, downloads
45+
dev-profile/ # Developer/user profile display
46+
favourites/ # Saved favorite repositories (presentation-only)
47+
home/ # Main discovery screen (trending, hot, popular)
48+
search/ # Repository search with filters
49+
settings/ # App settings (theme, account, appearance)
50+
starred/ # Starred repositories (presentation-only)
4651
build-logic/convention/ # Custom Gradle convention plugins
4752
```
4853

54+
Each feature has up to 3 sub-modules: `domain/` (interfaces & models), `data/` (implementations & DI), `presentation/` (screens & ViewModels). Some features (favourites, starred) are presentation-only and use core repositories directly.
55+
4956
## Architecture
5057

5158
**Clean Architecture + MVVM** with strict layer separation per feature module:
5259

5360
- **Domain** - Repository interfaces, models, use cases (no framework dependencies)
54-
- **Data** - Repository implementations, Ktor API clients, Room DAOs, DTOs
61+
- **Data** - Repository implementations, Ktor API clients, Room DAOs, DTOs, mappers
5562
- **Presentation** - ViewModels with `StateFlow`/`Channel`, Compose screens
5663

5764
### State Management Pattern
5865

66+
Every screen follows the same State/Action/Event pattern:
67+
5968
```kotlin
6069
class XViewModel : ViewModel() {
6170
private val _state = MutableStateFlow(XState())
62-
val state = _state.asStateFlow()
71+
val state = _state.asStateFlow() // or .stateIn() with WhileSubscribed
6372

6473
private val _events = Channel<XEvent>()
6574
val events = _events.receiveAsFlow()
@@ -68,15 +77,34 @@ class XViewModel : ViewModel() {
6877
}
6978
```
7079

71-
Each screen uses a `State` data class, sealed `Action` class for user input, and sealed `Event` class for one-off effects.
80+
- `State` - data class holding all UI state
81+
- `Action` - sealed interface for user input (clicks, refreshes, etc.)
82+
- `Event` - sealed interface for one-off effects (navigation, toasts, scroll)
7283

7384
### Navigation
7485

75-
Type-safe navigation using `@Serializable` sealed interface `GithubStoreGraph` in `composeApp/src/commonMain/.../app/navigation/`.
86+
Type-safe navigation using `@Serializable` sealed interface `GithubStoreGraph`:
87+
88+
```
89+
HomeScreen, SearchScreen, AuthenticationScreen, SettingsScreen,
90+
FavouritesScreen, StarredReposScreen, AppsScreen
91+
DetailsScreen(repositoryId, owner, repo)
92+
DeveloperProfileScreen(username)
93+
```
94+
95+
Routes defined in `composeApp/.../app/navigation/GithubStoreGraph.kt`, wired in `AppNavigation.kt`.
7696

7797
### Dependency Injection
7898

79-
**Koin** - modules defined in each feature's `data/di/` directory, registered in `composeApp/.../app/di/initKoin.kt`. ViewModels injected via `koinViewModel()`.
99+
**Koin** - modules defined in each feature's `data/di/SharedModule.kt`, registered in `composeApp/.../app/di/initKoin.kt`. ViewModels injected via `koinViewModel()`.
100+
101+
### Core Modules
102+
103+
| Module | Purpose | Key Contents |
104+
|--------|---------|--------------|
105+
| `core/domain` | Shared contracts | Repository interfaces (`FavouritesRepository`, `StarredRepository`, `InstalledAppsRepository`, `ThemesRepository`), models (`GithubRepoSummary`, `GithubRelease`, `InstalledApp`, `ProxyConfig`), system interfaces (`Downloader`, `Installer`, `PackageMonitor`) |
106+
| `core/data` | Shared implementations | `HttpClientFactory` (Ktor + interceptors), `AppDatabase` (Room), `ProxyManager`, `TokenStore`, `LocalizationManager`, platform-specific clients (OkHttp for Android, CIO for Desktop) |
107+
| `core/presentation` | Shared UI | `GithubStoreTheme` (Material 3), reusable components (`RepositoryCard`, `GithubStoreButton`), formatting utils |
80108

81109
## Tech Stack
82110

@@ -103,20 +131,20 @@ Custom Gradle plugins in `build-logic/convention/` standardize module setup:
103131

104132
| Plugin | Use For |
105133
|--------|---------|
106-
| `convention.kmp.library` | KMP shared library modules |
107-
| `convention.cmp.library` | Compose Multiplatform library modules |
108-
| `convention.cmp.feature` | Feature presentation modules |
134+
| `convention.kmp.library` | KMP shared library modules (domain, data) |
135+
| `convention.cmp.library` | Compose Multiplatform library modules (core/presentation) |
136+
| `convention.cmp.feature` | Feature presentation modules (auto-adds Compose + Koin + core:presentation) |
109137
| `convention.cmp.application` | Main app module |
110138
| `convention.room` | Room database modules |
111-
| `convention.buildkonfig` | Build-time config (API keys) |
139+
| `convention.buildkonfig` | Build-time config (API keys from local.properties) |
112140

113141
## Adding a New Feature
114142

115143
1. Create `feature/<name>/domain/`, `feature/<name>/data/`, `feature/<name>/presentation/`
116144
2. Add `build.gradle.kts` in each using the appropriate convention plugin
117145
3. Add `include` entries in `settings.gradle.kts`
118146
4. Define domain interfaces/models in `domain/`
119-
5. Implement repository + Koin DI module in `data/di/`
147+
5. Implement repository + Koin DI module in `data/di/SharedModule.kt`
120148
6. Create ViewModel (State/Action/Event pattern) and Screen in `presentation/`
121149
7. Add navigation route to `GithubStoreGraph.kt` and wire in `AppNavigation.kt`
122150
8. Register the Koin module in `initKoin.kt`
@@ -131,7 +159,8 @@ Custom Gradle plugins in `build-logic/convention/` standardize module setup:
131159

132160
- Packages follow `zed.rainxch.{module}.{layer}` pattern
133161
- Private state properties use underscore prefix: `_state`
134-
- Sealed classes for type-safe navigation routes, actions, events
162+
- Sealed classes/interfaces for type-safe navigation routes, actions, events
135163
- Repository pattern: interface in `domain/`, implementation in `data/`
136164
- Composition over inheritance via Koin DI
137165
- Source sets: `commonMain` for shared, `androidMain` for Android, `jvmMain` for Desktop
166+
- Feature CLAUDE.md files exist in each `feature/` directory for module-specific guidance

core/data/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,13 @@ kotlin {
2424

2525
androidMain {
2626
dependencies {
27-
27+
implementation(libs.ktor.client.okhttp)
2828
}
2929
}
3030

3131
jvmMain {
3232
dependencies {
33-
33+
implementation(libs.ktor.client.cio)
3434
}
3535
}
3636
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package zed.rainxch.core.data.network
2+
3+
import io.ktor.client.*
4+
import io.ktor.client.engine.okhttp.*
5+
import java.net.InetSocketAddress
6+
import java.net.Proxy
7+
import okhttp3.Credentials
8+
import zed.rainxch.core.domain.model.ProxyConfig
9+
import java.net.PasswordAuthentication
10+
import java.net.ProxySelector
11+
12+
actual fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient {
13+
return HttpClient(OkHttp) {
14+
engine {
15+
when (proxyConfig) {
16+
is ProxyConfig.None -> {
17+
proxy = Proxy.NO_PROXY
18+
}
19+
20+
is ProxyConfig.System -> {
21+
config {
22+
proxySelector(ProxySelector.getDefault())
23+
}
24+
}
25+
26+
is ProxyConfig.Http -> {
27+
proxy = Proxy(
28+
Proxy.Type.HTTP,
29+
InetSocketAddress(proxyConfig.host, proxyConfig.port)
30+
)
31+
if (proxyConfig.username != null) {
32+
config {
33+
proxyAuthenticator { _, response ->
34+
response.request.newBuilder()
35+
.header(
36+
"Proxy-Authorization",
37+
Credentials.basic(
38+
proxyConfig.username!!,
39+
proxyConfig.password.orEmpty()
40+
)
41+
)
42+
.build()
43+
}
44+
}
45+
}
46+
}
47+
48+
is ProxyConfig.Socks -> {
49+
proxy = Proxy(
50+
Proxy.Type.SOCKS,
51+
InetSocketAddress(proxyConfig.host, proxyConfig.port)
52+
)
53+
54+
if (proxyConfig.username != null) {
55+
java.net.Authenticator.setDefault(object : java.net.Authenticator() {
56+
override fun getPasswordAuthentication(): PasswordAuthentication? {
57+
if (requestingHost == proxyConfig.host &&
58+
requestingPort == proxyConfig.port
59+
) {
60+
return PasswordAuthentication(
61+
proxyConfig.username,
62+
proxyConfig.password.orEmpty().toCharArray()
63+
)
64+
}
65+
return null
66+
}
67+
})
68+
}
69+
}
70+
}
71+
}
72+
}
73+
}

core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import zed.rainxch.core.data.local.db.dao.InstalledAppDao
1313
import zed.rainxch.core.data.local.db.dao.StarredRepoDao
1414
import zed.rainxch.core.data.local.db.dao.UpdateHistoryDao
1515
import zed.rainxch.core.data.logging.KermitLogger
16+
import zed.rainxch.core.data.network.GitHubClientProvider
17+
import zed.rainxch.core.data.network.ProxyManager
1618
import zed.rainxch.core.data.network.createGitHubHttpClient
1719
import zed.rainxch.core.data.repository.AuthenticationStateImpl
1820
import zed.rainxch.core.data.repository.FavouritesRepositoryImpl
@@ -93,6 +95,14 @@ val coreModule = module {
9395
}
9496

9597
val networkModule = module {
98+
single<GitHubClientProvider> {
99+
GitHubClientProvider(
100+
tokenStore = get(),
101+
rateLimitRepository = get(),
102+
proxyConfigFlow = ProxyManager.currentProxyConfig
103+
)
104+
}
105+
96106
single<HttpClient> {
97107
createGitHubHttpClient(
98108
tokenStore = get(),
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package zed.rainxch.core.data.network
2+
3+
import io.ktor.client.HttpClient
4+
import kotlinx.coroutines.CoroutineScope
5+
import kotlinx.coroutines.Dispatchers
6+
import kotlinx.coroutines.SupervisorJob
7+
import kotlinx.coroutines.cancel
8+
import kotlinx.coroutines.flow.Flow
9+
import kotlinx.coroutines.flow.MutableStateFlow
10+
import kotlinx.coroutines.flow.SharingStarted
11+
import kotlinx.coroutines.flow.StateFlow
12+
import kotlinx.coroutines.flow.distinctUntilChanged
13+
import kotlinx.coroutines.flow.map
14+
import kotlinx.coroutines.flow.stateIn
15+
import kotlinx.coroutines.sync.Mutex
16+
import kotlinx.coroutines.sync.withLock
17+
import zed.rainxch.core.data.data_source.TokenStore
18+
import zed.rainxch.core.domain.model.ProxyConfig
19+
import zed.rainxch.core.domain.repository.RateLimitRepository
20+
21+
class GitHubClientProvider(
22+
private val tokenStore: TokenStore,
23+
private val rateLimitRepository: RateLimitRepository,
24+
proxyConfigFlow: StateFlow<ProxyConfig>
25+
) {
26+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
27+
private var currentClient: HttpClient? = null
28+
private val mutex = Mutex()
29+
30+
private val _client: StateFlow<HttpClient> = proxyConfigFlow
31+
.map { proxyConfig ->
32+
mutex.withLock {
33+
currentClient?.close()
34+
val newClient = createGitHubHttpClient(
35+
tokenStore = tokenStore,
36+
rateLimitRepository = rateLimitRepository,
37+
proxyConfig = proxyConfig
38+
)
39+
currentClient = newClient
40+
newClient
41+
}
42+
}
43+
.stateIn(
44+
scope = scope,
45+
started = SharingStarted.Eagerly,
46+
initialValue = createGitHubHttpClient(
47+
tokenStore = tokenStore,
48+
rateLimitRepository = rateLimitRepository,
49+
proxyConfig = proxyConfigFlow.value
50+
).also { currentClient = it }
51+
)
52+
53+
/** Get the current HttpClient (always up to date with proxy settings) */
54+
val client: HttpClient get() = _client.value
55+
56+
fun close() {
57+
currentClient?.close()
58+
scope.cancel()
59+
}
60+
}

core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,29 @@ import io.ktor.client.statement.HttpResponse
99
import io.ktor.http.*
1010
import io.ktor.serialization.kotlinx.json.*
1111
import io.ktor.util.network.UnresolvedAddressException
12+
import kotlinx.coroutines.flow.Flow
1213
import kotlinx.serialization.json.Json
1314
import zed.rainxch.core.data.data_source.TokenStore
1415
import zed.rainxch.core.data.network.interceptor.RateLimitInterceptor
16+
import zed.rainxch.core.domain.model.ProxyConfig
1517
import zed.rainxch.core.domain.model.RateLimitException
1618
import zed.rainxch.core.domain.repository.RateLimitRepository
1719
import java.io.IOException
1820
import kotlin.coroutines.cancellation.CancellationException
1921

22+
expect fun createPlatformHttpClient(proxyConfig: ProxyConfig): HttpClient
23+
2024
fun createGitHubHttpClient(
2125
tokenStore: TokenStore,
22-
rateLimitRepository: RateLimitRepository
26+
rateLimitRepository: RateLimitRepository,
27+
proxyConfig: ProxyConfig = ProxyConfig.None
2328
): HttpClient {
2429
val json = Json {
2530
ignoreUnknownKeys = true
2631
isLenient = true
2732
}
2833

29-
return HttpClient {
34+
return createPlatformHttpClient(proxyConfig).config {
3035
install(RateLimitInterceptor) {
3136
this.rateLimitRepository = rateLimitRepository
3237
}
@@ -45,23 +50,17 @@ fun createGitHubHttpClient(
4550
maxRetries = 3
4651
retryIf { _, response ->
4752
val code = response.status.value
48-
4953
if (code == 403) {
5054
val remaining = response.headers["X-RateLimit-Remaining"]?.toIntOrNull()
51-
if (remaining == 0) {
52-
return@retryIf false
53-
}
55+
if (remaining == 0) return@retryIf false
5456
}
55-
5657
code in 500..<600
5758
}
58-
5959
retryOnExceptionIf { _, cause ->
6060
cause is HttpRequestTimeoutException ||
6161
cause is UnresolvedAddressException ||
6262
cause is IOException
6363
}
64-
6564
exponentialDelay()
6665
}
6766

0 commit comments

Comments
 (0)