Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
05efc94
feat(deck): add listen/speak deck options and web image refs to schema
jvsena42 Jun 16, 2026
b5a067c
feat(import): add review-cards triage step and route create-deck to p…
jvsena42 Jun 16, 2026
bfe325e
feat(media): Unsplash web image search, gallery picker + compression,…
jvsena42 Jun 16, 2026
c01844c
feat(deck): wire cover image, Listen/Speak toggles, and card front image
jvsena42 Jun 17, 2026
b59b699
feat(study): Speak pronunciation practice with on-device speech recog…
jvsena42 Jun 17, 2026
046be9d
test(journeys): triage/image/speak journeys + surface sheet test tags
jvsena42 Jun 17, 2026
e4eda8d
design: remove speak button from front card
jvsena42 Jun 17, 2026
f01ffb1
fix(study): correct Listen/Speak labels, request mic permission, feed…
jvsena42 Jun 17, 2026
91b86a0
feat(publish): match New-deck design — peach badge, solid fields, opt…
jvsena42 Jun 17, 2026
a565dc2
feat(import): add bottom Next button to paste preview (design MJ1SR)
jvsena42 Jun 17, 2026
b831f15
feat(deck): make Edit-card Save an orange pill button (design vU2cv)
jvsena42 Jun 17, 2026
f635086
feat(media): make image-picker Done a filled pill, disabled until a s…
jvsena42 Jun 17, 2026
4fdde8d
test(journeys): record Listen/Speak fix + design-polish verification run
jvsena42 Jun 17, 2026
187d275
design: fix label
jvsena42 Jun 17, 2026
066f199
fix(publish): replace cover-change emoji with a Material image icon
jvsena42 Jun 17, 2026
c81d65b
feat(import): attach front/back card images in the paste/triage creat…
jvsena42 Jun 17, 2026
0e2f603
feat(deck): support front and back card images in Edit Card
jvsena42 Jun 17, 2026
3d9c096
refactor(deck): match Edit Card image control to design (card + Add-i…
jvsena42 Jun 17, 2026
c8eac78
style(deck): reduce Edit Card side-card elevation (6dp -> 2dp)
jvsena42 Jun 17, 2026
ab21319
style(deck): center top-bar titles across the deck-creation flow
jvsena42 Jun 17, 2026
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
14 changes: 14 additions & 0 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties

plugins {
alias(libs.plugins.kotlinMultiplatform)
Expand All @@ -21,6 +22,8 @@ kotlin {
implementation(libs.androidx.activity.compose)
implementation(libs.koin.android)
implementation(libs.play.services.code.scanner)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
}
commonMain.dependencies {
implementation(libs.compose.runtime)
Expand Down Expand Up @@ -53,6 +56,17 @@ android {
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0"

// Unsplash key for the "from web" image search; blank → gallery-only fallback.
val localProps = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) file.inputStream().use { load(it) }
}
val unsplashKey = (localProps.getProperty("UNSPLASH_ACCESS_KEY") ?: "").trim()
buildConfigField("String", "UNSPLASH_ACCESS_KEY", "\"$unsplashKey\"")
}
buildFeatures {
buildConfig = true
}
packaging {
resources {
Expand Down
8 changes: 8 additions & 0 deletions composeApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<!-- Speak pronunciation practice (study) records audio for on-device speech recognition.
Gallery image picking uses the system photo picker, which needs no storage permission. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />

<!-- Android 11+ package visibility: without this, resolveActivity() returns null
for the `pubkyauth://` deeplink even when Pubky Ring is installed. -->
<queries>
Expand All @@ -16,6 +20,10 @@
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
<!-- Visibility for the on-device speech recognition service (Speak practice). -->
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
</queries>

<application
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import org.koin.android.ext.koin.androidLogger
class EchoApp : Application() {
override fun onCreate() {
super.onCreate()
initKoinAndroid {
initKoinAndroid(unsplashAccessKey = BuildConfig.UNSPLASH_ACCESS_KEY) {
androidLogger()
androidContext(this@EchoApp)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.github.jvsena42.echo.ui.components

import androidx.compose.foundation.layout.Box
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.github.jvsena42.echo.data.repository.MediaRepository
import com.github.jvsena42.echo.domain.model.MediaRef
import org.koin.compose.koinInject

/**
* Renders a card/cover [MediaRef.Image]. Remote (web) images load directly from their URL via
* Coil; blob images are fetched from the homeserver through [MediaRepository] and rendered from
* their decoded bytes (also via Coil, which decodes the ByteArray).
*/
@Composable
fun CardMediaImage(
image: MediaRef.Image,
deckId: String,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop,
) {
if (image.isRemote) {
AsyncImage(
model = image.url,
contentDescription = null,
modifier = modifier,
contentScale = contentScale,
)
return
}

val mediaRepository = koinInject<MediaRepository>()
val bytes by produceState<ByteArray?>(initialValue = null, image.sha256, deckId) {
value = mediaRepository.get(deckId, image).getOrNull()
}
val data = bytes
if (data == null) {
Box(modifier, contentAlignment = Alignment.Center) {
CircularProgressIndicator(strokeWidth = 2.dp)
}
} else {
AsyncImage(
model = data,
contentDescription = null,
modifier = modifier,
contentScale = contentScale,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package com.github.jvsena42.echo.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.github.jvsena42.echo.R
import com.github.jvsena42.echo.ui.theme.EchoTheme

/**
* One side of a card in the Edit-card screens (design `vU2cv`): a white rounded card holding the
* text input, with the side label and an "Add image" pill in the header. When an image is set the
* pill is replaced by a thumbnail preview (tap to change) plus a circular remove button.
*
* Shared by the post-publish editor ([com.github.jvsena42.echo.ui.decks.EditCardScreen]) and the
* paste/triage editor so both match the design. [onSpeak] adds a TTS icon when non-null.
*/
@Composable
fun CardSideEditor(
label: String,
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
textStyle: TextStyle,
imageModel: Any?,
onPickImage: () -> Unit,
onRemoveImage: () -> Unit,
imageTag: String,
fieldTag: String,
modifier: Modifier = Modifier,
error: String? = null,
onSpeak: (() -> Unit)? = null,
speakDescription: String? = null,
) {
val colors = EchoTheme.colors
Column(
modifier = modifier
.fillMaxWidth()
.shadow(2.dp, RoundedCornerShape(20.dp))
.clip(RoundedCornerShape(20.dp))
.background(colors.surfaceCard)
.border(1.5.dp, colors.borderSubtle, RoundedCornerShape(20.dp))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
fontSize = 10.sp,
fontWeight = FontWeight.W700,
letterSpacing = 1.sp,
color = colors.foregroundMuted,
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (onSpeak != null) {
Icon(
imageVector = Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = speakDescription,
tint = colors.accentPrimary,
modifier = Modifier
.clip(CircleShape)
.clickable(onClick = onSpeak)
.padding(4.dp)
.size(18.dp),
)
}
if (imageModel == null) {
AddImagePill(onClick = onPickImage, tag = "${imageTag}_add")
} else {
ImagePreview(
model = imageModel,
onChange = onPickImage,
onRemove = onRemoveImage,
tag = imageTag,
)
}
}
}

BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.testTag(fieldTag),
textStyle = textStyle.copy(color = colors.foregroundPrimary),
cursorBrush = SolidColor(colors.accentPrimary),
decorationBox = { inner ->
Box {
if (value.isEmpty()) {
Text(text = placeholder, style = textStyle, color = colors.foregroundMuted)
}
inner()
}
},
)

error?.let { Text(text = it, fontSize = 12.sp, color = colors.danger) }
}
}

@Composable
private fun AddImagePill(onClick: () -> Unit, tag: String) {
val colors = EchoTheme.colors
Row(
modifier = Modifier
.testTag(tag)
.clip(RoundedCornerShape(50))
.background(colors.accentPrimarySoft)
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.Image, null, tint = colors.accentPrimary, modifier = Modifier.size(12.dp))
Text(
text = stringResource(R.string.edit_card_add_image),
fontSize = 11.sp,
fontWeight = FontWeight.W700,
color = colors.accentPrimary,
)
}
}

@Composable
private fun ImagePreview(model: Any, onChange: () -> Unit, onRemove: () -> Unit, tag: String) {
val colors = EchoTheme.colors
Row(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(colors.surfaceSecondary)
.padding(PaddingValues(start = 4.dp, top = 4.dp, bottom = 4.dp, end = 8.dp)),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = model,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.testTag("${tag}_chip")
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
.border(1.5.dp, colors.accentPrimary, RoundedCornerShape(8.dp))
.clickable(onClick = onChange),
)
Box(
modifier = Modifier
.testTag("${tag}_remove")
.size(24.dp)
.clip(CircleShape)
.background(colors.surfaceCard)
.clickable(onClick = onRemove),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Close, null, tint = colors.foregroundMuted, modifier = Modifier.size(14.dp))
}
}
}
Loading
Loading