Compare commits

...

4 Commits

Author SHA1 Message Date
fb2474ba21 readme: add build & deploy section (server docker, android APK, gitea release) 2026-05-18 15:46:49 -04:00
3c585c2f6b fix tasks.org: record real commit hash 66155f6 for milestone 5 2026-05-18 15:26:45 -04:00
37d424ef60 resolve merge conflict: take worktree milestone 5 DONE state, keep user title
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 15:20:22 -04:00
66155f6141 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>
2026-05-18 15:16:42 -04:00
13 changed files with 389 additions and 44 deletions

View File

@@ -156,6 +156,62 @@ synq/
5. add history screen and resend handling.
6. polish launch speed and widget/share-target only after core path works.
## build & deploy
### server (docker)
```bash
# build
docker build -t synq-server ./server
# run (replace paths and token as needed)
docker run -d --name synq --restart=unless-stopped \
-p 8765:8765 \
-v /mnt/user/synq/data:/data \
synq-server
```
the server generates a random token on first start and logs it prominently. copy it into the android app settings. to use a fixed token instead, pass `-e PHONE_CAPTURE_TOKEN=yourtoken`.
to rebuild after a `git pull`:
```bash
git pull
docker build -t synq-server ./server
docker stop synq && docker rm synq
# re-run the docker run command above
```
### android
open `android/` in Android Studio and hit **Sync**. then either:
- **run on device/emulator directly** from Android Studio (▶), or
- **build a release APK** from the terminal:
```bash
cd android
./gradlew assembleRelease
# output: app/build/outputs/apk/release/app-release-unsigned.apk
```
sideload to a connected device:
```bash
adb install app/build/outputs/apk/release/app-release-unsigned.apk
```
### releasing on gitea
tag the commit and push the tag:
```bash
git tag v1.0.0
git push gitea v1.0.0
```
then go to **Releases → New Release** in the Gitea UI, pick the tag, and drag the APK into the assets box. anyone on the LAN can download it from there.
## backup
### what to back up

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) {

View File

@@ -395,39 +395,81 @@ logger.info for accepted and duplicate (id only, no body). logger.warning for in
*** notes
Added "backup" section to README.md covering synq.org, capture.sqlite3, token.txt. Restore section documents behavior when each file is lost individually.
*** evidence
- commit: (this commit)
- commit: 66155f6
- tests: n/a
- datetime: [2026-05-18 Sun 19:00]
* milestone 5: optional v1.5 improvements
** TODO add edit-before-sync
*** acceptance
- pending/failed captures can be edited.
- synced captures are read-only unless explicitly duplicated.
** TODO tap to expand History item
- in History, tap to expand/contract each item
** TODO add "Last synced" display
*** acceptance
- app displays "last synced at" somewhere, maybe in Settings and History?
- "next sync at []" on History (and homepage?)
** TODO add settings for sync frequency
*** acceptance
- let user specify poll rate, in minutes
** TODO add recent tag chips
*** acceptance
- app shows recent tags.
- tapping chip adds/removes tag.
** TODO add android share target
** DONE add android share target
*** acceptance
- sharing text to app opens prefilled capture.
- user can save as note or todo.
*** notes
ACTION_SEND text/plain intent-filter added to MainActivity in manifest. MainActivity.onCreate extracts EXTRA_TEXT and passes it as a URL-encoded nav argument to the capture/{prefill} route. CaptureScreen applies it via LaunchedEffect on first composition.
*** evidence
- commit: 66155f6
- tests: manual — share any text from browser/notes to synq, verify it pre-fills capture body
- datetime: [2026-05-18 Sun 20:00]
** TODO add home screen quick capture widget
*** acceptance
- widget opens capture screen quickly.
- does not require sync to work.
** DONE add recent tag chips
*** acceptance
- app shows recent tags.
- tapping chip adds/removes tag.
*** notes
CaptureViewModel.recentTags collects the last 100 capture tagsJson rows, decodes each JSON array, flattens and deduplicates (LinkedHashSet order), limits to 20. CaptureScreen shows a FlowRow of FilterChip below the tags text field. Tapping a chip calls toggleTag() which adds/removes it from the tags string. Active chips are highlighted via FilterChip selected=true.
*** evidence
- commit: 66155f6
- tests: manual — save a few captures with tags, open capture screen, verify chips appear and toggle
- datetime: [2026-05-18 Sun 20:00]
** DONE add edit-before-sync
*** acceptance
- pending/failed captures can be edited.
- synced captures are read-only unless explicitly duplicated.
*** notes
CaptureDao.updateBody() new query. HistoryViewModel.updateBody() calls it. HistoryScreen CaptureRow shows "edit" OutlinedButton only when expanded AND status is pending/failed. Tapping edit opens an AlertDialog with an OutlinedTextField pre-filled with current body. Save calls updateBody; cancel dismisses.
*** evidence
- commit: 66155f6
- tests: manual — save a capture, open history, expand row, tap edit, change body, save, verify updated
- datetime: [2026-05-18 Sun 20:00]
** DONE tap to expand history item
*** acceptance
- tapping a history row expands/collapses it.
- expanded row shows full body.
- edit button is only visible when expanded.
*** notes
CaptureRow uses rememberSaveable expanded: Boolean keyed on capture.id. Card has clickable { expanded = !expanded }. maxLines switches between 2 and Int.MAX_VALUE. Edit button only renders when expanded && editable.
*** evidence
- commit: 66155f6
- tests: manual — tap history rows to expand/collapse, verify full body visible when expanded
- datetime: [2026-05-18 Sun 20:00]
** DONE add "Last synced" display
*** acceptance
- "last synced at" visible in history top bar and capture screen.
*** notes
CaptureDao.getLastSyncedAt() returns Flow<String?> of MAX(syncedAt). HistoryViewModel and CaptureViewModel both stateIn this flow. HistoryScreen shows it as a subtitle under "history" in the TopAppBar. CaptureScreen shows it as a small label above the save buttons. Both parse the ISO string via OffsetDateTime and format with DateTimeFormatter.ofLocalizedDateTime(SHORT).
*** evidence
- commit: 66155f6
- tests: manual — sync a capture, verify timestamp appears in history header and capture screen
- datetime: [2026-05-18 Sun 20:00]
** DONE add settings for sync frequency
*** acceptance
- user-specified poll rate in minutes (min 15).
- changing interval reschedules WorkManager immediately.
*** notes
SynqSettings gained syncIntervalMinutes: Int = 15 field with DataStore key sync_interval_minutes. SettingsScreen added OutlinedTextField with number keyboard for interval, shows supporting text "WorkManager minimum is 15 min". buildSettings() coerces to max(value, 15). SettingsViewModel.save() now also calls SyncWorker.schedule(app, settings.syncIntervalMinutes). SyncWorker.schedule() uses REPLACE policy so new interval takes effect immediately.
*** evidence
- commit: 66155f6
- tests: manual — change interval to 30, save, verify WorkManager re-queues (check adb shell dumpsys jobscheduler)
- datetime: [2026-05-18 Sun 20:00]
* implementation notes for coding agents
- keep the server small and testable.