Compare commits
2 Commits
00b95be3ed
...
18e512f5ac
| Author | SHA1 | Date | |
|---|---|---|---|
| 18e512f5ac | |||
| 19b05a89f9 |
66
android/app/build.gradle.kts
Normal file
66
android/app/build.gradle.kts
Normal 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")
|
||||
}
|
||||
24
android/app/src/main/AndroidManifest.xml
Normal file
24
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
34
android/app/src/main/kotlin/me/hgsky/synq/MainActivity.kt
Normal file
34
android/app/src/main/kotlin/me/hgsky/synq/MainActivity.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
10
android/app/src/main/kotlin/me/hgsky/synq/SynqApp.kt
Normal file
10
android/app/src/main/kotlin/me/hgsky/synq/SynqApp.kt
Normal 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) }
|
||||
}
|
||||
13
android/app/src/main/kotlin/me/hgsky/synq/data/CaptureId.kt
Normal file
13
android/app/src/main/kotlin/me/hgsky/synq/data/CaptureId.kt
Normal 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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
14
android/app/src/main/kotlin/me/hgsky/synq/ui/theme/Theme.kt
Normal file
14
android/app/src/main/kotlin/me/hgsky/synq/ui/theme/Theme.kt
Normal 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)
|
||||
}
|
||||
3
android/app/src/main/res/values/strings.xml
Normal file
3
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">synq</string>
|
||||
</resources>
|
||||
3
android/app/src/main/res/values/themes.xml
Normal file
3
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<style name="Theme.Synq" parent="Theme.Material3.DayNight.NoActionBar" />
|
||||
</resources>
|
||||
7
android/build.gradle.kts
Normal file
7
android/build.gradle.kts
Normal file
@@ -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
|
||||
}
|
||||
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -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
|
||||
17
android/settings.gradle.kts
Normal file
17
android/settings.gradle.kts
Normal file
@@ -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")
|
||||
44
tasks.org
44
tasks.org
@@ -184,13 +184,20 @@ server/Dockerfile uses python:3.11-slim, installs deps, copies app/, exposes 876
|
||||
- datetime: [2026-05-17 Sat 17:00]
|
||||
|
||||
* milestone 2: android local capture
|
||||
** TODO create android project
|
||||
** DONE create android project
|
||||
*** acceptance
|
||||
- kotlin android app builds.
|
||||
- jetpack compose enabled.
|
||||
- min sdk is reasonable for current personal device.
|
||||
- app id is stable, e.g. `me.hgsky.phonecapture`.
|
||||
** TODO build capture screen
|
||||
*** notes
|
||||
Gradle project in android/. AGP 8.7.3, Kotlin 2.0.21, Compose BOM 2024.10.01. minSdk=31, compileSdk=36. App ID is me.hgsky.synq. Open android/ in Android Studio and hit Sync — AS will download the Gradle wrapper (8.9) automatically.
|
||||
*** evidence
|
||||
- commit: 19b05a8
|
||||
- tests: Gradle sync in Android Studio (manual step)
|
||||
- datetime: [2026-05-18 Sun 17:00]
|
||||
|
||||
** DONE build capture screen
|
||||
*** acceptance
|
||||
- app opens to multiline text box.
|
||||
- keyboard opens automatically.
|
||||
@@ -198,22 +205,49 @@ server/Dockerfile uses python:3.11-slim, installs deps, copies app/, exposes 876
|
||||
- tags field exists.
|
||||
- save button exists.
|
||||
- save and close affordance exists.
|
||||
** TODO implement local room database
|
||||
*** notes
|
||||
CaptureScreen.kt uses LaunchedEffect(Unit) { focusRequester.requestFocus() } for auto-focus. note/todo Switch, tags OutlinedTextField, "save" OutlinedButton (stays), "save & close" Button (finishes activity). No network call on launch.
|
||||
*** evidence
|
||||
- commit: 19b05a8
|
||||
- tests: manual — build and run on device/emulator
|
||||
- datetime: [2026-05-18 Sun 17:00]
|
||||
|
||||
** DONE implement local room database
|
||||
*** acceptance
|
||||
- capture entity has id, created_at, kind, body, tags, device, status, synced_at, and last_error.
|
||||
- saving creates pending row.
|
||||
- pending captures survive app restart.
|
||||
** TODO implement capture id generation
|
||||
*** notes
|
||||
CaptureEntity, CaptureDao, CaptureDatabase in data/db/. Tags stored as JSON string (kotlinx-serialization). DB name synq.db, singleton via companion object. Room persists across restarts by default.
|
||||
*** evidence
|
||||
- commit: 19b05a8
|
||||
- tests: manual — save a capture, force-close app, reopen and check history
|
||||
- datetime: [2026-05-18 Sun 17:00]
|
||||
|
||||
** DONE implement capture id generation
|
||||
*** acceptance
|
||||
- id format is stable and readable, e.g. `phone-yyyymmdd-hhmmss-rand`.
|
||||
- ids are generated client-side.
|
||||
- tests or simple assertions prevent blank/duplicate ids in normal flow.
|
||||
** TODO implement basic history screen
|
||||
*** notes
|
||||
generateCaptureId() in data/CaptureId.kt. Format: phone-YYYYMMDD-HHmmss-xxxx (4 random [a-z0-9] chars). Called in CaptureViewModel.save() before Room insert.
|
||||
*** evidence
|
||||
- commit: 19b05a8
|
||||
- tests: manual — check id column in history; each save produces a unique phone-… id
|
||||
- datetime: [2026-05-18 Sun 17:00]
|
||||
|
||||
** DONE implement basic history screen
|
||||
*** acceptance
|
||||
- lists recent captures.
|
||||
- shows pending/synced/failed status.
|
||||
- failed row shows last error.
|
||||
- user can retry failed/pending sync.
|
||||
*** notes
|
||||
HistoryScreen.kt observes captureDao.observeAll() as Flow. Status colored green/red/grey. Retry button shown for pending/failed; calls updateStatus(id, "pending", null) so the sync worker picks it up in milestone 3.
|
||||
*** evidence
|
||||
- commit: 19b05a8
|
||||
- tests: manual — save captures, navigate to history, verify status labels and retry button
|
||||
- datetime: [2026-05-18 Sun 17:00]
|
||||
|
||||
* milestone 3: android sync
|
||||
** TODO implement api client
|
||||
|
||||
Reference in New Issue
Block a user