Skip to content

Commit 0d139d9

Browse files
authored
Merge branch 'develop' into GT-2980-No-Lessons-UI
2 parents 6ce2d02 + 2c3016d commit 0d139d9

52 files changed

Lines changed: 616 additions & 130 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/skills/pr-review/SKILL.md

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,34 @@ If it fails, report as **Must Fix** before reviewing anything else.
3838

3939
7. Output a structured review (format below).
4040

41-
8. After the review output, print:
41+
8. Post inline comments to the PR for every ⚠️ and ❌ finding that references a specific file and line number. Before posting, deduplicate against all existing comments (resolved or not) to avoid re-posting anything already raised:
42+
43+
```bash
44+
# Get the head SHA, repo, and all existing review comments (resolved and unresolved)
45+
HEAD_SHA=$(gh pr view $ARGUMENTS --json headRefOid -q .headRefOid)
46+
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
47+
EXISTING=$(gh api repos/$REPO/pulls/$ARGUMENTS/comments --jq '[.[] | select(.in_reply_to_id == null) | {path:.path, line:.line, body:.body}]')
48+
```
49+
50+
For each finding, check whether any existing comment (resolved or not) already covers the same file + line (or contains substantially the same text). Skip any finding that is already covered. Then bundle the remaining new comments into a single review submission:
51+
52+
```bash
53+
gh api repos/$REPO/pulls/$ARGUMENTS/reviews \
54+
--method POST \
55+
--field commit_id="$HEAD_SHA" \
56+
--field event="COMMENT" \
57+
--field "comments[][path]=<file path>" \
58+
--field "comments[][line]=<line number>" \
59+
--field "comments[][side]=RIGHT" \
60+
--field "comments[][body]=<finding text>
61+
62+
🤖 Posted by [Claude Code](https://claude.ai/code)" \
63+
# repeat --field "comments[]..." for each new finding
64+
```
65+
66+
Use the exact file path from the diff and the line number in the current version of the file (RIGHT side). Each comment body should contain the full finding description. Always append the attribution footer `\n\n🤖 Posted by [Claude Code](https://claude.ai/code)` to each comment. If no new actionable findings exist (only ✅ items or all already commented), skip this step.
67+
68+
9. After the review output, print:
4269

4370
```
4471
---
@@ -92,6 +119,15 @@ To dismiss a finding so it won't appear in future reviews, say:
92119
- [ ] `UiEvent` is a `sealed interface` implementing `CircuitUiEvent`, marked `internal`
93120
- [ ] `UiState` and `UiEvent` defined as nested types inside the Presenter
94121
- [ ] `UiState` exposes `val eventSink: (UiEvent) -> Unit`
122+
- [ ] `eventSink` lambda passed directly when constructing `UiState` — not extracted to a local variable first:
123+
```kotlin
124+
// Correct
125+
return UiState(items = items) { event -> when (event) { … } }
126+
// Wrong
127+
val eventSink: (UiEvent) -> Unit = { … }
128+
return UiState(items = items, eventSink = eventSink)
129+
```
130+
- [ ] `navigator.pop()` (or any navigation call) placed *inside* the `launch { }` block when it follows an async operation — `rememberCoroutineScope()` is canceled on composition disposal and can cancel an in-flight write before it completes
95131
- [ ] Presenter contains no UI logic — pure state/event handling
96132

97133
**UI Composable**
@@ -130,6 +166,7 @@ To dismiss a finding so it won't appear in future reviews, say:
130166
### Testing
131167

132168
- [ ] Presenter tests use `presenterTestOf { }` (Circuit test API)
169+
- [ ] Interfaces with `@Composable` functions use a hand-written `Fake*` class in tests, not `mockk<>()` — mockposable delays Kotlin version uptake and is incompatible with newer compiler versions
133170
- [ ] Paparazzi tests extend `BasePaparazziTest` from `:ui:base` testFixtures with `@TestParameter` night/accessibility matrix
134171
- [ ] Snapshots not recorded locally — triggered via GitHub Actions workflow on the feature branch
135172

@@ -190,4 +227,50 @@ When the user says `dismiss: <title> — <reason>` (in any form — "dismiss the
190227
**Dismissed by**: <git user.name>
191228
```
192229

193-
4. Confirm to the user what was added and that it will be suppressed in future reviews.
230+
4. If the current session reviewed a PR, find any open (unresolved) comment thread on that PR matching the dismissed issue. Use the GraphQL API to locate threads and resolve the matching one, replying with the dismissal reason first:
231+
232+
```bash
233+
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
234+
OWNER=${REPO%%/*}
235+
REPONAME=${REPO##*/}
236+
237+
# Find unresolved review threads
238+
gh api graphql -f query="
239+
{
240+
repository(owner: \"$OWNER\", name: \"$REPONAME\") {
241+
pullRequest(number: $PR_NUMBER) {
242+
reviewThreads(first: 100) {
243+
nodes {
244+
id
245+
isResolved
246+
comments(first: 1) {
247+
nodes { id body path line }
248+
}
249+
}
250+
}
251+
}
252+
}
253+
}"
254+
```
255+
256+
Match the thread by file path, line number, or substantial text overlap with the dismissed finding. Then reply to the thread and resolve it:
257+
258+
```bash
259+
# Reply to the thread's first comment explaining the dismissal
260+
gh api repos/$REPO/pulls/$PR_NUMBER/comments \
261+
--method POST \
262+
--field in_reply_to=<comment_id> \
263+
--field body="Dismissed: <reason given by user>
264+
265+
🤖 [Claude Code](https://claude.ai/code)"
266+
267+
# Resolve the thread via GraphQL
268+
gh api graphql -f query="
269+
mutation {
270+
resolveReviewThread(input: { threadId: \"<thread_node_id>\" }) {
271+
thread { id isResolved }
272+
}
273+
}"
274+
```
275+
276+
5. Confirm to the user what was added and that it will be suppressed in future reviews.

README.md

Lines changed: 138 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,144 @@ GodTools Android
33

44
[![codecov](https://codecov.io/gh/CruGlobal/godtools-android/branch/develop/graph/badge.svg)](https://codecov.io/gh/CruGlobal/godtools-android)
55

6-
# Git LFS
6+
GodTools is a mobile discipleship app built by [Cru](https://www.cru.org/). This repository contains the Android client.
77

8-
We use [Git LFS](https://git-lfs.com/) for storing Paparazzi snapshots. You will need to setup [Git LFS](https://git-lfs.com/) on your local machine in order to store new paparazzi snapshots or validate existing paparazzi snapshots.
8+
## Requirements
99

10-
# Crowdin
10+
- **JDK 21** (Temurin) — version specified in `.tool-versions`
11+
- **Android SDK** — compile/target SDK 36, minimum SDK 24
12+
- **Git LFS** — required for Paparazzi snapshot files
1113

12-
To enable Crowdin translation downloads/uploads, install the [Crowdin CLI tool](https://crowdin.github.io/crowdin-cli/)
14+
## Getting Started
15+
16+
```bash
17+
# Clone the repo (Git LFS must be installed first)
18+
git clone https://github.com/CruGlobal/godtools-android.git
19+
```
20+
21+
### Git LFS
22+
23+
Paparazzi snapshot images are stored in [Git LFS](https://git-lfs.com/). Install it before cloning or working with snapshot tests:
24+
25+
```bash
26+
brew install git-lfs # macOS
27+
git lfs install
28+
```
29+
30+
## Build & Development
31+
32+
```bash
33+
# Build the app bundle
34+
./gradlew bundle
35+
36+
# Run unit tests (all variants)
37+
./gradlew test
38+
39+
# Run tests for a specific module
40+
./gradlew :library:model:test
41+
./gradlew :app:test
42+
43+
# Verify Paparazzi snapshot tests (requires Git LFS)
44+
./gradlew verifyPaparazzi
45+
46+
# Run ktlint (code style checks)
47+
./gradlew :build-logic:ktlintCheck ktlintCheck
48+
49+
# Run Android lint
50+
./gradlew lint
51+
```
52+
53+
> **Paparazzi Snapshots:** Do not record snapshots locally. Trigger the manual GitHub Actions workflow on your feature branch to generate and commit updated screenshots.
54+
55+
## Project Structure
56+
57+
```
58+
app/ # Main application module
59+
build-logic/ # Gradle convention plugins
60+
feature/ # Google Play Dynamic Feature modules
61+
library/ # Core non-UI modules
62+
ui/ # UI and tool-renderer modules
63+
```
64+
65+
### `app/`
66+
Main application module — `GodToolsApplication` (Hilt entry point), `DashboardActivity`, Dagger modules in `dagger/`.
67+
68+
### `library/`
69+
| Module | Purpose |
70+
|---|---|
71+
| `model` | Data models and JSON:API type definitions |
72+
| `db` | Room database, DAOs, and repositories |
73+
| `api` | Retrofit REST and Scarlet WebSocket clients |
74+
| `sync` | WorkManager-based data synchronization |
75+
| `base` | Core utilities, settings, file system abstraction |
76+
| `account` | User authentication |
77+
| `analytics` | Event tracking |
78+
| `download-manager` | Tool download management |
79+
| `initial-content` | Bundled initial content |
80+
| `user-data` | User preferences and state |
81+
82+
### `ui/`
83+
| Module | Purpose |
84+
|---|---|
85+
| `base` | Shared Compose components and Material 3 theme |
86+
| `base-tool` | Base tool rendering infrastructure |
87+
| `tract-renderer` | Tract tool renderer |
88+
| `lesson-renderer` | Lesson tool renderer |
89+
| `article-renderer` | Article tool renderer |
90+
| `article-aem-renderer` | AEM article tool renderer |
91+
| `cyoa-renderer` | Choose Your Own Adventure renderer |
92+
| `tips-renderer` | Tips renderer |
93+
| `tutorial-renderer` | Tutorial renderer |
94+
| `shortcuts` | App shortcuts UI |
95+
| `qr-code` | QR code feature UI |
96+
97+
### `feature/`
98+
Google Play Dynamic Feature modules: `bundledcontent` (bundled initial content delivered via Play).
99+
100+
### `build-logic/`
101+
Gradle convention plugins: `godtools.application-conventions`, `godtools.library-conventions`, `godtools.dynamic-feature-conventions`.
102+
103+
## Architecture & Key Frameworks
104+
105+
| Concern | Technology |
106+
|---|---|
107+
| Dependency injection | Hilt (Dagger 2) |
108+
| UI | Jetpack Compose + Material Design 3 |
109+
| Navigation / UI state | [Slack Circuit](https://slackhq.github.io/circuit/) |
110+
| Networking | Retrofit (REST), Scarlet (WebSocket), OkHttp |
111+
| Database | Room |
112+
| Background work | WorkManager |
113+
| Image loading | Coil |
114+
| Dependency versions | `gradle/libs.versions.toml` version catalog |
115+
116+
**Circuit pattern:** Presenter/UI pairs annotated with `@CircuitInject`. Presenters are `@Composable` functions that return state objects; actions flow through `UiState.eventSink`.
117+
118+
## Build Variants
119+
120+
- **Product flavors** (`env` dimension): `stage` (staging API), `production` (production API)
121+
- **Build types**: `debug`, `qa` (inherits debug), `release`
122+
- The `stage` flavor is only available for `debug` and `qa` builds
123+
124+
## Code Style
125+
126+
Style is enforced by [ktlint](https://pinterest.github.io/ktlint/) with the `android_studio` code style.
127+
128+
- Max line length: 120
129+
- Indent: 4 spaces
130+
131+
Always run ktlint before committing:
132+
133+
```bash
134+
./gradlew :build-logic:ktlintCheck ktlintCheck
135+
```
136+
137+
## Translations (Crowdin)
138+
139+
Strings are managed via [Crowdin](https://crowdin.com/). To push/pull translations, install the [Crowdin CLI](https://crowdin.github.io/crowdin-cli/) and set the `CROWDIN_API_TOKEN` environment variable.
140+
141+
## Contributing
142+
143+
1. Branch from `develop` and open PRs against `develop` (the default branch).
144+
2. Add unit tests for any new functionality.
145+
3. Run ktlint and unit tests before opening a PR.
146+
4. For UI changes, trigger the Paparazzi snapshot workflow on your branch rather than recording locally.

app/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ android {
4646
applicationIdSuffix = ".stage"
4747

4848
buildConfigField("String", "MOBILE_CONTENT_API", "\"$URI_MOBILE_CONTENT_API_STAGE\"")
49+
buildConfigField("String", "MOBILE_CONTENT_CDN", "\"https://mobilecontent-stage.cru.org\"")
4950

5051
// Facebook
5152
resValue("string", "facebook_app_id", "448969905944197")
@@ -61,6 +62,7 @@ android {
6162
}
6263
named("production") {
6364
buildConfigField("String", "MOBILE_CONTENT_API", "\"$URI_MOBILE_CONTENT_API_PRODUCTION\"")
65+
buildConfigField("String", "MOBILE_CONTENT_CDN", "\"https://mobilecontent.cru.org\"")
6466

6567
// Facebook
6668
resValue("string", "facebook_app_id", "2236701616451487")

app/src/main/kotlin/org/cru/godtools/dagger/ConfigModule.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ import dagger.Module
44
import dagger.Provides
55
import dagger.hilt.InstallIn
66
import dagger.hilt.components.SingletonComponent
7-
import javax.inject.Named
87
import org.cru.godtools.BuildConfig
9-
import org.cru.godtools.api.ApiModule
8+
import org.cru.godtools.api.ApiConfig
109

1110
@Module
1211
@InstallIn(SingletonComponent::class)
1312
object ConfigModule {
1413
@get:Provides
15-
@get:Named(ApiModule.MOBILE_CONTENT_API_URL)
16-
val mobileContentApiBaseUrl = BuildConfig.MOBILE_CONTENT_API
14+
val apiConfig = ApiConfig(
15+
mobileContentApiUrl = BuildConfig.MOBILE_CONTENT_API,
16+
cdnUrl = BuildConfig.MOBILE_CONTENT_CDN
17+
)
1718
}

app/src/main/kotlin/org/cru/godtools/ui/dashboard/LocalizationSettingsBox.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,11 @@ internal fun LocalizationSettingsBox(
3030
Column(modifier = Modifier.padding(16.dp)) {
3131
Text(
3232
text = stringResource(title),
33-
fontWeight = FontWeight.Bold,
34-
style = MaterialTheme.typography.bodyLarge
33+
style = MaterialTheme.typography.titleMedium
3534
)
3635
Text(
3736
text = stringResource(description),
38-
style = MaterialTheme.typography.bodySmall
37+
style = MaterialTheme.typography.bodyMedium
3938
)
4039
Button(
4140
onClick = onClickSettings,

app/src/main/kotlin/org/cru/godtools/ui/dashboard/lessons/LessonsLayout.kt

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,23 +29,18 @@ import org.cru.godtools.base.ui.circuit.screen.dashboard.page.LessonsScreen
2929
import org.cru.godtools.ui.dashboard.LocalizationSettingsBox
3030
import org.cru.godtools.ui.dashboard.lessons.LessonsPresenter.UiEvent
3131
import org.cru.godtools.ui.dashboard.lessons.LessonsPresenter.UiState
32-
import org.cru.godtools.ui.dashboard.rememberPinLastItemBottomArrangement
32+
import org.cru.godtools.ui.dashboard.personalization.rememberFloatLastItemsToBottomArrangement
3333
import org.cru.godtools.ui.tools.LessonToolCard
3434

3535
internal val MARGIN_LESSONS_LAYOUT_HORIZONTAL = 16.dp
3636

3737
@Composable
3838
@CircuitInject(LessonsScreen::class, SingletonComponent::class)
3939
internal fun LessonsLayout(state: UiState, modifier: Modifier = Modifier) {
40-
val pinLastItem = rememberPinLastItemBottomArrangement()
41-
val verticalArrangement = if (state.mode == UiState.Mode.PERSONALIZATION) {
42-
pinLastItem
43-
} else {
44-
Arrangement.Top
45-
}
46-
4740
LazyColumn(
48-
verticalArrangement = verticalArrangement,
41+
verticalArrangement = rememberFloatLastItemsToBottomArrangement(
42+
numToFloat = if (state.mode == UiState.Mode.PERSONALIZATION) 1 else 0
43+
),
4944
modifier = modifier.fillMaxHeight()
5045
) {
5146
if (state.isPersonalizationEnabled) {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package org.cru.godtools.ui.dashboard.personalization
2+
3+
import androidx.compose.foundation.layout.Arrangement
4+
import androidx.compose.runtime.Composable
5+
import androidx.compose.runtime.remember
6+
import androidx.compose.ui.unit.Density
7+
8+
@Composable
9+
internal fun rememberFloatLastItemsToBottomArrangement(numToFloat: Int) =
10+
remember(numToFloat) { FloatLastItemsToBottomArrangement(numToFloat) }
11+
12+
internal class FloatLastItemsToBottomArrangement(val numToFloat: Int) : Arrangement.Vertical {
13+
override fun Density.arrange(totalSize: Int, sizes: IntArray, outPositions: IntArray) {
14+
var currentOffset = 0
15+
sizes.forEachIndexed { index, size ->
16+
outPositions[index] = currentOffset
17+
currentOffset += size
18+
}
19+
20+
if (currentOffset < totalSize && numToFloat > 0) {
21+
currentOffset = totalSize
22+
sizes.takeLast(numToFloat).reversed().forEachIndexed { index, size ->
23+
currentOffset -= size
24+
outPositions[sizes.lastIndex - index] = currentOffset
25+
}
26+
}
27+
}
28+
}

app/src/main/kotlin/org/cru/godtools/ui/dashboard/tools/ToolsLayout.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import org.cru.godtools.R
3232
import org.cru.godtools.base.ui.circuit.screen.dashboard.page.ToolsScreen
3333
import org.cru.godtools.ui.banner.Banners
3434
import org.cru.godtools.ui.dashboard.LocalizationSettingsBox
35-
import org.cru.godtools.ui.dashboard.rememberPinLastItemBottomArrangement
35+
import org.cru.godtools.ui.dashboard.personalization.rememberFloatLastItemsToBottomArrangement
3636
import org.cru.godtools.ui.dashboard.tools.ToolsPresenter.UiEvent
3737
import org.cru.godtools.ui.dashboard.tools.ToolsPresenter.UiState
3838
import org.cru.godtools.ui.tools.SquareToolCard
@@ -58,7 +58,9 @@ internal fun ToolsLayout(state: UiState, modifier: Modifier = Modifier) {
5858

5959
LazyColumn(
6060
state = columnState,
61-
verticalArrangement = verticalArrangement,
61+
verticalArrangement = rememberFloatLastItemsToBottomArrangement(
62+
numToFloat = if (state.mode == UiState.Mode.PERSONALIZATION) 1 else 0
63+
),
6264
modifier = modifier.fillMaxHeight()
6365
) {
6466
if (!state.dataLoaded) return@LazyColumn

0 commit comments

Comments
 (0)