Compare commits

...

2 Commits

Author SHA1 Message Date
18e512f5ac mark milestone 2 tasks DONE with evidence in tasks.org
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 10:27:34 -04:00
19b05a89f9 scaffold android app: compose ui, room db, capture/history/settings screens
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 10:27:02 -04:00
22 changed files with 803 additions and 5 deletions

View File

@@ -0,0 +1,66 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.devtools.ksp")
}
android {
namespace = "me.hgsky.synq"
compileSdk = 36
defaultConfig {
applicationId = "me.hgsky.synq"
minSdk = 31
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = "21"
}
buildFeatures {
compose = true
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.10.01")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.navigation:navigation-compose:2.8.4")
implementation("androidx.datastore:datastore-preferences:1.1.1")
val room = "2.6.1"
implementation("androidx.room:room-runtime:$room")
implementation("androidx.room:room-ktx:$room")
ksp("androidx.room:room-compiler:$room")
implementation("androidx.work:work-runtime-ktx:2.9.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("com.google.android.material:material:1.12.0")
}

View File

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

View File

@@ -0,0 +1,34 @@
package me.hgsky.synq
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import me.hgsky.synq.ui.capture.CaptureScreen
import me.hgsky.synq.ui.history.HistoryScreen
import me.hgsky.synq.ui.settings.SettingsScreen
import me.hgsky.synq.ui.theme.SynqTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
SynqTheme { SynqNav() }
}
}
}
@Composable
private fun SynqNav() {
val nav = rememberNavController()
NavHost(navController = nav, startDestination = "capture") {
composable("capture") { CaptureScreen(nav) }
composable("history") { HistoryScreen(nav) }
composable("settings") { SettingsScreen(nav) }
}
}

View File

@@ -0,0 +1,10 @@
package me.hgsky.synq
import android.app.Application
import me.hgsky.synq.data.SettingsRepository
import me.hgsky.synq.data.db.CaptureDatabase
class SynqApp : Application() {
val db by lazy { CaptureDatabase.get(this) }
val settings by lazy { SettingsRepository(this) }
}

View File

@@ -0,0 +1,13 @@
package me.hgsky.synq.data
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
private val DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")
private const val CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"
fun generateCaptureId(): String {
val ts = LocalDateTime.now().format(DATE_FMT)
val rand = (1..4).map { CHARS.random() }.joinToString("")
return "phone-$ts-$rand"
}

View File

@@ -0,0 +1,39 @@
package me.hgsky.synq.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore by preferencesDataStore("synq_settings")
data class SynqSettings(
val serverUrl: String = "http://jeeves.mother:8765",
val token: String = "",
val deviceLabel: String = "android",
)
class SettingsRepository(private val context: Context) {
private val KEY_URL = stringPreferencesKey("server_url")
private val KEY_TOKEN = stringPreferencesKey("token")
private val KEY_DEVICE = stringPreferencesKey("device_label")
val settings: Flow<SynqSettings> = context.dataStore.data.map { prefs ->
SynqSettings(
serverUrl = prefs[KEY_URL] ?: "http://jeeves.mother:8765",
token = prefs[KEY_TOKEN] ?: "",
deviceLabel = prefs[KEY_DEVICE] ?: "android",
)
}
suspend fun save(settings: SynqSettings) {
context.dataStore.edit { prefs ->
prefs[KEY_URL] = settings.serverUrl
prefs[KEY_TOKEN] = settings.token
prefs[KEY_DEVICE] = settings.deviceLabel
}
}
}

View File

@@ -0,0 +1,30 @@
package me.hgsky.synq.data.db
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface CaptureDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(capture: CaptureEntity)
@Update
suspend fun update(capture: CaptureEntity)
@Query("SELECT * FROM captures ORDER BY createdAt DESC")
fun observeAll(): Flow<List<CaptureEntity>>
@Query("SELECT * FROM captures WHERE status IN ('pending', 'failed') ORDER BY createdAt ASC")
suspend fun getPendingAndFailed(): List<CaptureEntity>
@Query("UPDATE captures SET status = :status, lastError = :error WHERE id = :id")
suspend fun updateStatus(id: String, status: String, error: String?)
@Query("UPDATE captures SET status = 'synced', syncedAt = :syncedAt, lastError = NULL WHERE id = :id")
suspend fun markSynced(id: String, syncedAt: String)
}

View File

@@ -0,0 +1,24 @@
package me.hgsky.synq.data.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [CaptureEntity::class], version = 1, exportSchema = false)
abstract class CaptureDatabase : RoomDatabase() {
abstract fun captureDao(): CaptureDao
companion object {
@Volatile private var instance: CaptureDatabase? = null
fun get(context: Context): CaptureDatabase = instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
CaptureDatabase::class.java,
"synq.db",
).build().also { instance = it }
}
}
}

View File

@@ -0,0 +1,17 @@
package me.hgsky.synq.data.db
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "captures")
data class CaptureEntity(
@PrimaryKey val id: String,
val createdAt: String,
val kind: String, // "note" | "todo"
val body: String,
val tagsJson: String, // JSON array, e.g. ["home","errands"]
val device: String,
val status: String, // "pending" | "syncing" | "synced" | "failed"
val syncedAt: String? = null,
val lastError: String? = null,
)

View File

@@ -0,0 +1,127 @@
package me.hgsky.synq.ui.capture
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CaptureScreen(nav: NavController, vm: CaptureViewModel = viewModel()) {
val state by vm.state.collectAsState()
val focusRequester = remember { FocusRequester() }
val context = LocalContext.current
LaunchedEffect(Unit) { focusRequester.requestFocus() }
Scaffold(
topBar = {
TopAppBar(
title = { Text("synq") },
actions = {
IconButton(onClick = { nav.navigate("history") }) {
Icon(Icons.Default.History, contentDescription = "history")
}
IconButton(onClick = { nav.navigate("settings") }) {
Icon(Icons.Default.Settings, contentDescription = "settings")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Spacer(Modifier.height(4.dp))
OutlinedTextField(
value = state.body,
onValueChange = vm::setBody,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.focusRequester(focusRequester),
placeholder = { Text("capture something…") },
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
maxLines = Int.MAX_VALUE,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("note", style = MaterialTheme.typography.bodyMedium)
Switch(
checked = state.isTodo,
onCheckedChange = vm::setTodo,
)
Text("todo", style = MaterialTheme.typography.bodyMedium)
}
OutlinedTextField(
value = state.tags,
onValueChange = vm::setTags,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("tags — space or comma separated") },
singleLine = true,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = { vm.save() },
modifier = Modifier.weight(1f),
enabled = state.body.isNotBlank(),
) { Text("save") }
Button(
onClick = { vm.save { (context as? ComponentActivity)?.finish() } },
modifier = Modifier.weight(1f),
enabled = state.body.isNotBlank(),
) { Text("save & close") }
}
}
}
}

View File

@@ -0,0 +1,64 @@
package me.hgsky.synq.ui.capture
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import me.hgsky.synq.SynqApp
import me.hgsky.synq.data.generateCaptureId
import me.hgsky.synq.data.db.CaptureEntity
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
data class CaptureUiState(
val body: String = "",
val isTodo: Boolean = false,
val tags: String = "",
)
class CaptureViewModel(app: Application) : AndroidViewModel(app) {
private val dao = (app as SynqApp).db.captureDao()
private val settingsRepo = app.settings
private val _state = MutableStateFlow(CaptureUiState())
val state: StateFlow<CaptureUiState> = _state.asStateFlow()
fun setBody(v: String) { _state.value = _state.value.copy(body = v) }
fun setTodo(v: Boolean) { _state.value = _state.value.copy(isTodo = v) }
fun setTags(v: String) { _state.value = _state.value.copy(tags = v) }
fun save(onDone: () -> Unit = {}) {
val s = _state.value
val body = s.body.trim()
if (body.isEmpty()) return
viewModelScope.launch {
val tags = parseTags(s.tags)
val now = OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
dao.insert(
CaptureEntity(
id = generateCaptureId(),
createdAt = now,
kind = if (s.isTodo) "todo" else "note",
body = body,
tagsJson = Json.encodeToString(tags),
device = "android",
status = "pending",
)
)
_state.value = CaptureUiState()
onDone()
}
}
private fun parseTags(raw: String): List<String> =
raw.split(Regex("[,\\s]+"))
.map { it.trim() }
.filter { it.isNotEmpty() }
}

View File

@@ -0,0 +1,128 @@
package me.hgsky.synq.ui.history
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import me.hgsky.synq.data.db.CaptureEntity
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryScreen(nav: NavController, vm: HistoryViewModel = viewModel()) {
val captures by vm.captures.collectAsState(initial = emptyList())
Scaffold(
topBar = {
TopAppBar(
title = { Text("history") },
navigationIcon = {
IconButton(onClick = { nav.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "back")
}
},
)
},
) { padding ->
if (captures.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(padding),
contentAlignment = Alignment.Center,
) { Text("no captures yet") }
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(captures, key = { it.id }) { capture ->
CaptureRow(capture, onRetry = { vm.retryCapture(capture) })
}
}
}
}
}
@Composable
private fun CaptureRow(capture: CaptureEntity, onRetry: () -> Unit) {
val statusColor = when (capture.status) {
"synced" -> Color(0xFF2E7D32)
"failed" -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
capture.kind.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
Text(
capture.status,
style = MaterialTheme.typography.labelSmall,
color = statusColor,
)
if (capture.status == "pending" || capture.status == "failed") {
Button(onClick = onRetry, contentPadding = PaddingValues(horizontal = 8.dp)) {
Text("retry", style = MaterialTheme.typography.labelSmall)
}
}
}
Text(
capture.body,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
capture.createdAt,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (capture.lastError != null) {
Text(
capture.lastError,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}

View File

@@ -0,0 +1,22 @@
package me.hgsky.synq.ui.history
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import me.hgsky.synq.SynqApp
import me.hgsky.synq.data.db.CaptureEntity
class HistoryViewModel(app: Application) : AndroidViewModel(app) {
private val dao = (app as SynqApp).db.captureDao()
val captures: Flow<List<CaptureEntity>> = dao.observeAll()
fun retryCapture(capture: CaptureEntity) {
viewModelScope.launch {
dao.updateStatus(capture.id, "pending", null)
}
}
}

View File

@@ -0,0 +1,90 @@
package me.hgsky.synq.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import me.hgsky.synq.data.SynqSettings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(nav: NavController, vm: SettingsViewModel = viewModel()) {
val saved by vm.settings.collectAsState()
var url by remember(saved.serverUrl) { mutableStateOf(saved.serverUrl) }
var token by remember(saved.token) { mutableStateOf(saved.token) }
var device by remember(saved.deviceLabel) { mutableStateOf(saved.deviceLabel) }
fun saveAndBack() {
vm.save(SynqSettings(url.trim(), token.trim(), device.trim().ifEmpty { "android" }))
nav.navigateUp()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("settings") },
navigationIcon = {
IconButton(onClick = ::saveAndBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "back")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = url,
onValueChange = { url = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("server url") },
singleLine = true,
)
OutlinedTextField(
value = token,
onValueChange = { token = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("bearer token") },
singleLine = true,
)
OutlinedTextField(
value = device,
onValueChange = { device = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("device label") },
singleLine = true,
)
Button(
onClick = ::saveAndBack,
modifier = Modifier.fillMaxWidth(),
) { Text("save") }
}
}
}

View File

@@ -0,0 +1,25 @@
package me.hgsky.synq.ui.settings
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import me.hgsky.synq.SynqApp
import me.hgsky.synq.data.SynqSettings
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
private val repo = (app as SynqApp).settings
val settings = repo.settings.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
SynqSettings(),
)
fun save(settings: SynqSettings) {
viewModelScope.launch { repo.save(settings) }
}
}

View File

@@ -0,0 +1,14 @@
package me.hgsky.synq.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@Composable
fun SynqTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
val colorScheme = dynamicLightColorScheme(context)
MaterialTheme(colorScheme = colorScheme, content = content)
}

View File

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

View File

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

7
android/build.gradle.kts Normal file
View 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
}

View 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

View 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")

View File

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