Skip to content

Commit aa29de7

Browse files
Clickable checkboxes (#2236)
* feat: make checkboxes in the Markdown checklists interactive I have successfully implemented interactive checkboxes for Markdown checklists in jtxBoard. Fixes: #1268 * ci: add GitHub Actions workflow to build and upload test APKs This workflow automates the generation of debug APKs for the OSE flavor whenever a push is made to the develop branch or a pull request is opened. The resulting APKs are uploaded as artifacts, allowing reviewers and testers to easily verify changes (such as the interactive Markdown checklists) without needing a local build environment. - Uses JDK 21 and Gradle cache for efficient builds. - Targets the OSE Debug build variant. - Uploads the APK as 'jtxboard-ose-debug-apk'. * ci: update workflows to JDK 21 and standardize actions - Updates unit-tests and android-tests to use JDK 21 to match build.gradle.kts. - Standardizes GitHub Actions versions to stable releases (v4). - Configures Build Test APK to run on all branches to support testing from feature branches. * ci: ignore APK generation for dependabot and l10n PRs References: #2236 --------- Co-authored-by: Patrick Lang <patrick.lang85@gmail.com>
1 parent d1e55b3 commit aa29de7

7 files changed

Lines changed: 366 additions & 6 deletions

File tree

.github/workflows/android-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
- uses: actions/setup-java@v5
1919
with:
2020
distribution: temurin
21-
java-version: 17
21+
java-version: 21
2222
- uses: gradle/actions/setup-gradle@v6
2323

2424
- name: Enable KVM group perms
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Build Test APK
2+
on:
3+
pull_request:
4+
push:
5+
branches: [ '**' ]
6+
branches-ignore:
7+
- 'dependabot/**'
8+
- 'l10n_develop'
9+
10+
jobs:
11+
build:
12+
name: Build OSE Debug APK
13+
runs-on: ubuntu-latest
14+
if: github.actor != 'patrickunterwegs'
15+
steps:
16+
- uses: actions/checkout@v4
17+
with:
18+
submodules: true
19+
- name: set up JDK 21
20+
uses: actions/setup-java@v4
21+
with:
22+
java-version: '21'
23+
distribution: 'temurin'
24+
cache: gradle
25+
26+
- name: Build Debug APK
27+
run: ./gradlew assembleOseDebug
28+
29+
- name: Upload APK
30+
uses: actions/upload-artifact@v4
31+
with:
32+
name: jtxboard-ose-debug-apk
33+
path: app/build/outputs/apk/ose/debug/*.apk

.github/workflows/unit-tests.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ jobs:
1111
- uses: actions/setup-java@v5
1212
with:
1313
distribution: temurin
14-
java-version: 17
14+
java-version: 21
1515
cache: gradle
1616
- name: Setup Gradle
17-
uses: gradle/actions/setup-gradle@v6
17+
- uses: gradle/actions/wrapper-validation@v4
1818

1919
- name: Run lint and unit tests
2020
run: ./gradlew app:lintOseDebug app:testGplayDebugUnitTest

app/src/main/java/at/techbee/jtx/ui/detail/DetailScreenContent.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import at.techbee.jtx.database.relations.ICalEntity
9090
import at.techbee.jtx.database.views.ICal4List
9191
import at.techbee.jtx.flavored.BillingManager
9292
import at.techbee.jtx.ui.detail.models.DetailsScreenSection
93+
import at.techbee.jtx.ui.reusable.components.InteractiveMarkdown
9394
import at.techbee.jtx.ui.reusable.elements.ProgressElement
9495
import at.techbee.jtx.ui.settings.DropdownSettingOption
9596
import at.techbee.jtx.util.DateTimeUtils
@@ -408,9 +409,13 @@ fun DetailScreenContent(
408409

409410
if (description.text.isNotBlank()) {
410411
if (detailSettings.detailSetting[DetailSettingsOption.ENABLE_MARKDOWN] != false)
411-
Markdown(
412+
InteractiveMarkdown(
412413
content = description.text.trim(),
413-
imageTransformer = Coil3ImageTransformerImpl,
414+
onContentChange = { updatedContent ->
415+
description = TextFieldValue(updatedContent)
416+
iCalObject.description = updatedContent.ifEmpty { null }
417+
changeState.value = DetailViewModel.DetailChangeState.CHANGEUNSAVED
418+
},
414419
modifier = Modifier
415420
.fillMaxWidth()
416421
.padding(8.dp)
@@ -1050,7 +1055,7 @@ fun DetailScreenContent_JOURNAL() {
10501055
goBack = { },
10511056
unlinkFromSeries = { _, _, _ -> },
10521057
onUnlinkSubEntry = { _, _ -> },
1053-
goToFilteredList = { },
1058+
goToFilteredList = { },
10541059
onShowLinkExistingDialog = { _, _ -> },
10551060
onUpdateSortOrder = { },
10561061
alarmSetting = DropdownSettingOption.AUTO_ALARM_ON_START
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package at.techbee.jtx.ui.reusable.components
2+
3+
import androidx.compose.foundation.clickable
4+
import androidx.compose.foundation.layout.Column
5+
import androidx.compose.foundation.layout.Row
6+
import androidx.compose.foundation.layout.fillMaxWidth
7+
import androidx.compose.foundation.layout.padding
8+
import androidx.compose.material.icons.Icons
9+
import androidx.compose.material.icons.filled.CheckBox
10+
import androidx.compose.material.icons.filled.CheckBoxOutlineBlank
11+
import androidx.compose.material3.Icon
12+
import androidx.compose.material3.MaterialTheme
13+
import androidx.compose.material3.Text
14+
import androidx.compose.runtime.Composable
15+
import androidx.compose.runtime.mutableStateOf
16+
import androidx.compose.runtime.remember
17+
import androidx.compose.ui.Alignment
18+
import androidx.compose.ui.Modifier
19+
import androidx.compose.ui.unit.dp
20+
import com.mikepenz.markdown.coil3.Coil3ImageTransformerImpl
21+
import com.mikepenz.markdown.m3.Markdown
22+
23+
/**
24+
* Custom Markdown component that supports interactive checkboxes in checklists
25+
*
26+
* This component renders Markdown content with interactive checkboxes for task lists.
27+
* When a checkbox is clicked, the underlying Markdown content is updated to reflect
28+
* the new state (checked/unchecked).
29+
*
30+
* @param content The Markdown content to render
31+
* @param onContentChange Callback when content changes (e.g., checkbox state changes)
32+
* @param modifier Modifier for the component
33+
*/
34+
@Composable
35+
fun InteractiveMarkdown(
36+
content: String,
37+
onContentChange: (String) -> Unit,
38+
modifier: Modifier = Modifier
39+
) {
40+
val segments = remember(content) {
41+
parseMarkdownSegments(content)
42+
}
43+
44+
val checkboxStates = remember(content) {
45+
segments.mapNotNull { segment ->
46+
(segment as? MarkdownSegment.CheckboxItem)?.let { mutableStateOf(it.isChecked) }
47+
}.toMutableList()
48+
}
49+
50+
Column(modifier = modifier.fillMaxWidth()) {
51+
var checkboxIndex = 0
52+
53+
segments.forEach { segment ->
54+
when (segment) {
55+
is MarkdownSegment.MarkdownText -> {
56+
if (segment.content.isNotBlank()) {
57+
Markdown(
58+
content = segment.content,
59+
imageTransformer = Coil3ImageTransformerImpl,
60+
modifier = Modifier.fillMaxWidth()
61+
)
62+
}
63+
}
64+
65+
is MarkdownSegment.CheckboxItem -> {
66+
val currentCheckboxIndex = checkboxIndex++
67+
InteractiveCheckboxItem(
68+
isChecked = checkboxStates[currentCheckboxIndex].value,
69+
text = segment.text,
70+
onCheckedChange = { newState ->
71+
checkboxStates[currentCheckboxIndex].value = newState
72+
onContentChange(updateCheckboxState(content, segment.lineIndex, newState))
73+
}
74+
)
75+
}
76+
}
77+
}
78+
}
79+
}
80+
81+
private val checkboxLineRegex = Regex("""^(\s*)-\s*\[([ xX])]\s*(.*)$""")
82+
83+
sealed interface MarkdownSegment {
84+
data class MarkdownText(val content: String) : MarkdownSegment
85+
data class CheckboxItem(
86+
val lineIndex: Int,
87+
val text: String,
88+
val isChecked: Boolean
89+
) : MarkdownSegment
90+
}
91+
92+
fun parseMarkdownSegments(content: String): List<MarkdownSegment> {
93+
if (content.isEmpty()) return emptyList()
94+
95+
val segments = mutableListOf<MarkdownSegment>()
96+
val markdownLines = mutableListOf<String>()
97+
98+
fun flushMarkdownLines() {
99+
if (markdownLines.isEmpty()) return
100+
segments.add(MarkdownSegment.MarkdownText(markdownLines.joinToString("\n")))
101+
markdownLines.clear()
102+
}
103+
104+
content.split('\n').forEachIndexed { index, line ->
105+
val match = checkboxLineRegex.matchEntire(line)
106+
if (match != null) {
107+
flushMarkdownLines()
108+
segments.add(
109+
MarkdownSegment.CheckboxItem(
110+
lineIndex = index,
111+
text = match.groupValues[3],
112+
isChecked = match.groupValues[2].equals("x", ignoreCase = true)
113+
)
114+
)
115+
} else {
116+
markdownLines.add(line)
117+
}
118+
}
119+
120+
flushMarkdownLines()
121+
122+
return segments
123+
}
124+
125+
fun updateCheckboxState(
126+
originalContent: String,
127+
lineIndex: Int,
128+
newState: Boolean
129+
): String {
130+
val lines = originalContent.split('\n').toMutableList()
131+
if (lineIndex !in lines.indices) return originalContent
132+
133+
val match = checkboxLineRegex.matchEntire(lines[lineIndex]) ?: return originalContent
134+
val indentation = match.groupValues[1]
135+
val textContent = match.groupValues[3]
136+
lines[lineIndex] = "$indentation- [${if (newState) "x" else " "}] $textContent"
137+
138+
return lines.joinToString("\n")
139+
}
140+
141+
/**
142+
* Custom composable for rendering interactive checkboxes
143+
*/
144+
@Composable
145+
fun InteractiveCheckboxItem(
146+
isChecked: Boolean,
147+
text: String,
148+
onCheckedChange: (Boolean) -> Unit,
149+
modifier: Modifier = Modifier
150+
) {
151+
Row(
152+
verticalAlignment = Alignment.CenterVertically,
153+
modifier = modifier
154+
.clickable { onCheckedChange(!isChecked) }
155+
.padding(vertical = 4.dp, horizontal = 8.dp)
156+
.fillMaxWidth()
157+
) {
158+
Icon(
159+
imageVector = if (isChecked) Icons.Filled.CheckBox else Icons.Filled.CheckBoxOutlineBlank,
160+
contentDescription = if (isChecked) "Checked" else "Unchecked",
161+
tint = MaterialTheme.colorScheme.primary,
162+
modifier = Modifier.padding(end = 8.dp)
163+
)
164+
Text(
165+
text = text,
166+
style = MaterialTheme.typography.bodyMedium.copy(
167+
color = MaterialTheme.colorScheme.onSurface
168+
)
169+
)
170+
}
171+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package at.techbee.jtx.ui.reusable.components
2+
3+
import androidx.compose.foundation.layout.Column
4+
import androidx.compose.foundation.layout.fillMaxWidth
5+
import androidx.compose.foundation.layout.padding
6+
import androidx.compose.material3.Text
7+
import androidx.compose.runtime.Composable
8+
import androidx.compose.runtime.mutableStateOf
9+
import androidx.compose.runtime.remember
10+
import androidx.compose.ui.Modifier
11+
import androidx.compose.ui.tooling.preview.Preview
12+
import androidx.compose.ui.unit.dp
13+
14+
@Preview(showBackground = true)
15+
@Composable
16+
fun InteractiveMarkdownPreview() {
17+
val markdownContent = remember { mutableStateOf(
18+
"""
19+
# Task List
20+
21+
Here are some tasks:
22+
23+
- [ ] Buy groceries
24+
- [x] Finish report
25+
- [ ] Call client
26+
27+
Some regular text here.
28+
29+
- [ ] Another task
30+
""".trimIndent()
31+
)}
32+
33+
Column(
34+
modifier = Modifier
35+
.fillMaxWidth()
36+
.padding(16.dp)
37+
) {
38+
Text("Interactive Markdown Checklist:", modifier = Modifier.padding(bottom = 8.dp))
39+
40+
InteractiveMarkdown(
41+
content = markdownContent.value,
42+
onContentChange = { updatedContent ->
43+
markdownContent.value = updatedContent
44+
}
45+
)
46+
47+
Text("Current content:", modifier = Modifier.padding(top = 16.dp, bottom = 4.dp))
48+
Text(markdownContent.value, modifier = Modifier.padding(8.dp))
49+
}
50+
}

0 commit comments

Comments
 (0)