implement milestone 3: api client, sync service, workmanager, manual sync button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 12:44:31 -04:00
parent b1265f2c2d
commit 2a963d9380
6 changed files with 197 additions and 5 deletions

View File

@@ -3,8 +3,14 @@ package me.hgsky.synq
import android.app.Application
import me.hgsky.synq.data.SettingsRepository
import me.hgsky.synq.data.db.CaptureDatabase
import me.hgsky.synq.data.sync.SyncWorker
class SynqApp : Application() {
val db by lazy { CaptureDatabase.get(this) }
val settings by lazy { SettingsRepository(this) }
override fun onCreate() {
super.onCreate()
SyncWorker.schedule(this)
}
}

View File

@@ -0,0 +1,77 @@
package me.hgsky.synq.data.api
import kotlinx.serialization.json.Json
import me.hgsky.synq.data.SynqSettings
import me.hgsky.synq.data.db.CaptureEntity
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
sealed class PostResult {
data class Accepted(val id: String) : PostResult()
data class AlreadySeen(val id: String) : PostResult()
data class Failed(val id: String, val error: String) : PostResult()
}
class SynqApiClient {
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
fun checkHealth(serverUrl: String): Boolean = try {
val req = Request.Builder().url("$serverUrl/health").get().build()
http.newCall(req).execute().use { it.isSuccessful }
} catch (_: IOException) {
false
} catch (_: Exception) {
false
}
fun postCapture(capture: CaptureEntity, settings: SynqSettings): PostResult = try {
val tags = try {
Json.decodeFromString<List<String>>(capture.tagsJson)
} catch (_: Exception) {
emptyList()
}
val bodyJson = JSONObject().apply {
put("id", capture.id)
put("created_at", capture.createdAt)
put("kind", capture.kind)
put("body", capture.body)
put("tags", JSONArray(tags))
put("device", capture.device)
}.toString()
val req = Request.Builder()
.url("${settings.serverUrl}/capture")
.header("Authorization", "Bearer ${settings.token}")
.post(bodyJson.toRequestBody("application/json".toMediaType()))
.build()
http.newCall(req).execute().use { response ->
when {
response.code == 401 -> PostResult.Failed(capture.id, "unauthorized (check token in settings)")
!response.isSuccessful -> PostResult.Failed(capture.id, "server error ${response.code}")
else -> {
val json = JSONObject(response.body?.string() ?: "{}")
if (json.optString("status") == "already_seen")
PostResult.AlreadySeen(capture.id)
else
PostResult.Accepted(capture.id)
}
}
}
} catch (e: IOException) {
PostResult.Failed(capture.id, e.message ?: "network error")
} catch (e: Exception) {
PostResult.Failed(capture.id, e.message ?: "unexpected error")
}
}

View File

@@ -0,0 +1,23 @@
package me.hgsky.synq.data.sync
import me.hgsky.synq.data.SynqSettings
import me.hgsky.synq.data.api.PostResult
import me.hgsky.synq.data.api.SynqApiClient
import me.hgsky.synq.data.db.CaptureDao
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
suspend fun syncPending(dao: CaptureDao, client: SynqApiClient, settings: SynqSettings) {
val pending = dao.getPendingAndFailed()
for (capture in pending) {
dao.updateStatus(capture.id, "syncing", null)
val result = client.postCapture(capture, settings)
when (result) {
is PostResult.Accepted, is PostResult.AlreadySeen -> {
val now = OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
dao.markSynced(capture.id, now)
}
is PostResult.Failed -> dao.updateStatus(capture.id, "failed", result.error)
}
}
}

View File

@@ -0,0 +1,48 @@
package me.hgsky.synq.data.sync
import android.content.Context
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import kotlinx.coroutines.flow.first
import me.hgsky.synq.SynqApp
import me.hgsky.synq.data.api.SynqApiClient
import java.util.concurrent.TimeUnit
class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
val app = applicationContext as SynqApp
val settings = app.settings.settings.first()
val client = SynqApiClient()
if (!client.checkHealth(settings.serverUrl)) return Result.success()
syncPending(app.db.captureDao(), client, settings)
return Result.success()
}
companion object {
private const val WORK_NAME = "synq_periodic_sync"
fun schedule(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request,
)
}
}
}

View File

@@ -9,11 +9,14 @@ import androidx.compose.foundation.layout.fillMaxSize
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.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Settings
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.Icon
import androidx.compose.material3.IconButton
@@ -53,6 +56,19 @@ fun CaptureScreen(nav: NavController, vm: CaptureViewModel = viewModel()) {
TopAppBar(
title = { Text("synq") },
actions = {
IconButton(
onClick = { vm.syncNow() },
enabled = !state.isSyncing,
) {
if (state.isSyncing) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
} else {
Icon(Icons.Default.Sync, contentDescription = "sync now")
}
}
IconButton(onClick = { nav.navigate("history") }) {
Icon(Icons.Default.History, contentDescription = "history")
}
@@ -105,9 +121,7 @@ fun CaptureScreen(nav: NavController, vm: CaptureViewModel = viewModel()) {
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
@@ -122,6 +136,8 @@ fun CaptureScreen(nav: NavController, vm: CaptureViewModel = viewModel()) {
enabled = state.body.isNotBlank(),
) { Text("save & close") }
}
Spacer(Modifier.height(4.dp))
}
}
}

View File

@@ -3,15 +3,19 @@ package me.hgsky.synq.ui.capture
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import me.hgsky.synq.SynqApp
import me.hgsky.synq.data.api.SynqApiClient
import me.hgsky.synq.data.generateCaptureId
import me.hgsky.synq.data.db.CaptureEntity
import me.hgsky.synq.data.sync.syncPending
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
@@ -19,11 +23,13 @@ data class CaptureUiState(
val body: String = "",
val isTodo: Boolean = false,
val tags: String = "",
val isSyncing: Boolean = false,
)
class CaptureViewModel(app: Application) : AndroidViewModel(app) {
private val dao = (app as SynqApp).db.captureDao()
private val synqApp = app as SynqApp
private val dao = synqApp.db.captureDao()
private val _state = MutableStateFlow(CaptureUiState())
val state: StateFlow<CaptureUiState> = _state.asStateFlow()
@@ -51,11 +57,27 @@ class CaptureViewModel(app: Application) : AndroidViewModel(app) {
status = "pending",
)
)
_state.value = CaptureUiState()
_state.value = _state.value.copy(body = "", tags = "")
onDone()
}
}
fun syncNow() {
if (_state.value.isSyncing) return
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(isSyncing = true)
try {
val settings = synqApp.settings.settings.first()
val client = SynqApiClient()
if (client.checkHealth(settings.serverUrl)) {
syncPending(dao, client, settings)
}
} finally {
_state.value = _state.value.copy(isSyncing = false)
}
}
}
private fun parseTags(raw: String): List<String> =
raw.split(Regex("[,\\s]+"))
.map { it.trim() }