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]
|
- datetime: [2026-05-17 Sat 17:00]
|
||||||
|
|
||||||
* milestone 2: android local capture
|
* milestone 2: android local capture
|
||||||
** TODO create android project
|
** DONE create android project
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- kotlin android app builds.
|
- kotlin android app builds.
|
||||||
- jetpack compose enabled.
|
- jetpack compose enabled.
|
||||||
- min sdk is reasonable for current personal device.
|
- min sdk is reasonable for current personal device.
|
||||||
- app id is stable, e.g. `me.hgsky.phonecapture`.
|
- 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
|
*** acceptance
|
||||||
- app opens to multiline text box.
|
- app opens to multiline text box.
|
||||||
- keyboard opens automatically.
|
- keyboard opens automatically.
|
||||||
@@ -198,22 +205,49 @@ server/Dockerfile uses python:3.11-slim, installs deps, copies app/, exposes 876
|
|||||||
- tags field exists.
|
- tags field exists.
|
||||||
- save button exists.
|
- save button exists.
|
||||||
- save and close affordance 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
|
*** acceptance
|
||||||
- capture entity has id, created_at, kind, body, tags, device, status, synced_at, and last_error.
|
- capture entity has id, created_at, kind, body, tags, device, status, synced_at, and last_error.
|
||||||
- saving creates pending row.
|
- saving creates pending row.
|
||||||
- pending captures survive app restart.
|
- 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
|
*** acceptance
|
||||||
- id format is stable and readable, e.g. `phone-yyyymmdd-hhmmss-rand`.
|
- id format is stable and readable, e.g. `phone-yyyymmdd-hhmmss-rand`.
|
||||||
- ids are generated client-side.
|
- ids are generated client-side.
|
||||||
- tests or simple assertions prevent blank/duplicate ids in normal flow.
|
- 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
|
*** acceptance
|
||||||
- lists recent captures.
|
- lists recent captures.
|
||||||
- shows pending/synced/failed status.
|
- shows pending/synced/failed status.
|
||||||
- failed row shows last error.
|
- failed row shows last error.
|
||||||
- user can retry failed/pending sync.
|
- 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
|
* milestone 3: android sync
|
||||||
** TODO implement api client
|
** TODO implement api client
|
||||||
|
|||||||
Reference in New Issue
Block a user