@coralai/sps-cli 0.41.2 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -3
- package/dist/commands/cardAdd.d.ts +1 -1
- package/dist/commands/cardAdd.d.ts.map +1 -1
- package/dist/commands/cardAdd.js +16 -6
- package/dist/commands/cardAdd.js.map +1 -1
- package/dist/commands/cardDashboard.js +1 -1
- package/dist/commands/cardDashboard.js.map +1 -1
- package/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +3 -314
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/hookCommand.d.ts.map +1 -1
- package/dist/commands/hookCommand.js +6 -7
- package/dist/commands/hookCommand.js.map +1 -1
- package/dist/commands/pmCommand.js +1 -1
- package/dist/commands/pmCommand.js.map +1 -1
- package/dist/commands/projectInit.d.ts.map +1 -1
- package/dist/commands/projectInit.js +60 -37
- package/dist/commands/projectInit.js.map +1 -1
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +3 -30
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/skillCommand.d.ts +2 -0
- package/dist/commands/skillCommand.d.ts.map +1 -0
- package/dist/commands/skillCommand.js +235 -0
- package/dist/commands/skillCommand.js.map +1 -0
- package/dist/commands/tick.js +1 -1
- package/dist/commands/tick.js.map +1 -1
- package/dist/core/checklist.d.ts +22 -0
- package/dist/core/checklist.d.ts.map +1 -0
- package/dist/core/checklist.js +38 -0
- package/dist/core/checklist.js.map +1 -0
- package/dist/core/checklist.test.d.ts +2 -0
- package/dist/core/checklist.test.d.ts.map +1 -0
- package/dist/core/checklist.test.js +74 -0
- package/dist/core/checklist.test.js.map +1 -0
- package/dist/core/config.d.ts +1 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +1 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/config.test.js +7 -4
- package/dist/core/config.test.js.map +1 -1
- package/dist/core/context.d.ts +1 -1
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/skillStore.d.ts +46 -0
- package/dist/core/skillStore.d.ts.map +1 -0
- package/dist/core/skillStore.js +197 -0
- package/dist/core/skillStore.js.map +1 -0
- package/dist/core/skillStore.test.d.ts +2 -0
- package/dist/core/skillStore.test.d.ts.map +1 -0
- package/dist/core/skillStore.test.js +190 -0
- package/dist/core/skillStore.test.js.map +1 -0
- package/dist/engines/EventHandler.test.js +3 -3
- package/dist/engines/EventHandler.test.js.map +1 -1
- package/dist/engines/MonitorEngine.js +2 -2
- package/dist/engines/MonitorEngine.js.map +1 -1
- package/dist/engines/SchedulerEngine.js +1 -1
- package/dist/engines/SchedulerEngine.js.map +1 -1
- package/dist/engines/StageEngine.js +3 -3
- package/dist/engines/StageEngine.js.map +1 -1
- package/dist/engines/engine-pipeline-adapter.test.js +2 -2
- package/dist/engines/engine-pipeline-adapter.test.js.map +1 -1
- package/dist/interfaces/TaskBackend.d.ts +3 -1
- package/dist/interfaces/TaskBackend.d.ts.map +1 -1
- package/dist/main.js +19 -17
- package/dist/main.js.map +1 -1
- package/dist/models/types.d.ts +16 -1
- package/dist/models/types.d.ts.map +1 -1
- package/dist/providers/MarkdownTaskBackend.d.ts +2 -1
- package/dist/providers/MarkdownTaskBackend.d.ts.map +1 -1
- package/dist/providers/MarkdownTaskBackend.js +28 -5
- package/dist/providers/MarkdownTaskBackend.js.map +1 -1
- package/dist/providers/registry.d.ts.map +1 -1
- package/dist/providers/registry.js +5 -7
- package/dist/providers/registry.js.map +1 -1
- package/package.json +1 -1
- package/project-template/.claude/hooks/start.sh +44 -0
- package/project-template/.claude/settings.json +1 -1
- package/skills/architecture-decision-records/SKILL.md +207 -0
- package/skills/backend/SKILL.md +62 -0
- package/skills/backend/references/api-design.md +168 -0
- package/skills/backend/references/caching.md +181 -0
- package/skills/backend/references/data-access.md +173 -0
- package/skills/backend/references/layering.md +181 -0
- package/skills/backend/references/observability.md +190 -0
- package/skills/backend/references/resilience.md +201 -0
- package/skills/backend/references/security.md +186 -0
- package/skills/backend-architect/SKILL.md +119 -0
- package/skills/code-reviewer/SKILL.md +143 -0
- package/skills/coding-standards/SKILL.md +60 -0
- package/skills/coding-standards/references/clean-code.md +258 -0
- package/skills/coding-standards/references/code-review.md +192 -0
- package/skills/coding-standards/references/commits-and-prs.md +226 -0
- package/skills/coding-standards/references/error-strategy.md +193 -0
- package/skills/coding-standards/references/naming.md +185 -0
- package/skills/coding-standards/references/tdd.md +171 -0
- package/skills/database/SKILL.md +53 -0
- package/skills/database/references/indexing.md +190 -0
- package/skills/database/references/migrations.md +199 -0
- package/skills/database/references/nosql.md +185 -0
- package/skills/database/references/queries.md +295 -0
- package/skills/database/references/scaling.md +203 -0
- package/skills/database/references/schema.md +191 -0
- package/skills/database-optimizer/SKILL.md +168 -0
- package/skills/debugging-workflow/SKILL.md +244 -0
- package/skills/devops/SKILL.md +55 -0
- package/skills/devops/references/ci-cd.md +204 -0
- package/skills/devops/references/containers.md +272 -0
- package/skills/devops/references/deploy.md +201 -0
- package/skills/devops/references/iac.md +252 -0
- package/skills/devops/references/observability.md +228 -0
- package/skills/devops/references/secrets.md +178 -0
- package/skills/devops-automator/SKILL.md +164 -0
- package/skills/frontend/SKILL.md +52 -0
- package/skills/frontend/references/accessibility.md +222 -0
- package/skills/frontend/references/components.md +206 -0
- package/skills/frontend/references/performance.md +219 -0
- package/skills/frontend/references/routing.md +209 -0
- package/skills/frontend/references/state.md +190 -0
- package/skills/frontend/references/testing.md +216 -0
- package/skills/frontend-developer/SKILL.md +115 -0
- package/skills/git-workflow/SKILL.md +355 -0
- package/skills/golang/SKILL.md +49 -0
- package/skills/golang/references/concurrency.md +284 -0
- package/skills/golang/references/errors.md +241 -0
- package/skills/golang/references/idioms.md +285 -0
- package/skills/golang/references/testing.md +238 -0
- package/skills/java/SKILL.md +50 -0
- package/skills/java/references/concurrency.md +194 -0
- package/skills/java/references/idioms.md +283 -0
- package/skills/java/references/testing.md +228 -0
- package/skills/kotlin/SKILL.md +47 -0
- package/skills/kotlin/references/coroutines.md +240 -0
- package/skills/kotlin/references/idioms.md +268 -0
- package/skills/kotlin/references/testing.md +219 -0
- package/skills/mobile/SKILL.md +50 -0
- package/skills/mobile/references/architecture.md +204 -0
- package/skills/mobile/references/navigation.md +158 -0
- package/skills/mobile/references/performance.md +152 -0
- package/skills/mobile/references/platform.md +166 -0
- package/skills/mobile/references/state-and-data.md +174 -0
- package/skills/python/SKILL.md +51 -0
- package/skills/python/THIRD_PARTY.md +14 -0
- package/skills/python/references/async.md +218 -0
- package/skills/python/references/error-handling.md +254 -0
- package/skills/python/references/idioms.md +279 -0
- package/skills/python/references/packaging.md +233 -0
- package/skills/python/references/testing.md +269 -0
- package/skills/python/references/typing.md +292 -0
- package/skills/qa-tester/SKILL.md +186 -0
- package/skills/rust/SKILL.md +50 -0
- package/skills/rust/references/async.md +224 -0
- package/skills/rust/references/errors.md +240 -0
- package/skills/rust/references/ownership.md +263 -0
- package/skills/rust/references/testing.md +274 -0
- package/skills/rust/references/traits.md +250 -0
- package/skills/security-engineer/SKILL.md +157 -0
- package/skills/swift/SKILL.md +48 -0
- package/skills/swift/references/concurrency.md +280 -0
- package/skills/swift/references/idioms.md +334 -0
- package/skills/swift/references/testing.md +229 -0
- package/skills/typescript/SKILL.md +51 -0
- package/skills/typescript/references/async.md +241 -0
- package/skills/typescript/references/errors.md +208 -0
- package/skills/typescript/references/idioms.md +246 -0
- package/skills/typescript/references/testing.md +225 -0
- package/skills/typescript/references/tooling.md +208 -0
- package/skills/typescript/references/types.md +259 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# Kotlin — Testing
|
|
2
|
+
|
|
3
|
+
JUnit 5, Kotest, MockK, turbine for Flow. For TDD, see `coding-standards/references/tdd.md`.
|
|
4
|
+
|
|
5
|
+
## Runner choice — JUnit 5 is the default
|
|
6
|
+
|
|
7
|
+
Fine for most projects. Kotest is a Kotlin-first alternative with extra styles (BehaviorSpec, FunSpec) — pick one per project and be consistent.
|
|
8
|
+
|
|
9
|
+
```kotlin
|
|
10
|
+
// JUnit 5
|
|
11
|
+
import org.junit.jupiter.api.Test
|
|
12
|
+
import kotlin.test.assertEquals
|
|
13
|
+
|
|
14
|
+
class UserServiceTest {
|
|
15
|
+
@Test
|
|
16
|
+
fun `creates user with generated id`() {
|
|
17
|
+
val svc = UserService(InMemoryUserRepo())
|
|
18
|
+
val u = svc.create("a@x.com")
|
|
19
|
+
assertEquals("a@x.com", u.email)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Backtick-quoted function names are standard Kotlin test style — they read as behaviour statements.
|
|
25
|
+
|
|
26
|
+
## Assertions
|
|
27
|
+
|
|
28
|
+
- `kotlin.test.*` for cross-platform (`assertEquals`, `assertTrue`, `assertFailsWith`).
|
|
29
|
+
- `AssertJ` (Java) for rich fluent asserts.
|
|
30
|
+
- `Kotest assertions` (`shouldBe`, `shouldHaveSize`) if you've adopted Kotest.
|
|
31
|
+
|
|
32
|
+
```kotlin
|
|
33
|
+
// kotlin.test
|
|
34
|
+
assertEquals(5, add(2, 3))
|
|
35
|
+
assertFailsWith<ValidationError> { validate(bad) }
|
|
36
|
+
|
|
37
|
+
// AssertJ
|
|
38
|
+
assertThat(users).hasSize(3).extracting("email").contains("a@x.com")
|
|
39
|
+
|
|
40
|
+
// Kotest
|
|
41
|
+
user.name shouldBe "A"
|
|
42
|
+
users shouldHaveSize 3
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Pick one assertion library per project. Mixing creates friction.
|
|
46
|
+
|
|
47
|
+
## Parameterized tests
|
|
48
|
+
|
|
49
|
+
```kotlin
|
|
50
|
+
@ParameterizedTest
|
|
51
|
+
@CsvSource("1,2,3", "0,0,0", "-1,1,0")
|
|
52
|
+
fun `add`(a: Int, b: Int, expected: Int) {
|
|
53
|
+
assertEquals(expected, add(a, b))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@ParameterizedTest
|
|
57
|
+
@MethodSource("cases")
|
|
58
|
+
fun `login cases`(c: LoginCase) { ... }
|
|
59
|
+
|
|
60
|
+
companion object {
|
|
61
|
+
@JvmStatic
|
|
62
|
+
fun cases() = listOf(
|
|
63
|
+
LoginCase("a@x.com", "pw", true),
|
|
64
|
+
LoginCase("", "pw", false),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Kotest syntax is lighter:
|
|
70
|
+
```kotlin
|
|
71
|
+
"add" - {
|
|
72
|
+
withData(
|
|
73
|
+
nameFn = { "${it.a} + ${it.b}" },
|
|
74
|
+
Triple(1, 2, 3), Triple(0, 0, 0)
|
|
75
|
+
) { (a, b, want) -> add(a, b) shouldBe want }
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Mocking — MockK
|
|
80
|
+
|
|
81
|
+
MockK is Kotlin-idiomatic (final classes work out of the box; Mockito needs extra config).
|
|
82
|
+
|
|
83
|
+
```kotlin
|
|
84
|
+
import io.mockk.*
|
|
85
|
+
|
|
86
|
+
val repo = mockk<UserRepository>()
|
|
87
|
+
every { repo.find("u1") } returns User("u1", "a@x.com")
|
|
88
|
+
every { repo.find(not("u1")) } returns null
|
|
89
|
+
|
|
90
|
+
val svc = UserService(repo)
|
|
91
|
+
assertEquals("a@x.com", svc.getEmail("u1"))
|
|
92
|
+
|
|
93
|
+
verify(exactly = 1) { repo.find("u1") }
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Coroutine mocks:
|
|
97
|
+
```kotlin
|
|
98
|
+
val svc = mockk<UserService>()
|
|
99
|
+
coEvery { svc.fetch("u1") } returns User("u1", "a@x.com")
|
|
100
|
+
coVerify { svc.fetch("u1") }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`relaxed = true` makes all unconfigured calls return defaults. Use sparingly — silent defaults mask bugs.
|
|
104
|
+
|
|
105
|
+
## Prefer fakes over mocks
|
|
106
|
+
|
|
107
|
+
```kotlin
|
|
108
|
+
class InMemoryUserRepo : UserRepository {
|
|
109
|
+
private val users = mutableMapOf<String, User>()
|
|
110
|
+
override fun find(id: String) = users[id]
|
|
111
|
+
override fun save(u: User) { users[u.id] = u }
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Reuse across tests; reset in `@BeforeEach`. More code up front; much less friction when the interface grows.
|
|
116
|
+
|
|
117
|
+
## Coroutine tests — `runTest`
|
|
118
|
+
|
|
119
|
+
```kotlin
|
|
120
|
+
import kotlinx.coroutines.test.runTest
|
|
121
|
+
|
|
122
|
+
@Test
|
|
123
|
+
fun fetches() = runTest {
|
|
124
|
+
val svc = UserService(fakeRepo)
|
|
125
|
+
val u = svc.fetchUser("u1") // no real delay
|
|
126
|
+
assertEquals("u1", u.id)
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`runTest` virtualizes time. `delay(1.hours)` completes instantly. `advanceTimeBy(...)` controls the scheduler.
|
|
131
|
+
|
|
132
|
+
## `Flow` tests — turbine
|
|
133
|
+
|
|
134
|
+
```kotlin
|
|
135
|
+
import app.cash.turbine.test
|
|
136
|
+
|
|
137
|
+
@Test
|
|
138
|
+
fun `flow emits expected values`() = runTest {
|
|
139
|
+
viewModel.state.test {
|
|
140
|
+
assertEquals(State.Loading, awaitItem())
|
|
141
|
+
viewModel.load()
|
|
142
|
+
assertEquals(State.Success(user), awaitItem())
|
|
143
|
+
cancelAndIgnoreRemainingEvents()
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Don't test Flows by collecting into a list in an uncontrolled scope — tests become flaky.
|
|
149
|
+
|
|
150
|
+
## Integration tests
|
|
151
|
+
|
|
152
|
+
- **JVM backend**: real DB via Testcontainers; real HTTP via Ktor client or `MockWebServer`.
|
|
153
|
+
- **Android**: instrumented tests (`androidx.test`), or local-JVM Robolectric for fast feedback.
|
|
154
|
+
|
|
155
|
+
```kotlin
|
|
156
|
+
@Test
|
|
157
|
+
fun `loads user from real db`() = runTest {
|
|
158
|
+
Testcontainers.start("postgres:16")
|
|
159
|
+
val repo = JdbcUserRepository(dataSource)
|
|
160
|
+
repo.save(User("u1", "a@x.com"))
|
|
161
|
+
assertEquals("a@x.com", repo.find("u1")?.email)
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Keep integration tests in a separate source set (`src/integrationTest/`) with its own Gradle task — `./gradlew integrationTest`.
|
|
166
|
+
|
|
167
|
+
## Android UI tests
|
|
168
|
+
|
|
169
|
+
- **Jetpack Compose**: `createComposeRule()`, `onNodeWithText(...).performClick()`.
|
|
170
|
+
- **Views**: Espresso.
|
|
171
|
+
|
|
172
|
+
```kotlin
|
|
173
|
+
@get:Rule
|
|
174
|
+
val compose = createComposeRule()
|
|
175
|
+
|
|
176
|
+
@Test
|
|
177
|
+
fun `shows user name`() {
|
|
178
|
+
compose.setContent { UserCard(user = sampleUser) }
|
|
179
|
+
compose.onNodeWithText("Alice").assertIsDisplayed()
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Code coverage
|
|
184
|
+
|
|
185
|
+
JaCoCo for JVM / Android. See `coding-standards/references/tdd.md` for target numbers.
|
|
186
|
+
|
|
187
|
+
```kotlin
|
|
188
|
+
// build.gradle.kts
|
|
189
|
+
plugins { jacoco }
|
|
190
|
+
tasks.test { finalizedBy(tasks.jacocoTestReport) }
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Property tests — Kotest property API
|
|
194
|
+
|
|
195
|
+
```kotlin
|
|
196
|
+
import io.kotest.property.*
|
|
197
|
+
import io.kotest.property.arbitrary.*
|
|
198
|
+
|
|
199
|
+
"sort is idempotent" - {
|
|
200
|
+
checkAll(Arb.list(Arb.int())) { xs ->
|
|
201
|
+
xs.sorted() shouldBe xs.sorted().sorted()
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Great for parsers, serializers, invariants.
|
|
207
|
+
|
|
208
|
+
## Anti-patterns
|
|
209
|
+
|
|
210
|
+
| Anti-pattern | Fix |
|
|
211
|
+
|---|---|
|
|
212
|
+
| `Thread.sleep` in coroutine tests | `delay` + `runTest` |
|
|
213
|
+
| `runBlocking` in tests of suspend functions | `runTest` virtualizes time |
|
|
214
|
+
| Over-mocking (10 mocks for 20 LOC) | Use a fake |
|
|
215
|
+
| `verify { ... }` counts that duplicate the mock setup | Assert observable behaviour instead |
|
|
216
|
+
| Snapshot tests for flaky output (time, UUID) | Inject fakes / fixed values |
|
|
217
|
+
| Tests that depend on coroutine ordering | Use `runTest` scheduler primitives, not real threads |
|
|
218
|
+
| Real network in unit tests | Fake the HTTP client or use `MockWebServer` |
|
|
219
|
+
| Test name like `test1`, `test2` | Describe behaviour: \`\`add combines positives\`\` |
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mobile
|
|
3
|
+
description: Mobile end skill — architecture, offline-first, state, performance, lifecycle. Platform-neutral (iOS / Android / cross-platform). Pair with a language skill (`swift` / `kotlin` / `typescript`) and `coding-standards`.
|
|
4
|
+
origin: original
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Mobile
|
|
8
|
+
|
|
9
|
+
Native and cross-platform mobile app architecture. **Platform-neutral**; load with a language skill for syntax and a framework perspective.
|
|
10
|
+
|
|
11
|
+
## When to load
|
|
12
|
+
|
|
13
|
+
- Native iOS (Swift + UIKit / SwiftUI) or Android (Kotlin + Jetpack Compose / Views)
|
|
14
|
+
- Cross-platform: React Native, Flutter, Kotlin Multiplatform, .NET MAUI
|
|
15
|
+
- Hybrid: Capacitor, Cordova
|
|
16
|
+
- Topics: lifecycle, offline, state, navigation, platform APIs, app size, battery
|
|
17
|
+
|
|
18
|
+
## Core principles
|
|
19
|
+
|
|
20
|
+
1. **Lifecycle is the tax.** Your app gets suspended, killed, restored — design for it, don't pretend it's a desktop.
|
|
21
|
+
2. **Offline is the default.** The network is bad, intermittent, or absent. Degrade gracefully.
|
|
22
|
+
3. **Screens are ephemeral; state persists.** Recreate UI from state on re-entry.
|
|
23
|
+
4. **One source of truth per piece of data.** Caches reconcile with server truth; UIs reflect the cache.
|
|
24
|
+
5. **Trim aggressively.** Every 1 MB costs downloads and disk. Every dep adds attack surface and supply risk.
|
|
25
|
+
6. **Respect the user's battery and data.** Background work is a privilege; schedule wisely.
|
|
26
|
+
7. **Accessibility is platform-native.** Use TalkBack / VoiceOver primitives, not custom read-aloud.
|
|
27
|
+
8. **Test on a low-end device with slow network.** "Works on latest iPhone" is not a release signal.
|
|
28
|
+
|
|
29
|
+
## How to use references
|
|
30
|
+
|
|
31
|
+
| Reference | When to load |
|
|
32
|
+
|---|---|
|
|
33
|
+
| [`references/architecture.md`](references/architecture.md) | MVVM / MVI / TCA / clean architecture, DI, module boundaries |
|
|
34
|
+
| [`references/state-and-data.md`](references/state-and-data.md) | Local store, sync, offline-first, optimistic updates, reactive streams |
|
|
35
|
+
| [`references/navigation.md`](references/navigation.md) | Stack, tab, modal, deep links, universal links, back-stack |
|
|
36
|
+
| [`references/performance.md`](references/performance.md) | Startup, frame rate, memory, battery, app size |
|
|
37
|
+
| [`references/platform.md`](references/platform.md) | Permissions, notifications, background tasks, biometrics, crypto / keychain |
|
|
38
|
+
|
|
39
|
+
## Forbidden patterns (auto-reject)
|
|
40
|
+
|
|
41
|
+
- Blocking the UI thread (network, disk, JSON parse) in the request path
|
|
42
|
+
- Storing secrets / tokens in `UserDefaults` / `SharedPreferences` — use Keychain / Keystore
|
|
43
|
+
- `Thread.sleep` / `Thread.sleep(...)` in UI code
|
|
44
|
+
- Manual lifecycle handling in modern frameworks (use `ViewModel` / `StateObject` / scoped observers)
|
|
45
|
+
- Global mutable singletons for business data
|
|
46
|
+
- Hard-coded base URLs in release builds
|
|
47
|
+
- Silent swallow of network errors (no indicator, no retry)
|
|
48
|
+
- Unbounded in-memory caches (OOM kills)
|
|
49
|
+
- Hitting `/me` on every screen (fetch once, cache, invalidate on write)
|
|
50
|
+
- "Reload the world" after any change (no delta / optimistic update)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Mobile — Architecture
|
|
2
|
+
|
|
3
|
+
MVVM, MVI, TCA, clean architecture. Platform-neutral.
|
|
4
|
+
|
|
5
|
+
## The shapes
|
|
6
|
+
|
|
7
|
+
| Pattern | Idea | Good for |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| **MVVM** | View ← binds → ViewModel → Model | SwiftUI, Jetpack Compose, most modern apps |
|
|
10
|
+
| **MVI** | View → Intent → Reducer → State → View | Deterministic, testable flows; complex screens |
|
|
11
|
+
| **TCA** (iOS) | Redux-like, dependency-injected | Teams that want explicit wiring everywhere |
|
|
12
|
+
| **Clean / Onion** | Layers; domain in the middle | Large apps with multiple teams |
|
|
13
|
+
|
|
14
|
+
Most apps today land on MVVM + unidirectional data flow. MVI is MVVM with explicit state / intent naming. Pick one per project.
|
|
15
|
+
|
|
16
|
+
## Layering (clean-ish)
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
┌──────────────────────────────────────────┐
|
|
20
|
+
│ UI (screens, components) │ — platform-specific
|
|
21
|
+
├──────────────────────────────────────────┤
|
|
22
|
+
│ Presentation (ViewModels / Stores) │ — state + actions
|
|
23
|
+
├──────────────────────────────────────────┤
|
|
24
|
+
│ Domain (entities, use cases) │ — pure, platform-free
|
|
25
|
+
├──────────────────────────────────────────┤
|
|
26
|
+
│ Data (repositories + sources: API, DB) │ — infrastructure
|
|
27
|
+
└──────────────────────────────────────────┘
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- **UI** calls into the ViewModel; ViewModel calls into use cases / repos.
|
|
31
|
+
- **Domain** has no Android / iOS imports. Shareable across platforms.
|
|
32
|
+
- **Data** implements the interfaces the Domain declares.
|
|
33
|
+
|
|
34
|
+
Like `backend/layering.md`: dependency flows inward. Don't let `Context` / `UIApplication` leak into domain code.
|
|
35
|
+
|
|
36
|
+
## ViewModel — owner of screen state
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
ViewModel:
|
|
40
|
+
state: observable<ScreenState>
|
|
41
|
+
|
|
42
|
+
onIntent(Intent):
|
|
43
|
+
match intent:
|
|
44
|
+
case .load: state = .loading; launch { fetchUser() }
|
|
45
|
+
case .retry: state = .loading; launch { fetchUser() }
|
|
46
|
+
case .select(id): navigator.push(Detail(id))
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Responsibilities:
|
|
50
|
+
- Own the screen's state
|
|
51
|
+
- Talk to use cases / repos
|
|
52
|
+
- Translate user input into state changes
|
|
53
|
+
- Survive configuration changes (rotation, dark mode) — framework handles if you use the right scope
|
|
54
|
+
|
|
55
|
+
NOT responsibilities:
|
|
56
|
+
- Drawing pixels
|
|
57
|
+
- Navigating directly (use a navigator / router)
|
|
58
|
+
- Accessing platform APIs directly (get them via DI)
|
|
59
|
+
|
|
60
|
+
## Unidirectional data flow
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
User action ────▶ Intent ────▶ ViewModel ────▶ State
|
|
64
|
+
▲ │
|
|
65
|
+
└────────────────────────────────────────┘
|
|
66
|
+
View observes
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- Views are a function of state. Don't mutate state inside views.
|
|
70
|
+
- Intents are explicit. Don't let the view call arbitrary business logic.
|
|
71
|
+
- State changes are traceable (log the intent) for debugging.
|
|
72
|
+
|
|
73
|
+
## Dependency injection
|
|
74
|
+
|
|
75
|
+
Every non-trivial app uses DI. Options vary by platform:
|
|
76
|
+
|
|
77
|
+
| Platform | Common choices |
|
|
78
|
+
|---|---|
|
|
79
|
+
| Android | Hilt, Koin, manual constructor injection |
|
|
80
|
+
| iOS | Swinject, @Environment, manual |
|
|
81
|
+
| Cross-platform (KMP / RN) | Manual / small DI libs |
|
|
82
|
+
|
|
83
|
+
Prefer constructor injection; simpler, testable.
|
|
84
|
+
|
|
85
|
+
Anti-pattern: service locator / global accessor. They hide deps and make testing painful.
|
|
86
|
+
|
|
87
|
+
## Modularization
|
|
88
|
+
|
|
89
|
+
As the app grows, split by feature (not by layer):
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
app/
|
|
93
|
+
├── feature-auth/
|
|
94
|
+
│ ├── domain/ presentation/ data/
|
|
95
|
+
├── feature-orders/
|
|
96
|
+
│ ├── domain/ presentation/ data/
|
|
97
|
+
├── feature-profile/
|
|
98
|
+
├── shared/
|
|
99
|
+
│ ├── ui/ network/ persistence/
|
|
100
|
+
└── app-shell/ # wires features together, navigation root
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Benefits:
|
|
104
|
+
- Parallel team work.
|
|
105
|
+
- Faster incremental builds.
|
|
106
|
+
- Forces clean interfaces between features.
|
|
107
|
+
|
|
108
|
+
Over-modularization before the app needs it is friction. Start single-module; split when team size or build time hurts.
|
|
109
|
+
|
|
110
|
+
## Use cases
|
|
111
|
+
|
|
112
|
+
A use case is one screen's intent: `GetUserProfile`, `PlaceOrder`, `LogOut`.
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
class PlaceOrder(
|
|
116
|
+
val orderRepo: OrderRepository,
|
|
117
|
+
val paymentGateway: PaymentGateway,
|
|
118
|
+
val analytics: Analytics,
|
|
119
|
+
) {
|
|
120
|
+
suspend fun invoke(cmd: PlaceOrderCommand): Result<OrderId> {
|
|
121
|
+
val order = Order.create(cmd) // domain rule
|
|
122
|
+
orderRepo.save(order)
|
|
123
|
+
paymentGateway.authorize(order.total)
|
|
124
|
+
analytics.track("order_placed", order.id)
|
|
125
|
+
return Result.success(order.id)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Small, one method, testable. Don't force every screen to go through a use case — if it's just loading data, call the repo from the ViewModel.
|
|
131
|
+
|
|
132
|
+
## Repository — hides the data sources
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
interface UserRepository {
|
|
136
|
+
suspend fun get(id: String): User // hits cache, falls back to API
|
|
137
|
+
suspend fun refresh(id: String): User // forces network
|
|
138
|
+
fun observe(id: String): Flow<User> // reactive stream
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Inside:
|
|
143
|
+
- Local store (Room / SwiftData / Realm / SQLite).
|
|
144
|
+
- Remote source (HTTP client).
|
|
145
|
+
- Reconciliation logic (cache + network).
|
|
146
|
+
|
|
147
|
+
Consumers don't know or care. That's the point.
|
|
148
|
+
|
|
149
|
+
## Error surfacing
|
|
150
|
+
|
|
151
|
+
Domain errors → enum / sealed type that the ViewModel maps to UI state.
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
sealed class OrderError {
|
|
155
|
+
object InsufficientFunds : OrderError()
|
|
156
|
+
object OutOfStock : OrderError()
|
|
157
|
+
data class Network(val cause: Throwable) : OrderError()
|
|
158
|
+
data class Unknown(val cause: Throwable) : OrderError()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
state = State.Error(OrderError.InsufficientFunds)
|
|
162
|
+
// UI: show a dialog with specific copy + action
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Never show a raw exception message to the user. Map to something actionable.
|
|
166
|
+
|
|
167
|
+
## Coroutine / async scoping
|
|
168
|
+
|
|
169
|
+
- Android: `viewModelScope`, `lifecycleScope`.
|
|
170
|
+
- iOS Swift: `Task`, tied to view lifecycle or `@StateObject`.
|
|
171
|
+
- React Native: effects tied to component lifecycle; clean up on unmount.
|
|
172
|
+
- Flutter: `dispose()` on `StatefulWidget`; `Stream` subscriptions cancelled.
|
|
173
|
+
|
|
174
|
+
Don't start work that outlives the scope. Leaked background tasks cause crashes, stale state, wasted battery.
|
|
175
|
+
|
|
176
|
+
## Feature flags
|
|
177
|
+
|
|
178
|
+
Every mobile release is "all or nothing" for some hours. Feature flags let you ship code dark and flip when ready.
|
|
179
|
+
|
|
180
|
+
- Client flag service (LaunchDarkly, ConfigCat, home-grown).
|
|
181
|
+
- Defaults that are safe if the service is unreachable (offline start).
|
|
182
|
+
- Short-lived; clean up flags once fully rolled out.
|
|
183
|
+
|
|
184
|
+
## Telemetry hooks
|
|
185
|
+
|
|
186
|
+
- App open, session start/end
|
|
187
|
+
- Screen viewed (one event per meaningful screen; don't inflate)
|
|
188
|
+
- Intent triggered (for key flows — signup, checkout)
|
|
189
|
+
- Error happened (with anonymous context; never PII)
|
|
190
|
+
|
|
191
|
+
Ship to a pipeline (Firebase Analytics, Segment, MixPanel, self-hosted) with a schema you control.
|
|
192
|
+
|
|
193
|
+
## Anti-patterns
|
|
194
|
+
|
|
195
|
+
| Anti-pattern | Fix |
|
|
196
|
+
|---|---|
|
|
197
|
+
| Business logic in view files | Move to ViewModel / use case |
|
|
198
|
+
| ViewModel holds a `Context` / `UIViewController` reference | Leaks UI into presentation; refactor |
|
|
199
|
+
| Singleton "service" with mutable business data | DI via constructor; scope to a request or screen |
|
|
200
|
+
| Navigation logic scattered across screens | Navigator / router at app level |
|
|
201
|
+
| Each screen fetches `/me` on start | Cache; fetch once, invalidate on logout / update |
|
|
202
|
+
| Data layer throws raw SQL exceptions to the UI | Repository maps to domain errors |
|
|
203
|
+
| Feature modules depending on each other arbitrarily | Feature modules depend on `shared/`; never on siblings |
|
|
204
|
+
| "Reactive Everywhere" — observable every primitive | Use streams for state that changes; keep static state static |
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Mobile — Navigation
|
|
2
|
+
|
|
3
|
+
Stack, tabs, modals, deep links, universal / app links.
|
|
4
|
+
|
|
5
|
+
## Core model
|
|
6
|
+
|
|
7
|
+
Mobile navigation is a stack (usually multiple, one per tab). The user always has:
|
|
8
|
+
- A clear back action (system or in-app).
|
|
9
|
+
- An understanding of where they are (title, breadcrumb, tab highlight).
|
|
10
|
+
- A way to exit / cancel a flow.
|
|
11
|
+
|
|
12
|
+
## Stack
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
Root ─▶ List ─▶ Detail ─▶ Edit ─▶ Confirmation
|
|
16
|
+
▲
|
|
17
|
+
└── back returns here, not to List
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Rules:
|
|
21
|
+
- **Keep stacks shallow.** 4+ levels usually means you should bring the user back to a root or use a modal.
|
|
22
|
+
- **Clear the stack after destructive flows** (signup complete, logout, delete-account).
|
|
23
|
+
- **Don't insert navigation into the middle** (teleports jarring to users).
|
|
24
|
+
|
|
25
|
+
## Tabs
|
|
26
|
+
|
|
27
|
+
Top-level categories — don't abuse. Rule of thumb: 3–5 tabs max.
|
|
28
|
+
|
|
29
|
+
- Each tab has its own stack.
|
|
30
|
+
- Switching tabs preserves per-tab state (position in list, scroll).
|
|
31
|
+
- Re-selecting the active tab scrolls to top (standard iOS / Android behavior).
|
|
32
|
+
|
|
33
|
+
## Modals
|
|
34
|
+
|
|
35
|
+
Use for tasks that are:
|
|
36
|
+
- Focused (one clear goal).
|
|
37
|
+
- Interruptible (user can cancel).
|
|
38
|
+
- Temporary (they don't want to return here later).
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
[ Close ] Edit profile [ Save ]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Don't use modals for content the user needs to return to. A profile page isn't a modal; editing the profile is.
|
|
45
|
+
|
|
46
|
+
Bottom sheets / partial modals are modals too. Same rules.
|
|
47
|
+
|
|
48
|
+
## Deep links
|
|
49
|
+
|
|
50
|
+
Every route should be addressable by URL.
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
myapp://orders/123
|
|
54
|
+
https://example.com/orders/123
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- **Custom scheme** (`myapp://`) — older; still used for some flows.
|
|
58
|
+
- **Universal links (iOS) / App links (Android)** — modern; open the app if installed, fall back to web otherwise. Required for secure flows.
|
|
59
|
+
|
|
60
|
+
Setup is fiddly:
|
|
61
|
+
- iOS: `apple-app-site-association` file, `associatedDomains` entitlement.
|
|
62
|
+
- Android: intent filters + Digital Asset Links.
|
|
63
|
+
|
|
64
|
+
Test with scanner tools (`branch.io` validator, `assetlinks.google.com/tools`).
|
|
65
|
+
|
|
66
|
+
## Back-stack hydration
|
|
67
|
+
|
|
68
|
+
When the user lands via a deep link, the back stack should reflect the path they'd have if they navigated manually.
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
Link: /orders/123/items/7
|
|
72
|
+
|
|
73
|
+
# ❌ back pops out of the app
|
|
74
|
+
[ items/7 ]
|
|
75
|
+
|
|
76
|
+
# ✅ back goes to the logical parent
|
|
77
|
+
[ root ] ─▶ [ orders ] ─▶ [ order 123 ] ─▶ [ item 7 ]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Build the stack when resolving the link; don't leave the user stuck.
|
|
81
|
+
|
|
82
|
+
## Navigation state is your state
|
|
83
|
+
|
|
84
|
+
Declarative navigation (Compose Navigation, SwiftUI `NavigationStack`, React Navigation) treats the route as a data structure. Restore on cold start by persisting and rehydrating.
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
state = [ Home, Orders, Order(id=123) ]
|
|
88
|
+
// on process death, serialize; on cold start, rehydrate
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Imperative navigation (push/pop calls) is harder to persist. Prefer declarative in new apps.
|
|
92
|
+
|
|
93
|
+
## Passing data between screens
|
|
94
|
+
|
|
95
|
+
Options, in order of preference:
|
|
96
|
+
|
|
97
|
+
1. **Route params** (IDs only). `order/123`. Serializable, deep-linkable.
|
|
98
|
+
2. **Shared ViewModel / store**. The source of truth lives in one place.
|
|
99
|
+
3. **Nav callbacks** (for single-screen outcomes: "picker returns a country"). Keep narrow.
|
|
100
|
+
4. **Serialized object in the route** — last resort; breaks if the model changes.
|
|
101
|
+
|
|
102
|
+
Don't pass live objects / closures across screens; the framework can't restore them after process death.
|
|
103
|
+
|
|
104
|
+
## Tabs + deep links
|
|
105
|
+
|
|
106
|
+
Deep link lands on a tab and a stack within that tab:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
/profile/settings ─▶ tab=Profile, stack=[Profile, Settings]
|
|
110
|
+
/cart ─▶ tab=Cart, stack=[Cart]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Set the tab, then restore the stack, in one atomic transition.
|
|
114
|
+
|
|
115
|
+
## Transitions
|
|
116
|
+
|
|
117
|
+
- **Native** where available (push / pop, modal present). Users recognize the motion.
|
|
118
|
+
- **Custom** only for brand moments. Custom transitions are slow to build and often feel off on other devices.
|
|
119
|
+
- Respect `prefers-reduced-motion` — reduce or skip animations.
|
|
120
|
+
|
|
121
|
+
## Bottom navigation vs. side drawer
|
|
122
|
+
|
|
123
|
+
- **Bottom nav** (mobile-native, iOS tabs / Android BottomNavigationView): 3–5 top-level items, always visible.
|
|
124
|
+
- **Side drawer** (hamburger): de-emphasized items, usable with one hand awkward, often hurts discoverability.
|
|
125
|
+
|
|
126
|
+
Bottom nav by default. Drawer for overflow or very large navigation sets.
|
|
127
|
+
|
|
128
|
+
## Back button (Android) and gestures
|
|
129
|
+
|
|
130
|
+
- **Android hardware/system back**: must work everywhere. Never block.
|
|
131
|
+
- **iOS swipe-back**: enabled by default on navigation stacks; don't disable without reason.
|
|
132
|
+
- **Android predictive back** (13+): opt in via `BackInvokedCallback` to show a preview.
|
|
133
|
+
|
|
134
|
+
Handle in-flow back (dismiss modal, close keyboard, confirm-before-exit for unsaved data).
|
|
135
|
+
|
|
136
|
+
## Sign-out / session expiry
|
|
137
|
+
|
|
138
|
+
On logout or token revocation:
|
|
139
|
+
1. Clear secure storage (tokens, keys, cached user).
|
|
140
|
+
2. Wipe in-memory state.
|
|
141
|
+
3. Reset navigation to the auth / onboarding root.
|
|
142
|
+
4. Cancel any in-flight requests / subscriptions.
|
|
143
|
+
|
|
144
|
+
Skipping step 3 leaves the user on a logged-in screen with no data — confusing.
|
|
145
|
+
|
|
146
|
+
## Anti-patterns
|
|
147
|
+
|
|
148
|
+
| Anti-pattern | Fix |
|
|
149
|
+
|---|---|
|
|
150
|
+
| Deep link lands on a screen with no back stack | Hydrate the full path |
|
|
151
|
+
| Complex data serialized into the URL | Pass an ID; fetch from source |
|
|
152
|
+
| 8-tab bottom nav | Consolidate; use "More" or search |
|
|
153
|
+
| Modal pushing another modal pushing another | Rethink the flow |
|
|
154
|
+
| Blocking system back | Always respond; ask if truly destructive |
|
|
155
|
+
| Navigation in `useEffect` during render | Navigate on user action or in route loader |
|
|
156
|
+
| Different bottom nav per screen | Confusing; top-level navigation should be stable |
|
|
157
|
+
| Losing form state on rotation / background | Persist draft to local store |
|
|
158
|
+
| "Back" that goes forward (resets to root) | Match user expectation of "previous" |
|