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:
@@ -19,6 +19,11 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
@@ -1,33 +1,56 @@
|
|||||||
package me.hgsky.synq
|
package me.hgsky.synq
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
import me.hgsky.synq.ui.capture.CaptureScreen
|
import me.hgsky.synq.ui.capture.CaptureScreen
|
||||||
import me.hgsky.synq.ui.history.HistoryScreen
|
import me.hgsky.synq.ui.history.HistoryScreen
|
||||||
import me.hgsky.synq.ui.settings.SettingsScreen
|
import me.hgsky.synq.ui.settings.SettingsScreen
|
||||||
import me.hgsky.synq.ui.theme.SynqTheme
|
import me.hgsky.synq.ui.theme.SynqTheme
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
val sharedText = if (intent.action == Intent.ACTION_SEND)
|
||||||
|
intent.getStringExtra(Intent.EXTRA_TEXT) else null
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
SynqTheme { SynqNav() }
|
SynqTheme { SynqNav(sharedText = sharedText) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SynqNav() {
|
private fun SynqNav(sharedText: String? = null) {
|
||||||
val nav = rememberNavController()
|
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("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("history") { HistoryScreen(nav) }
|
||||||
composable("settings") { SettingsScreen(nav) }
|
composable("settings") { SettingsScreen(nav) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package me.hgsky.synq.data
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -13,6 +14,7 @@ data class SynqSettings(
|
|||||||
val serverUrl: String = "http://jeeves.mother:8765",
|
val serverUrl: String = "http://jeeves.mother:8765",
|
||||||
val token: String = "",
|
val token: String = "",
|
||||||
val deviceLabel: String = "android",
|
val deviceLabel: String = "android",
|
||||||
|
val syncIntervalMinutes: Int = 15,
|
||||||
)
|
)
|
||||||
|
|
||||||
class SettingsRepository(private val context: Context) {
|
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_URL = stringPreferencesKey("server_url")
|
||||||
private val KEY_TOKEN = stringPreferencesKey("token")
|
private val KEY_TOKEN = stringPreferencesKey("token")
|
||||||
private val KEY_DEVICE = stringPreferencesKey("device_label")
|
private val KEY_DEVICE = stringPreferencesKey("device_label")
|
||||||
|
private val KEY_INTERVAL = intPreferencesKey("sync_interval_minutes")
|
||||||
|
|
||||||
val settings: Flow<SynqSettings> = context.dataStore.data.map { prefs ->
|
val settings: Flow<SynqSettings> = context.dataStore.data.map { prefs ->
|
||||||
SynqSettings(
|
SynqSettings(
|
||||||
serverUrl = prefs[KEY_URL] ?: "http://jeeves.mother:8765",
|
serverUrl = prefs[KEY_URL] ?: "http://jeeves.mother:8765",
|
||||||
token = prefs[KEY_TOKEN] ?: "",
|
token = prefs[KEY_TOKEN] ?: "",
|
||||||
deviceLabel = prefs[KEY_DEVICE] ?: "android",
|
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_URL] = settings.serverUrl
|
||||||
prefs[KEY_TOKEN] = settings.token
|
prefs[KEY_TOKEN] = settings.token
|
||||||
prefs[KEY_DEVICE] = settings.deviceLabel
|
prefs[KEY_DEVICE] = settings.deviceLabel
|
||||||
|
prefs[KEY_INTERVAL] = settings.syncIntervalMinutes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,4 +27,13 @@ interface CaptureDao {
|
|||||||
|
|
||||||
@Query("UPDATE captures SET status = 'synced', syncedAt = :syncedAt, lastError = NULL WHERE id = :id")
|
@Query("UPDATE captures SET status = 'synced', syncedAt = :syncedAt, lastError = NULL WHERE id = :id")
|
||||||
suspend fun markSynced(id: String, syncedAt: String)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,18 +29,20 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
|
|||||||
companion object {
|
companion object {
|
||||||
private const val WORK_NAME = "synq_periodic_sync"
|
private const val WORK_NAME = "synq_periodic_sync"
|
||||||
|
|
||||||
fun schedule(context: Context) {
|
fun schedule(context: Context, intervalMinutes: Int = 15) {
|
||||||
val constraints = Constraints.Builder()
|
val constraints = Constraints.Builder()
|
||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val request = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
|
val request = PeriodicWorkRequestBuilder<SyncWorker>(
|
||||||
|
intervalMinutes.toLong().coerceAtLeast(15), TimeUnit.MINUTES,
|
||||||
|
)
|
||||||
.setConstraints(constraints)
|
.setConstraints(constraints)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||||
WORK_NAME,
|
WORK_NAME,
|
||||||
ExistingPeriodicWorkPolicy.KEEP,
|
ExistingPeriodicWorkPolicy.REPLACE,
|
||||||
request,
|
request,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package me.hgsky.synq.ui.capture
|
package me.hgsky.synq.ui.capture
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.History
|
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.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -41,15 +46,40 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavController
|
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
|
@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 state by vm.state.collectAsState()
|
||||||
|
val recentTags by vm.recentTags.collectAsState()
|
||||||
|
val lastSyncedAt by vm.lastSyncedAt.collectAsState()
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -120,6 +150,29 @@ fun CaptureScreen(nav: NavController, vm: CaptureViewModel = viewModel()) {
|
|||||||
singleLine = true,
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import androidx.lifecycle.AndroidViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -34,10 +37,31 @@ class CaptureViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
private val _state = MutableStateFlow(CaptureUiState())
|
private val _state = MutableStateFlow(CaptureUiState())
|
||||||
val state: StateFlow<CaptureUiState> = _state.asStateFlow()
|
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 setBody(v: String) { _state.value = _state.value.copy(body = v) }
|
||||||
fun setTodo(v: Boolean) { _state.value = _state.value.copy(isTodo = v) }
|
fun setTodo(v: Boolean) { _state.value = _state.value.copy(isTodo = v) }
|
||||||
fun setTags(v: String) { _state.value = _state.value.copy(tags = 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 = {}) {
|
fun save(onDone: () -> Unit = {}) {
|
||||||
val s = _state.value
|
val s = _state.value
|
||||||
val body = s.body.trim()
|
val body = s.body.trim()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package me.hgsky.synq.ui.history
|
package me.hgsky.synq.ui.history
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -32,16 +41,40 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import me.hgsky.synq.data.db.CaptureEntity
|
import me.hgsky.synq.data.db.CaptureEntity
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HistoryScreen(nav: NavController, vm: HistoryViewModel = viewModel()) {
|
fun HistoryScreen(nav: NavController, vm: HistoryViewModel = viewModel()) {
|
||||||
val captures by vm.captures.collectAsState(initial = emptyList())
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("history") },
|
title = {
|
||||||
|
Column {
|
||||||
|
Text("history")
|
||||||
|
if (lastSyncedText != null) {
|
||||||
|
Text(
|
||||||
|
lastSyncedText,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { nav.navigateUp() }) {
|
IconButton(onClick = { nav.navigateUp() }) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "back")
|
||||||
@@ -62,7 +95,11 @@ fun HistoryScreen(nav: NavController, vm: HistoryViewModel = viewModel()) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
items(captures, key = { it.id }) { capture ->
|
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
|
@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) {
|
val statusColor = when (capture.status) {
|
||||||
"synced" -> Color(0xFF2E7D32)
|
"synced" -> Color(0xFF2E7D32)
|
||||||
"failed" -> MaterialTheme.colorScheme.error
|
"failed" -> MaterialTheme.colorScheme.error
|
||||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
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(
|
Column(
|
||||||
modifier = Modifier.padding(12.dp),
|
modifier = Modifier.padding(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
@@ -96,18 +175,29 @@ private fun CaptureRow(capture: CaptureEntity, onRetry: () -> Unit) {
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = statusColor,
|
color = statusColor,
|
||||||
)
|
)
|
||||||
if (capture.status == "pending" || capture.status == "failed") {
|
if (editable) {
|
||||||
Button(onClick = onRetry, contentPadding = PaddingValues(horizontal = 8.dp)) {
|
OutlinedButton(
|
||||||
|
onClick = onRetry,
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||||
|
) {
|
||||||
Text("retry", style = MaterialTheme.typography.labelSmall)
|
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(
|
Text(
|
||||||
capture.body,
|
capture.body,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = 2,
|
maxLines = if (expanded) Int.MAX_VALUE else 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import android.app.Application
|
|||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
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 kotlinx.coroutines.launch
|
||||||
import me.hgsky.synq.SynqApp
|
import me.hgsky.synq.SynqApp
|
||||||
import me.hgsky.synq.data.db.CaptureEntity
|
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 captures: Flow<List<CaptureEntity>> = dao.observeAll()
|
||||||
|
|
||||||
|
val lastSyncedAt: StateFlow<String?> = dao.getLastSyncedAt()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
fun retryCapture(capture: CaptureEntity) {
|
fun retryCapture(capture: CaptureEntity) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
dao.updateStatus(capture.id, "pending", null)
|
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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -29,6 +30,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavController
|
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 url by remember(saved.serverUrl) { mutableStateOf(saved.serverUrl) }
|
||||||
var token by remember(saved.token) { mutableStateOf(saved.token) }
|
var token by remember(saved.token) { mutableStateOf(saved.token) }
|
||||||
var device by remember(saved.deviceLabel) { mutableStateOf(saved.deviceLabel) }
|
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() {
|
fun saveAndBack() {
|
||||||
vm.save(SynqSettings(url.trim(), token.trim(), device.trim().ifEmpty { "android" }))
|
vm.save(buildSettings())
|
||||||
nav.navigateUp()
|
nav.navigateUp()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +99,15 @@ fun SettingsScreen(nav: NavController, vm: SettingsViewModel = viewModel()) {
|
|||||||
label = { Text("device label") },
|
label = { Text("device label") },
|
||||||
singleLine = true,
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -97,7 +116,7 @@ fun SettingsScreen(nav: NavController, vm: SettingsViewModel = viewModel()) {
|
|||||||
) {
|
) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
vm.save(SynqSettings(url.trim(), token.trim(), device.trim().ifEmpty { "android" }))
|
vm.save(buildSettings())
|
||||||
vm.checkConnection(url.trim())
|
vm.checkConnection(url.trim())
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.launch
|
|||||||
import me.hgsky.synq.SynqApp
|
import me.hgsky.synq.SynqApp
|
||||||
import me.hgsky.synq.data.SynqSettings
|
import me.hgsky.synq.data.SynqSettings
|
||||||
import me.hgsky.synq.data.api.SynqApiClient
|
import me.hgsky.synq.data.api.SynqApiClient
|
||||||
|
import me.hgsky.synq.data.sync.SyncWorker
|
||||||
|
|
||||||
sealed class PingState {
|
sealed class PingState {
|
||||||
object Idle : PingState()
|
object Idle : PingState()
|
||||||
@@ -23,7 +24,8 @@ sealed class PingState {
|
|||||||
|
|
||||||
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
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(
|
val settings = repo.settings.stateIn(
|
||||||
viewModelScope,
|
viewModelScope,
|
||||||
@@ -35,7 +37,10 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val ping: StateFlow<PingState> = _ping.asStateFlow()
|
val ping: StateFlow<PingState> = _ping.asStateFlow()
|
||||||
|
|
||||||
fun save(settings: SynqSettings) {
|
fun save(settings: SynqSettings) {
|
||||||
viewModelScope.launch { repo.save(settings) }
|
viewModelScope.launch {
|
||||||
|
repo.save(settings)
|
||||||
|
SyncWorker.schedule(synqApp, settings.syncIntervalMinutes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun checkConnection(url: String) {
|
fun checkConnection(url: String) {
|
||||||
|
|||||||
60
tasks.org
60
tasks.org
@@ -373,22 +373,76 @@ Added "backup" section to README.md covering synq.org, capture.sqlite3, token.tx
|
|||||||
- datetime: [2026-05-18 Sun 19:00]
|
- datetime: [2026-05-18 Sun 19:00]
|
||||||
|
|
||||||
* milestone 5: optional v1.5
|
* milestone 5: optional v1.5
|
||||||
** TODO add android share target
|
** DONE add android share target
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- sharing text to app opens prefilled capture.
|
- sharing text to app opens prefilled capture.
|
||||||
- user can save as note or todo.
|
- 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: (this commit)
|
||||||
|
- 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
|
** TODO add home screen quick capture widget
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- widget opens capture screen quickly.
|
- widget opens capture screen quickly.
|
||||||
- does not require sync to work.
|
- does not require sync to work.
|
||||||
** TODO add recent tag chips
|
|
||||||
|
** DONE add recent tag chips
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- app shows recent tags.
|
- app shows recent tags.
|
||||||
- tapping chip adds/removes tag.
|
- tapping chip adds/removes tag.
|
||||||
** TODO add edit-before-sync
|
*** 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: (this commit)
|
||||||
|
- 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
|
*** acceptance
|
||||||
- pending/failed captures can be edited.
|
- pending/failed captures can be edited.
|
||||||
- synced captures are read-only unless explicitly duplicated.
|
- 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: (this commit)
|
||||||
|
- 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: (this commit)
|
||||||
|
- 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: (this commit)
|
||||||
|
- 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: (this commit)
|
||||||
|
- 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
|
* implementation notes for coding agents
|
||||||
- keep the server small and testable.
|
- keep the server small and testable.
|
||||||
|
|||||||
Reference in New Issue
Block a user