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