resolve merge conflict: take worktree milestone 5 DONE state, keep user title
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" />
|
||||
<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>
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user