milestone 5: tag chips, edit-before-sync, share target, last synced, sync interval

- CaptureScreen: FlowRow of FilterChip from recent tags; tap to toggle tag in/out
- CaptureViewModel: recentTags flow (flat deduplicated from last 100 captures), lastSyncedAt, toggleTag()
- HistoryScreen: tap to expand rows, edit dialog for pending/failed, last synced in TopAppBar subtitle
- HistoryViewModel: lastSyncedAt flow, updateBody()
- CaptureDao: getLastSyncedAt(), getRecentTagsJson() (from prior commit), updateBody()
- SettingsScreen: sync interval field (number, min 15), SynqSettings.syncIntervalMinutes
- SettingsViewModel: reschedules WorkManager on save with new interval
- SyncWorker: schedule() takes intervalMinutes param, uses REPLACE policy
- AndroidManifest: ACTION_SEND text/plain intent-filter on MainActivity
- MainActivity: extracts EXTRA_TEXT on share, passes as URL-encoded nav arg to CaptureScreen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 15:16:42 -04:00
parent fd28a45cd7
commit 66155f6141
12 changed files with 324 additions and 23 deletions

View File

@@ -19,6 +19,11 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
</application>

View File

@@ -1,33 +1,56 @@
package me.hgsky.synq
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import me.hgsky.synq.ui.capture.CaptureScreen
import me.hgsky.synq.ui.history.HistoryScreen
import me.hgsky.synq.ui.settings.SettingsScreen
import me.hgsky.synq.ui.theme.SynqTheme
import java.net.URLDecoder
import java.net.URLEncoder
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val sharedText = if (intent.action == Intent.ACTION_SEND)
intent.getStringExtra(Intent.EXTRA_TEXT) else null
setContent {
SynqTheme { SynqNav() }
SynqTheme { SynqNav(sharedText = sharedText) }
}
}
}
@Composable
private fun SynqNav() {
private fun SynqNav(sharedText: String? = null) {
val nav = rememberNavController()
NavHost(navController = nav, startDestination = "capture") {
val startRoute = if (sharedText != null) {
"capture/${URLEncoder.encode(sharedText, "UTF-8")}"
} else {
"capture"
}
NavHost(navController = nav, startDestination = startRoute) {
composable("capture") { CaptureScreen(nav) }
composable(
route = "capture/{prefill}",
arguments = listOf(navArgument("prefill") { type = NavType.StringType }),
) { back ->
val prefill = back.arguments?.getString("prefill")
?.let { URLDecoder.decode(it, "UTF-8") }
CaptureScreen(nav, prefill = prefill)
}
composable("history") { HistoryScreen(nav) }
composable("settings") { SettingsScreen(nav) }
}

View File

@@ -2,6 +2,7 @@ package me.hgsky.synq.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
@@ -13,6 +14,7 @@ data class SynqSettings(
val serverUrl: String = "http://jeeves.mother:8765",
val token: String = "",
val deviceLabel: String = "android",
val syncIntervalMinutes: Int = 15,
)
class SettingsRepository(private val context: Context) {
@@ -20,12 +22,14 @@ class SettingsRepository(private val context: Context) {
private val KEY_URL = stringPreferencesKey("server_url")
private val KEY_TOKEN = stringPreferencesKey("token")
private val KEY_DEVICE = stringPreferencesKey("device_label")
private val KEY_INTERVAL = intPreferencesKey("sync_interval_minutes")
val settings: Flow<SynqSettings> = context.dataStore.data.map { prefs ->
SynqSettings(
serverUrl = prefs[KEY_URL] ?: "http://jeeves.mother:8765",
token = prefs[KEY_TOKEN] ?: "",
deviceLabel = prefs[KEY_DEVICE] ?: "android",
syncIntervalMinutes = prefs[KEY_INTERVAL] ?: 15,
)
}
@@ -34,6 +38,7 @@ class SettingsRepository(private val context: Context) {
prefs[KEY_URL] = settings.serverUrl
prefs[KEY_TOKEN] = settings.token
prefs[KEY_DEVICE] = settings.deviceLabel
prefs[KEY_INTERVAL] = settings.syncIntervalMinutes
}
}
}

View File

@@ -27,4 +27,13 @@ interface CaptureDao {
@Query("UPDATE captures SET status = 'synced', syncedAt = :syncedAt, lastError = NULL WHERE id = :id")
suspend fun markSynced(id: String, syncedAt: String)
@Query("SELECT MAX(syncedAt) FROM captures WHERE syncedAt IS NOT NULL")
fun getLastSyncedAt(): Flow<String?>
@Query("SELECT tagsJson FROM captures WHERE tagsJson != '[]' AND tagsJson != '' ORDER BY createdAt DESC LIMIT 100")
fun getRecentTagsJson(): Flow<List<String>>
@Query("UPDATE captures SET body = :body WHERE id = :id")
suspend fun updateBody(id: String, body: String)
}

View File

@@ -29,18 +29,20 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
companion object {
private const val WORK_NAME = "synq_periodic_sync"
fun schedule(context: Context) {
fun schedule(context: Context, intervalMinutes: Int = 15) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
val request = PeriodicWorkRequestBuilder<SyncWorker>(
intervalMinutes.toLong().coerceAtLeast(15), TimeUnit.MINUTES,
)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
ExistingPeriodicWorkPolicy.REPLACE,
request,
)
}

View File

@@ -1,8 +1,11 @@
package me.hgsky.synq.ui.capture
import androidx.activity.ComponentActivity
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -10,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.History
@@ -18,6 +22,7 @@ import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -41,15 +46,40 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun CaptureScreen(nav: NavController, vm: CaptureViewModel = viewModel()) {
fun CaptureScreen(
nav: NavController,
prefill: String? = null,
vm: CaptureViewModel = viewModel(),
) {
val state by vm.state.collectAsState()
val recentTags by vm.recentTags.collectAsState()
val lastSyncedAt by vm.lastSyncedAt.collectAsState()
val focusRequester = remember { FocusRequester() }
val context = LocalContext.current
LaunchedEffect(Unit) { focusRequester.requestFocus() }
LaunchedEffect(prefill) {
if (!prefill.isNullOrEmpty()) vm.setBody(prefill)
}
val activeTags = remember(state.tags) {
state.tags.split(Regex("[,\\s]+")).filter { it.isNotEmpty() }.toSet()
}
val lastSyncedText = remember(lastSyncedAt) {
lastSyncedAt?.let { iso ->
runCatching {
val dt = OffsetDateTime.parse(iso)
"synced " + dt.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT))
}.getOrNull()
}
}
Scaffold(
topBar = {
@@ -120,6 +150,29 @@ fun CaptureScreen(nav: NavController, vm: CaptureViewModel = viewModel()) {
singleLine = true,
)
if (recentTags.isNotEmpty()) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
recentTags.forEach { tag ->
FilterChip(
selected = tag in activeTags,
onClick = { vm.toggleTag(tag) },
label = { Text(tag, style = MaterialTheme.typography.labelSmall) },
)
}
}
}
if (lastSyncedText != null) {
Text(
lastSyncedText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),

View File

@@ -5,9 +5,12 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@@ -34,10 +37,31 @@ class CaptureViewModel(app: Application) : AndroidViewModel(app) {
private val _state = MutableStateFlow(CaptureUiState())
val state: StateFlow<CaptureUiState> = _state.asStateFlow()
val recentTags: StateFlow<List<String>> = dao.getRecentTagsJson()
.map { jsonList ->
val seen = LinkedHashSet<String>()
for (json in jsonList) {
runCatching { Json.decodeFromString<List<String>>(json) }
.getOrElse { emptyList() }
.forEach { seen.add(it) }
}
seen.take(20).toList()
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val lastSyncedAt: StateFlow<String?> = dao.getLastSyncedAt()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
fun setBody(v: String) { _state.value = _state.value.copy(body = v) }
fun setTodo(v: Boolean) { _state.value = _state.value.copy(isTodo = v) }
fun setTags(v: String) { _state.value = _state.value.copy(tags = v) }
fun toggleTag(tag: String) {
val current = parseTags(_state.value.tags).toMutableList()
if (current.contains(tag)) current.remove(tag) else current.add(tag)
_state.value = _state.value.copy(tags = current.joinToString(" "))
}
fun save(onDone: () -> Unit = {}) {
val s = _state.value
val body = s.body.trim()

View File

@@ -1,5 +1,6 @@
package me.hgsky.synq.ui.history
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -12,18 +13,26 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -32,16 +41,40 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import me.hgsky.synq.data.db.CaptureEntity
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryScreen(nav: NavController, vm: HistoryViewModel = viewModel()) {
val captures by vm.captures.collectAsState(initial = emptyList())
val lastSyncedAt by vm.lastSyncedAt.collectAsState()
val lastSyncedText = remember(lastSyncedAt) {
lastSyncedAt?.let { iso ->
runCatching {
val dt = OffsetDateTime.parse(iso)
"last synced " + dt.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT))
}.getOrNull()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("history") },
title = {
Column {
Text("history")
if (lastSyncedText != null) {
Text(
lastSyncedText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = {
IconButton(onClick = { nav.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "back")
@@ -62,7 +95,11 @@ fun HistoryScreen(nav: NavController, vm: HistoryViewModel = viewModel()) {
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(captures, key = { it.id }) { capture ->
CaptureRow(capture, onRetry = { vm.retryCapture(capture) })
CaptureRow(
capture = capture,
onRetry = { vm.retryCapture(capture) },
onEditConfirm = { newBody -> vm.updateBody(capture, newBody) },
)
}
}
}
@@ -70,14 +107,56 @@ fun HistoryScreen(nav: NavController, vm: HistoryViewModel = viewModel()) {
}
@Composable
private fun CaptureRow(capture: CaptureEntity, onRetry: () -> Unit) {
private fun CaptureRow(
capture: CaptureEntity,
onRetry: () -> Unit,
onEditConfirm: (String) -> Unit,
) {
var expanded by rememberSaveable(capture.id) { mutableStateOf(false) }
var showEdit by remember { mutableStateOf(false) }
var editBody by remember(capture.body) { mutableStateOf(capture.body) }
val statusColor = when (capture.status) {
"synced" -> Color(0xFF2E7D32)
"failed" -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Card(modifier = Modifier.fillMaxWidth()) {
val editable = capture.status == "pending" || capture.status == "failed"
if (showEdit) {
AlertDialog(
onDismissRequest = { showEdit = false },
title = { Text("edit capture") },
text = {
OutlinedTextField(
value = editBody,
onValueChange = { editBody = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("body") },
minLines = 3,
)
},
confirmButton = {
Button(
onClick = {
onEditConfirm(editBody)
showEdit = false
},
enabled = editBody.isNotBlank(),
) { Text("save") }
},
dismissButton = {
TextButton(onClick = { showEdit = false }) { Text("cancel") }
},
)
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
@@ -96,18 +175,29 @@ private fun CaptureRow(capture: CaptureEntity, onRetry: () -> Unit) {
style = MaterialTheme.typography.labelSmall,
color = statusColor,
)
if (capture.status == "pending" || capture.status == "failed") {
Button(onClick = onRetry, contentPadding = PaddingValues(horizontal = 8.dp)) {
if (editable) {
OutlinedButton(
onClick = onRetry,
contentPadding = PaddingValues(horizontal = 8.dp),
) {
Text("retry", style = MaterialTheme.typography.labelSmall)
}
}
if (editable && expanded) {
OutlinedButton(
onClick = { showEdit = true },
contentPadding = PaddingValues(horizontal = 8.dp),
) {
Text("edit", style = MaterialTheme.typography.labelSmall)
}
}
}
Text(
capture.body,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
maxLines = if (expanded) Int.MAX_VALUE else 2,
overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis,
)
Text(

View File

@@ -4,6 +4,9 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import me.hgsky.synq.SynqApp
import me.hgsky.synq.data.db.CaptureEntity
@@ -14,9 +17,18 @@ class HistoryViewModel(app: Application) : AndroidViewModel(app) {
val captures: Flow<List<CaptureEntity>> = dao.observeAll()
val lastSyncedAt: StateFlow<String?> = dao.getLastSyncedAt()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
fun retryCapture(capture: CaptureEntity) {
viewModelScope.launch {
dao.updateStatus(capture.id, "pending", null)
}
}
fun updateBody(capture: CaptureEntity, newBody: String) {
val trimmed = newBody.trim()
if (trimmed.isEmpty()) return
viewModelScope.launch { dao.updateBody(capture.id, trimmed) }
}
}

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
@@ -29,6 +30,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
@@ -43,9 +45,17 @@ fun SettingsScreen(nav: NavController, vm: SettingsViewModel = viewModel()) {
var url by remember(saved.serverUrl) { mutableStateOf(saved.serverUrl) }
var token by remember(saved.token) { mutableStateOf(saved.token) }
var device by remember(saved.deviceLabel) { mutableStateOf(saved.deviceLabel) }
var interval by remember(saved.syncIntervalMinutes) { mutableStateOf(saved.syncIntervalMinutes.toString()) }
fun buildSettings() = SynqSettings(
serverUrl = url.trim(),
token = token.trim(),
deviceLabel = device.trim().ifEmpty { "android" },
syncIntervalMinutes = interval.toIntOrNull()?.coerceAtLeast(15) ?: 15,
)
fun saveAndBack() {
vm.save(SynqSettings(url.trim(), token.trim(), device.trim().ifEmpty { "android" }))
vm.save(buildSettings())
nav.navigateUp()
}
@@ -89,6 +99,15 @@ fun SettingsScreen(nav: NavController, vm: SettingsViewModel = viewModel()) {
label = { Text("device label") },
singleLine = true,
)
OutlinedTextField(
value = interval,
onValueChange = { v -> if (v.length <= 4 && v.all { it.isDigit() }) interval = v },
modifier = Modifier.fillMaxWidth(),
label = { Text("sync interval (minutes, min 15)") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
supportingText = { Text("WorkManager minimum is 15 min") },
)
Row(
modifier = Modifier.fillMaxWidth(),
@@ -97,7 +116,7 @@ fun SettingsScreen(nav: NavController, vm: SettingsViewModel = viewModel()) {
) {
OutlinedButton(
onClick = {
vm.save(SynqSettings(url.trim(), token.trim(), device.trim().ifEmpty { "android" }))
vm.save(buildSettings())
vm.checkConnection(url.trim())
},
modifier = Modifier.weight(1f),

View File

@@ -13,6 +13,7 @@ import kotlinx.coroutines.launch
import me.hgsky.synq.SynqApp
import me.hgsky.synq.data.SynqSettings
import me.hgsky.synq.data.api.SynqApiClient
import me.hgsky.synq.data.sync.SyncWorker
sealed class PingState {
object Idle : PingState()
@@ -23,7 +24,8 @@ sealed class PingState {
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
private val repo = (app as SynqApp).settings
private val synqApp = app as SynqApp
private val repo = synqApp.settings
val settings = repo.settings.stateIn(
viewModelScope,
@@ -35,7 +37,10 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
val ping: StateFlow<PingState> = _ping.asStateFlow()
fun save(settings: SynqSettings) {
viewModelScope.launch { repo.save(settings) }
viewModelScope.launch {
repo.save(settings)
SyncWorker.schedule(synqApp, settings.syncIntervalMinutes)
}
}
fun checkConnection(url: String) {