scaffold android app: compose ui, room db, capture/history/settings screens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 10:27:02 -04:00
parent 00b95be3ed
commit 19b05a89f9
21 changed files with 764 additions and 0 deletions

View File

@@ -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")
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".SynqApp"
android:label="@string/app_name"
android:theme="@style/Theme.Synq"
android:allowBackup="true"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -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) }
}
}

View File

@@ -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) }
}

View File

@@ -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"
}

View File

@@ -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<SynqSettings> = 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
}
}
}

View File

@@ -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<List<CaptureEntity>>
@Query("SELECT * FROM captures WHERE status IN ('pending', 'failed') ORDER BY createdAt ASC")
suspend fun getPendingAndFailed(): List<CaptureEntity>
@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)
}

View File

@@ -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 }
}
}
}

View File

@@ -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,
)

View File

@@ -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") }
}
}
}
}

View File

@@ -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<CaptureUiState> = _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<String> =
raw.split(Regex("[,\\s]+"))
.map { it.trim() }
.filter { it.isNotEmpty() }
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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<List<CaptureEntity>> = dao.observeAll()
fun retryCapture(capture: CaptureEntity) {
viewModelScope.launch {
dao.updateStatus(capture.id, "pending", null)
}
}
}

View File

@@ -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") }
}
}
}

View File

@@ -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) }
}
}

View File

@@ -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)
}

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">synq</string>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<style name="Theme.Synq" parent="Theme.Material3.DayNight.NoActionBar" />
</resources>