diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..b4cc5b2
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,66 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+ id("org.jetbrains.kotlin.plugin.serialization")
+ id("com.google.devtools.ksp")
+}
+
+android {
+ namespace = "me.hgsky.synq"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "me.hgsky.synq"
+ minSdk = 31
+ targetSdk = 36
+ versionCode = 1
+ versionName = "0.1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+ }
+
+ kotlinOptions {
+ jvmTarget = "21"
+ }
+
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ val composeBom = platform("androidx.compose:compose-bom:2024.10.01")
+ implementation(composeBom)
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.material:material-icons-extended")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+
+ implementation("androidx.activity:activity-compose:1.9.3")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
+ implementation("androidx.navigation:navigation-compose:2.8.4")
+ implementation("androidx.datastore:datastore-preferences:1.1.1")
+
+ val room = "2.6.1"
+ implementation("androidx.room:room-runtime:$room")
+ implementation("androidx.room:room-ktx:$room")
+ ksp("androidx.room:room-compiler:$room")
+
+ implementation("androidx.work:work-runtime-ktx:2.9.1")
+
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
+
+ implementation("com.google.android.material:material:1.12.0")
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..83c75a7
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/me/hgsky/synq/MainActivity.kt b/android/app/src/main/kotlin/me/hgsky/synq/MainActivity.kt
new file mode 100644
index 0000000..de55ab2
--- /dev/null
+++ b/android/app/src/main/kotlin/me/hgsky/synq/MainActivity.kt
@@ -0,0 +1,34 @@
+package me.hgsky.synq
+
+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.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+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
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ SynqTheme { SynqNav() }
+ }
+ }
+}
+
+@Composable
+private fun SynqNav() {
+ val nav = rememberNavController()
+ NavHost(navController = nav, startDestination = "capture") {
+ composable("capture") { CaptureScreen(nav) }
+ composable("history") { HistoryScreen(nav) }
+ composable("settings") { SettingsScreen(nav) }
+ }
+}
diff --git a/android/app/src/main/kotlin/me/hgsky/synq/SynqApp.kt b/android/app/src/main/kotlin/me/hgsky/synq/SynqApp.kt
new file mode 100644
index 0000000..3b07337
--- /dev/null
+++ b/android/app/src/main/kotlin/me/hgsky/synq/SynqApp.kt
@@ -0,0 +1,10 @@
+package me.hgsky.synq
+
+import android.app.Application
+import me.hgsky.synq.data.SettingsRepository
+import me.hgsky.synq.data.db.CaptureDatabase
+
+class SynqApp : Application() {
+ val db by lazy { CaptureDatabase.get(this) }
+ val settings by lazy { SettingsRepository(this) }
+}
diff --git a/android/app/src/main/kotlin/me/hgsky/synq/data/CaptureId.kt b/android/app/src/main/kotlin/me/hgsky/synq/data/CaptureId.kt
new file mode 100644
index 0000000..509ab22
--- /dev/null
+++ b/android/app/src/main/kotlin/me/hgsky/synq/data/CaptureId.kt
@@ -0,0 +1,13 @@
+package me.hgsky.synq.data
+
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+private val DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")
+private const val CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"
+
+fun generateCaptureId(): String {
+ val ts = LocalDateTime.now().format(DATE_FMT)
+ val rand = (1..4).map { CHARS.random() }.joinToString("")
+ return "phone-$ts-$rand"
+}
diff --git a/android/app/src/main/kotlin/me/hgsky/synq/data/SettingsRepository.kt b/android/app/src/main/kotlin/me/hgsky/synq/data/SettingsRepository.kt
new file mode 100644
index 0000000..d6b41dc
--- /dev/null
+++ b/android/app/src/main/kotlin/me/hgsky/synq/data/SettingsRepository.kt
@@ -0,0 +1,39 @@
+package me.hgsky.synq.data
+
+import android.content.Context
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+private val Context.dataStore by preferencesDataStore("synq_settings")
+
+data class SynqSettings(
+ val serverUrl: String = "http://jeeves.mother:8765",
+ val token: String = "",
+ val deviceLabel: String = "android",
+)
+
+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")
+
+ val settings: Flow = context.dataStore.data.map { prefs ->
+ SynqSettings(
+ serverUrl = prefs[KEY_URL] ?: "http://jeeves.mother:8765",
+ token = prefs[KEY_TOKEN] ?: "",
+ deviceLabel = prefs[KEY_DEVICE] ?: "android",
+ )
+ }
+
+ suspend fun save(settings: SynqSettings) {
+ context.dataStore.edit { prefs ->
+ prefs[KEY_URL] = settings.serverUrl
+ prefs[KEY_TOKEN] = settings.token
+ prefs[KEY_DEVICE] = settings.deviceLabel
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/me/hgsky/synq/data/db/CaptureDao.kt b/android/app/src/main/kotlin/me/hgsky/synq/data/db/CaptureDao.kt
new file mode 100644
index 0000000..34fed7a
--- /dev/null
+++ b/android/app/src/main/kotlin/me/hgsky/synq/data/db/CaptureDao.kt
@@ -0,0 +1,30 @@
+package me.hgsky.synq.data.db
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface CaptureDao {
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(capture: CaptureEntity)
+
+ @Update
+ suspend fun update(capture: CaptureEntity)
+
+ @Query("SELECT * FROM captures ORDER BY createdAt DESC")
+ fun observeAll(): Flow>
+
+ @Query("SELECT * FROM captures WHERE status IN ('pending', 'failed') ORDER BY createdAt ASC")
+ suspend fun getPendingAndFailed(): List
+
+ @Query("UPDATE captures SET status = :status, lastError = :error WHERE id = :id")
+ suspend fun updateStatus(id: String, status: String, error: String?)
+
+ @Query("UPDATE captures SET status = 'synced', syncedAt = :syncedAt, lastError = NULL WHERE id = :id")
+ suspend fun markSynced(id: String, syncedAt: String)
+}
diff --git a/android/app/src/main/kotlin/me/hgsky/synq/data/db/CaptureDatabase.kt b/android/app/src/main/kotlin/me/hgsky/synq/data/db/CaptureDatabase.kt
new file mode 100644
index 0000000..98d0f33
--- /dev/null
+++ b/android/app/src/main/kotlin/me/hgsky/synq/data/db/CaptureDatabase.kt
@@ -0,0 +1,24 @@
+package me.hgsky.synq.data.db
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+
+@Database(entities = [CaptureEntity::class], version = 1, exportSchema = false)
+abstract class CaptureDatabase : RoomDatabase() {
+
+ abstract fun captureDao(): CaptureDao
+
+ companion object {
+ @Volatile private var instance: CaptureDatabase? = null
+
+ fun get(context: Context): CaptureDatabase = instance ?: synchronized(this) {
+ instance ?: Room.databaseBuilder(
+ context.applicationContext,
+ CaptureDatabase::class.java,
+ "synq.db",
+ ).build().also { instance = it }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/me/hgsky/synq/data/db/CaptureEntity.kt b/android/app/src/main/kotlin/me/hgsky/synq/data/db/CaptureEntity.kt
new file mode 100644
index 0000000..a84c497
--- /dev/null
+++ b/android/app/src/main/kotlin/me/hgsky/synq/data/db/CaptureEntity.kt
@@ -0,0 +1,17 @@
+package me.hgsky.synq.data.db
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "captures")
+data class CaptureEntity(
+ @PrimaryKey val id: String,
+ val createdAt: String,
+ val kind: String, // "note" | "todo"
+ val body: String,
+ val tagsJson: String, // JSON array, e.g. ["home","errands"]
+ val device: String,
+ val status: String, // "pending" | "syncing" | "synced" | "failed"
+ val syncedAt: String? = null,
+ val lastError: String? = null,
+)
diff --git a/android/app/src/main/kotlin/me/hgsky/synq/ui/capture/CaptureScreen.kt b/android/app/src/main/kotlin/me/hgsky/synq/ui/capture/CaptureScreen.kt
new file mode 100644
index 0000000..0716bda
--- /dev/null
+++ b/android/app/src/main/kotlin/me/hgsky/synq/ui/capture/CaptureScreen.kt
@@ -0,0 +1,127 @@
+package me.hgsky.synq.ui.capture
+
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+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.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.material3.Button
+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.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavController
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CaptureScreen(nav: NavController, vm: CaptureViewModel = viewModel()) {
+ val state by vm.state.collectAsState()
+ val focusRequester = remember { FocusRequester() }
+ val context = LocalContext.current
+
+ LaunchedEffect(Unit) { focusRequester.requestFocus() }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("synq") },
+ actions = {
+ IconButton(onClick = { nav.navigate("history") }) {
+ Icon(Icons.Default.History, contentDescription = "history")
+ }
+ IconButton(onClick = { nav.navigate("settings") }) {
+ Icon(Icons.Default.Settings, contentDescription = "settings")
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Spacer(Modifier.height(4.dp))
+
+ OutlinedTextField(
+ value = state.body,
+ onValueChange = vm::setBody,
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ .focusRequester(focusRequester),
+ placeholder = { Text("capture something…") },
+ keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
+ maxLines = Int.MAX_VALUE,
+ )
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text("note", style = MaterialTheme.typography.bodyMedium)
+ Switch(
+ checked = state.isTodo,
+ onCheckedChange = vm::setTodo,
+ )
+ Text("todo", style = MaterialTheme.typography.bodyMedium)
+ }
+
+ OutlinedTextField(
+ value = state.tags,
+ onValueChange = vm::setTags,
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = { Text("tags — space or comma separated") },
+ singleLine = true,
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ OutlinedButton(
+ onClick = { vm.save() },
+ modifier = Modifier.weight(1f),
+ enabled = state.body.isNotBlank(),
+ ) { Text("save") }
+
+ Button(
+ onClick = { vm.save { (context as? ComponentActivity)?.finish() } },
+ modifier = Modifier.weight(1f),
+ enabled = state.body.isNotBlank(),
+ ) { Text("save & close") }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/me/hgsky/synq/ui/capture/CaptureViewModel.kt b/android/app/src/main/kotlin/me/hgsky/synq/ui/capture/CaptureViewModel.kt
new file mode 100644
index 0000000..b5162af
--- /dev/null
+++ b/android/app/src/main/kotlin/me/hgsky/synq/ui/capture/CaptureViewModel.kt
@@ -0,0 +1,64 @@
+package me.hgsky.synq.ui.capture
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import me.hgsky.synq.SynqApp
+import me.hgsky.synq.data.generateCaptureId
+import me.hgsky.synq.data.db.CaptureEntity
+import java.time.OffsetDateTime
+import java.time.format.DateTimeFormatter
+
+data class CaptureUiState(
+ val body: String = "",
+ val isTodo: Boolean = false,
+ val tags: String = "",
+)
+
+class CaptureViewModel(app: Application) : AndroidViewModel(app) {
+
+ private val dao = (app as SynqApp).db.captureDao()
+ private val settingsRepo = app.settings
+
+ private val _state = MutableStateFlow(CaptureUiState())
+ val state: StateFlow = _state.asStateFlow()
+
+ 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 save(onDone: () -> Unit = {}) {
+ val s = _state.value
+ val body = s.body.trim()
+ if (body.isEmpty()) return
+
+ viewModelScope.launch {
+ val tags = parseTags(s.tags)
+ val now = OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
+ dao.insert(
+ CaptureEntity(
+ id = generateCaptureId(),
+ createdAt = now,
+ kind = if (s.isTodo) "todo" else "note",
+ body = body,
+ tagsJson = Json.encodeToString(tags),
+ device = "android",
+ status = "pending",
+ )
+ )
+ _state.value = CaptureUiState()
+ onDone()
+ }
+ }
+
+ private fun parseTags(raw: String): List =
+ raw.split(Regex("[,\\s]+"))
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+}
diff --git a/android/app/src/main/kotlin/me/hgsky/synq/ui/history/HistoryScreen.kt b/android/app/src/main/kotlin/me/hgsky/synq/ui/history/HistoryScreen.kt
new file mode 100644
index 0000000..172b4a4
--- /dev/null
+++ b/android/app/src/main/kotlin/me/hgsky/synq/ui/history/HistoryScreen.kt
@@ -0,0 +1,128 @@
+package me.hgsky.synq.ui.history
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+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.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.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavController
+import me.hgsky.synq.data.db.CaptureEntity
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HistoryScreen(nav: NavController, vm: HistoryViewModel = viewModel()) {
+ val captures by vm.captures.collectAsState(initial = emptyList())
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("history") },
+ navigationIcon = {
+ IconButton(onClick = { nav.navigateUp() }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "back")
+ }
+ },
+ )
+ },
+ ) { padding ->
+ if (captures.isEmpty()) {
+ Box(
+ modifier = Modifier.fillMaxSize().padding(padding),
+ contentAlignment = Alignment.Center,
+ ) { Text("no captures yet") }
+ } else {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize().padding(padding),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(captures, key = { it.id }) { capture ->
+ CaptureRow(capture, onRetry = { vm.retryCapture(capture) })
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CaptureRow(capture: CaptureEntity, onRetry: () -> Unit) {
+ val statusColor = when (capture.status) {
+ "synced" -> Color(0xFF2E7D32)
+ "failed" -> MaterialTheme.colorScheme.error
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
+ }
+
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ capture.kind.uppercase(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Text(
+ capture.status,
+ style = MaterialTheme.typography.labelSmall,
+ color = statusColor,
+ )
+ if (capture.status == "pending" || capture.status == "failed") {
+ Button(onClick = onRetry, contentPadding = PaddingValues(horizontal = 8.dp)) {
+ Text("retry", style = MaterialTheme.typography.labelSmall)
+ }
+ }
+ }
+
+ Text(
+ capture.body,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ Text(
+ capture.createdAt,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ if (capture.lastError != null) {
+ Text(
+ capture.lastError,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/me/hgsky/synq/ui/history/HistoryViewModel.kt b/android/app/src/main/kotlin/me/hgsky/synq/ui/history/HistoryViewModel.kt
new file mode 100644
index 0000000..85d84e2
--- /dev/null
+++ b/android/app/src/main/kotlin/me/hgsky/synq/ui/history/HistoryViewModel.kt
@@ -0,0 +1,22 @@
+package me.hgsky.synq.ui.history
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.launch
+import me.hgsky.synq.SynqApp
+import me.hgsky.synq.data.db.CaptureEntity
+
+class HistoryViewModel(app: Application) : AndroidViewModel(app) {
+
+ private val dao = (app as SynqApp).db.captureDao()
+
+ val captures: Flow> = dao.observeAll()
+
+ fun retryCapture(capture: CaptureEntity) {
+ viewModelScope.launch {
+ dao.updateStatus(capture.id, "pending", null)
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/me/hgsky/synq/ui/settings/SettingsScreen.kt b/android/app/src/main/kotlin/me/hgsky/synq/ui/settings/SettingsScreen.kt
new file mode 100644
index 0000000..31448cf
--- /dev/null
+++ b/android/app/src/main/kotlin/me/hgsky/synq/ui/settings/SettingsScreen.kt
@@ -0,0 +1,90 @@
+package me.hgsky.synq.ui.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+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.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavController
+import me.hgsky.synq.data.SynqSettings
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(nav: NavController, vm: SettingsViewModel = viewModel()) {
+ val saved by vm.settings.collectAsState()
+
+ 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) }
+
+ fun saveAndBack() {
+ vm.save(SynqSettings(url.trim(), token.trim(), device.trim().ifEmpty { "android" }))
+ nav.navigateUp()
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("settings") },
+ navigationIcon = {
+ IconButton(onClick = ::saveAndBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "back")
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ OutlinedTextField(
+ value = url,
+ onValueChange = { url = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("server url") },
+ singleLine = true,
+ )
+ OutlinedTextField(
+ value = token,
+ onValueChange = { token = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("bearer token") },
+ singleLine = true,
+ )
+ OutlinedTextField(
+ value = device,
+ onValueChange = { device = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("device label") },
+ singleLine = true,
+ )
+ Button(
+ onClick = ::saveAndBack,
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("save") }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/me/hgsky/synq/ui/settings/SettingsViewModel.kt b/android/app/src/main/kotlin/me/hgsky/synq/ui/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..6d045e4
--- /dev/null
+++ b/android/app/src/main/kotlin/me/hgsky/synq/ui/settings/SettingsViewModel.kt
@@ -0,0 +1,25 @@
+package me.hgsky.synq.ui.settings
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import me.hgsky.synq.SynqApp
+import me.hgsky.synq.data.SynqSettings
+
+class SettingsViewModel(app: Application) : AndroidViewModel(app) {
+
+ private val repo = (app as SynqApp).settings
+
+ val settings = repo.settings.stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(5_000),
+ SynqSettings(),
+ )
+
+ fun save(settings: SynqSettings) {
+ viewModelScope.launch { repo.save(settings) }
+ }
+}
diff --git a/android/app/src/main/kotlin/me/hgsky/synq/ui/theme/Theme.kt b/android/app/src/main/kotlin/me/hgsky/synq/ui/theme/Theme.kt
new file mode 100644
index 0000000..ab2f24c
--- /dev/null
+++ b/android/app/src/main/kotlin/me/hgsky/synq/ui/theme/Theme.kt
@@ -0,0 +1,14 @@
+package me.hgsky.synq.ui.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+@Composable
+fun SynqTheme(content: @Composable () -> Unit) {
+ val context = LocalContext.current
+ val colorScheme = dynamicLightColorScheme(context)
+ MaterialTheme(colorScheme = colorScheme, content = content)
+}
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..3dd4eae
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ synq
+
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..610797e
--- /dev/null
+++ b/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..1fac040
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,7 @@
+plugins {
+ id("com.android.application") version "8.7.3" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
+ id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false
+}
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..09523c0
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..64c0578
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,17 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "synq"
+include(":app")