Compare commits
29 Commits
d606a59a92
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 62c9b8e59d | |||
| a4591655b7 | |||
| 5a7542fb4c | |||
| 9989d81b51 | |||
| 7e0e7e4c29 | |||
| 28d96bd3c1 | |||
| adc7fb54d9 | |||
| 4adca319be | |||
| 1d31556923 | |||
| 628a28a775 | |||
| fca3ea6f60 | |||
| 68deb5cb3e | |||
| edd2c1745f | |||
| 4597f92d93 | |||
| a37ef4a794 | |||
| 905e618d20 | |||
| 415742b974 | |||
| a6af2fe5b9 | |||
| 23df95dacf | |||
| 16a18be9e3 | |||
| fb2474ba21 | |||
| 3c585c2f6b | |||
| 37d424ef60 | |||
| 66155f6141 | |||
| 4ea8e1c2ff | |||
| 65cb8ee2d5 | |||
| fd28a45cd7 | |||
| df9485bd98 | |||
| b907fdbbc3 |
22
.claude/settings.local.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(\"C:\\\\Users\\\\moses\\\\projects\\\\synq\\\\venv\\\\Scripts\\\\pip.exe\" install *)",
|
||||||
|
"Bash(\"C:\\\\Users\\\\moses\\\\projects\\\\synq\\\\venv\\\\Scripts\\\\python.exe\" -m pytest tests/ -v)",
|
||||||
|
"Bash(git add *)",
|
||||||
|
"Bash(git commit -m ' *)",
|
||||||
|
"Bash(/synq/venv/bin/python -m pytest /synq/.claude/worktrees/serene-mclean-b76496/server/tests/ -v)",
|
||||||
|
"Read(//c/mnt/**)",
|
||||||
|
"Read(//proc/**)",
|
||||||
|
"Read(//c/Users/moses/projects/synq/venv/Scripts//**)",
|
||||||
|
"Read(//c/Users/moses/projects/synq/venv/**)",
|
||||||
|
"Bash(/c/Users/moses/projects/synq/venv/Scripts/python.exe -m pytest tests/ -v)",
|
||||||
|
"Bash(ls \"/c/Users/moses/AppData/Local/Android/Sdk/platforms/\" 2>/dev/null && echo \"FOUND at AppData/Local/Android/Sdk\")",
|
||||||
|
"Bash(/c/Users/moses/projects/synq/venv/Scripts/python.exe -m pytest tests/ -q)",
|
||||||
|
"Bash(git stash *)",
|
||||||
|
"Bash(git merge *)",
|
||||||
|
"Bash(git push *)",
|
||||||
|
"Bash(git commit *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
6
.gitignore
vendored
@@ -23,6 +23,10 @@ android/captures/
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.iml
|
*.iml
|
||||||
|
|
||||||
# os
|
# os and emacs
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
/archive
|
||||||
|
|
||||||
|
# claude code
|
||||||
|
.claude/
|
||||||
|
|||||||
179
README.md
@@ -1,21 +1,17 @@
|
|||||||
# synq, a tiny android-to-org capture system.
|
# synq, a tiny android-to-org capture system.
|
||||||
|
synq consists of an Android app for capturing notes items and a docker agent that pulls notes and saves them to a local .org file.
|
||||||
|
|
||||||
open app.
|
1. Open Synq app
|
||||||
type, optionally mark as todo, optionally add tags, save.
|
2. Type note, mark as TODO (opt), add tags (opt), save.
|
||||||
if home server accessible, post unsynced captures to lan-only service and appended to `phone.org`.
|
3. If home server accessible, post unsynced captures to lan-only service and appended to `synq.org`.
|
||||||
|
|
||||||
## non-goals
|
<p>
|
||||||
|
<img src="docs/home-v1.png" alt="home-screen-v1" width="220">
|
||||||
|
<img src="docs/history-v1.png" alt="history-screen-v1" width="220">
|
||||||
|
<img src="docs/settings-v1.png" alt="settings-v1" width="220">
|
||||||
|
</p>
|
||||||
|
|
||||||
- no org parser on android
|
## Architecture
|
||||||
- no agenda on android
|
|
||||||
- no sync provider dependency
|
|
||||||
- no nextcloud file editing
|
|
||||||
- no conflict resolution
|
|
||||||
- no public internet exposure
|
|
||||||
- no ai tagging in v1
|
|
||||||
- no account system in v1
|
|
||||||
|
|
||||||
## architecture
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
android app
|
android app
|
||||||
@@ -29,142 +25,43 @@ home server
|
|||||||
python + fastapi
|
python + fastapi
|
||||||
sqlite idempotency store
|
sqlite idempotency store
|
||||||
append-only org writer
|
append-only org writer
|
||||||
|
|
||||||
flow
|
|
||||||
user saves capture locally
|
|
||||||
app marks it pending
|
|
||||||
sync runs when server is reachable
|
|
||||||
app posts pending captures to fastapi
|
|
||||||
server validates, dedupes by id, appends to phone.org
|
|
||||||
app marks capture synced
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## v1 behavior
|
1. user saves capture locally
|
||||||
|
2. app marks it pending
|
||||||
|
3. sync runs when server is reachable
|
||||||
|
4. app posts pending captures to fastapi
|
||||||
|
5. server validates, dedupes by id, appends to phone.org
|
||||||
|
6. app marks capture synced
|
||||||
|
|
||||||
the app launches into a focused text box. the user can type immediately.
|
|
||||||
|
|
||||||
controls:
|
## Build & Deploy
|
||||||
|
First, clone the repo.
|
||||||
|
|
||||||
- note/todo toggle
|
Build and deploy `synq-server`, the docker container that listens for new notes and writes to the local file.
|
||||||
- optional tags field
|
|
||||||
- save
|
|
||||||
- save and close
|
|
||||||
- sync now
|
|
||||||
- small history view showing pending, synced, and failed entries
|
|
||||||
|
|
||||||
each capture has:
|
Then, build and install the `synq` app.
|
||||||
|
|
||||||
- stable client-generated id
|
Finally, retrieve the token (`token.txt` in the docker container's data directory, or from the container logs `docker logs synq-server`)
|
||||||
- created timestamp with timezone
|
|
||||||
- kind: `note` or `todo`
|
|
||||||
- body text
|
|
||||||
- tags
|
|
||||||
- device name
|
|
||||||
- sync status
|
|
||||||
- optional last error
|
|
||||||
|
|
||||||
## org output
|
### 1. synq-server (docker)
|
||||||
|
- Build from `docker build -t synq-server ./server`
|
||||||
todo:
|
- Run (replace paths and token as needed):
|
||||||
|
```docker
|
||||||
```org
|
docker run -d --name synq --restart=unless-stopped \
|
||||||
* TODO buy printer paper :home:errands:
|
-p 8765:8765 \
|
||||||
:PROPERTIES:
|
-v /mnt/user/synq/data:/data \
|
||||||
:CREATED: [2026-05-17 sun 14:31]
|
synq-server
|
||||||
:SOURCE: android
|
|
||||||
:ID: phone-20260517-143122-a8f2
|
|
||||||
:END:
|
|
||||||
```
|
```
|
||||||
|
|
||||||
note:
|
The server generates a random token on first start and logs it prominently. Copy it into the android app settings. To use a fixed token instead, pass `-e PHONE_CAPTURE_TOKEN=yourtoken`.
|
||||||
|
|
||||||
```org
|
### 2. synq (android)
|
||||||
* note :retcon:
|
Download apk from Releases, on the right
|
||||||
:PROPERTIES:
|
|
||||||
:CREATED: [2026-05-17 sun 14:33]
|
|
||||||
:SOURCE: android
|
|
||||||
:ID: phone-20260517-143322-b91c
|
|
||||||
:END:
|
|
||||||
|
|
||||||
mobile capture should stay dumb and append-only.
|
Or, build from source:
|
||||||
```
|
Open `android/` in Android Studio and hit **Sync**. then either:
|
||||||
|
- **run on device/emulator directly** from Android Studio (▶), or
|
||||||
## server paths
|
- **build a release APK**
|
||||||
|
Then sideload to a connected device:
|
||||||
default container paths:
|
- terminal: `adb install app/build/outputs/apk/release/app-release-unsigned.apk`
|
||||||
|
|
||||||
```text
|
|
||||||
/data/synq.org
|
|
||||||
/data/capture.sqlite3
|
|
||||||
/data/rejected.log
|
|
||||||
```
|
|
||||||
|
|
||||||
recommended unraid host mapping:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/mnt/user/synq/phone-capture:/data
|
|
||||||
```
|
|
||||||
|
|
||||||
or map `/data/synq.org` directly to wherever the real org file lives. prefer a dedicated capture file first; emacs can include it in agenda later.
|
|
||||||
|
|
||||||
## security model
|
|
||||||
|
|
||||||
v1 is lan-only. bind the service to the host lan and do not expose it through swag/cloudflare/public dns.
|
|
||||||
|
|
||||||
minimum useful controls:
|
|
||||||
|
|
||||||
- shared bearer token in app and server env
|
|
||||||
- server only accepts json
|
|
||||||
- server rejects empty body
|
|
||||||
- server dedupes ids
|
|
||||||
- server writes append-only org
|
|
||||||
- server never edits or parses existing org
|
|
||||||
- docker volume is backed up
|
|
||||||
|
|
||||||
## repo layout
|
|
||||||
|
|
||||||
suggested monorepo:
|
|
||||||
|
|
||||||
```text
|
|
||||||
synq/
|
|
||||||
android/
|
|
||||||
app/
|
|
||||||
server/
|
|
||||||
app/
|
|
||||||
main.py
|
|
||||||
models.py
|
|
||||||
org_writer.py
|
|
||||||
store.py
|
|
||||||
tests/
|
|
||||||
Dockerfile
|
|
||||||
pyproject.toml
|
|
||||||
docs/
|
|
||||||
api.md
|
|
||||||
android-notes.md
|
|
||||||
server-notes.md
|
|
||||||
docker-compose.example.yml
|
|
||||||
.env.example
|
|
||||||
tasks.org
|
|
||||||
README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## build order
|
|
||||||
|
|
||||||
1. implement server first using curl tests.
|
|
||||||
2. implement android local capture with room.
|
|
||||||
3. implement manual sync.
|
|
||||||
4. add workmanager opportunistic sync.
|
|
||||||
5. add history screen and resend handling.
|
|
||||||
6. polish launch speed and widget/share-target only after core path works.
|
|
||||||
|
|
||||||
## v2 parking lot
|
|
||||||
|
|
||||||
- android share target
|
|
||||||
- quick settings tile or widget
|
|
||||||
- tag chips from recent tags
|
|
||||||
- configurable default tag
|
|
||||||
- edit unsynced entries only
|
|
||||||
- multi-device capture
|
|
||||||
- wireguard-aware sync
|
|
||||||
- local export/import
|
|
||||||
- optional emacs ingest helpers
|
|
||||||
|
|||||||
@@ -14,13 +14,17 @@ android {
|
|||||||
applicationId = "me.hgsky.synq"
|
applicationId = "me.hgsky.synq"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 2
|
||||||
versionName = "0.1.0"
|
versionName = "1.0.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Keep Room entities and DAOs
|
||||||
|
-keep class me.hgsky.synq.data.db.** { *; }
|
||||||
|
|
||||||
|
# Keep Retrofit/OkHttp
|
||||||
|
-dontwarn okhttp3.**
|
||||||
|
-keep class okhttp3.** { *; }
|
||||||
|
|
||||||
|
# Keep kotlinx.serialization
|
||||||
|
-keepattributes *Annotation*, InnerClasses
|
||||||
|
-dontnote kotlinx.serialization.AnnotationsKt
|
||||||
|
-keepclassmembers class kotlinx.serialization.json.** { *** Companion; }
|
||||||
|
-keepclasseswithmembers class **$$serializer { *; }
|
||||||
|
-keepclassmembers @kotlinx.serialization.Serializable class ** {
|
||||||
|
*** Companion;
|
||||||
|
*** serializer(...);
|
||||||
|
}
|
||||||
@@ -9,8 +9,9 @@
|
|||||||
android:theme="@style/Theme.Synq"
|
android:theme="@style/Theme.Synq"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -19,6 +20,11 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
BIN
android/app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
@@ -1,33 +1,55 @@
|
|||||||
package me.hgsky.synq
|
package me.hgsky.synq
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
import me.hgsky.synq.ui.capture.CaptureScreen
|
import me.hgsky.synq.ui.capture.CaptureScreen
|
||||||
import me.hgsky.synq.ui.history.HistoryScreen
|
import me.hgsky.synq.ui.history.HistoryScreen
|
||||||
import me.hgsky.synq.ui.settings.SettingsScreen
|
import me.hgsky.synq.ui.settings.SettingsScreen
|
||||||
import me.hgsky.synq.ui.theme.SynqTheme
|
import me.hgsky.synq.ui.theme.SynqTheme
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
val sharedText = if (intent.action == Intent.ACTION_SEND)
|
||||||
|
intent.getStringExtra(Intent.EXTRA_TEXT) else null
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
SynqTheme { SynqNav() }
|
SynqTheme { SynqNav(sharedText = sharedText) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SynqNav() {
|
private fun SynqNav(sharedText: String? = null) {
|
||||||
val nav = rememberNavController()
|
val nav = rememberNavController()
|
||||||
NavHost(navController = nav, startDestination = "capture") {
|
val startRoute = if (sharedText != null) {
|
||||||
|
"capture/${URLEncoder.encode(sharedText, "UTF-8")}"
|
||||||
|
} else {
|
||||||
|
"capture"
|
||||||
|
}
|
||||||
|
|
||||||
|
NavHost(navController = nav, startDestination = startRoute) {
|
||||||
composable("capture") { CaptureScreen(nav) }
|
composable("capture") { CaptureScreen(nav) }
|
||||||
|
composable(
|
||||||
|
route = "capture/{prefill}",
|
||||||
|
arguments = listOf(navArgument("prefill") { type = NavType.StringType }),
|
||||||
|
) { back ->
|
||||||
|
val prefill = back.arguments?.getString("prefill")
|
||||||
|
?.let { URLDecoder.decode(it, "UTF-8") }
|
||||||
|
CaptureScreen(nav, prefill = prefill)
|
||||||
|
}
|
||||||
composable("history") { HistoryScreen(nav) }
|
composable("history") { HistoryScreen(nav) }
|
||||||
composable("settings") { SettingsScreen(nav) }
|
composable("settings") { SettingsScreen(nav) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package me.hgsky.synq.data
|
package me.hgsky.synq.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -13,6 +16,7 @@ data class SynqSettings(
|
|||||||
val serverUrl: String = "http://jeeves.mother:8765",
|
val serverUrl: String = "http://jeeves.mother:8765",
|
||||||
val token: String = "",
|
val token: String = "",
|
||||||
val deviceLabel: String = "android",
|
val deviceLabel: String = "android",
|
||||||
|
val syncIntervalMinutes: Int = 15,
|
||||||
)
|
)
|
||||||
|
|
||||||
class SettingsRepository(private val context: Context) {
|
class SettingsRepository(private val context: Context) {
|
||||||
@@ -20,12 +24,17 @@ class SettingsRepository(private val context: Context) {
|
|||||||
private val KEY_URL = stringPreferencesKey("server_url")
|
private val KEY_URL = stringPreferencesKey("server_url")
|
||||||
private val KEY_TOKEN = stringPreferencesKey("token")
|
private val KEY_TOKEN = stringPreferencesKey("token")
|
||||||
private val KEY_DEVICE = stringPreferencesKey("device_label")
|
private val KEY_DEVICE = stringPreferencesKey("device_label")
|
||||||
|
private val KEY_INTERVAL = intPreferencesKey("sync_interval_minutes")
|
||||||
|
|
||||||
val settings: Flow<SynqSettings> = context.dataStore.data.map { prefs ->
|
val settings: Flow<SynqSettings> = context.dataStore.data.map { prefs ->
|
||||||
SynqSettings(
|
SynqSettings(
|
||||||
serverUrl = prefs[KEY_URL] ?: "http://jeeves.mother:8765",
|
serverUrl = prefs[KEY_URL] ?: "http://jeeves.mother:8765",
|
||||||
token = prefs[KEY_TOKEN] ?: "",
|
token = prefs[KEY_TOKEN] ?: "",
|
||||||
deviceLabel = prefs[KEY_DEVICE] ?: "android",
|
deviceLabel = prefs[KEY_DEVICE] ?: (
|
||||||
|
Settings.Global.getString(context.contentResolver, Settings.Global.DEVICE_NAME)
|
||||||
|
?.takeIf { it.isNotBlank() } ?: Build.MODEL
|
||||||
|
),
|
||||||
|
syncIntervalMinutes = prefs[KEY_INTERVAL] ?: 15,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +43,7 @@ class SettingsRepository(private val context: Context) {
|
|||||||
prefs[KEY_URL] = settings.serverUrl
|
prefs[KEY_URL] = settings.serverUrl
|
||||||
prefs[KEY_TOKEN] = settings.token
|
prefs[KEY_TOKEN] = settings.token
|
||||||
prefs[KEY_DEVICE] = settings.deviceLabel
|
prefs[KEY_DEVICE] = settings.deviceLabel
|
||||||
|
prefs[KEY_INTERVAL] = settings.syncIntervalMinutes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,4 +27,13 @@ interface CaptureDao {
|
|||||||
|
|
||||||
@Query("UPDATE captures SET status = 'synced', syncedAt = :syncedAt, lastError = NULL WHERE id = :id")
|
@Query("UPDATE captures SET status = 'synced', syncedAt = :syncedAt, lastError = NULL WHERE id = :id")
|
||||||
suspend fun markSynced(id: String, syncedAt: String)
|
suspend fun markSynced(id: String, syncedAt: String)
|
||||||
|
|
||||||
|
@Query("SELECT MAX(syncedAt) FROM captures WHERE syncedAt IS NOT NULL")
|
||||||
|
fun getLastSyncedAt(): Flow<String?>
|
||||||
|
|
||||||
|
@Query("SELECT tagsJson FROM captures WHERE tagsJson != '[]' AND tagsJson != '' ORDER BY createdAt DESC LIMIT 100")
|
||||||
|
fun getRecentTagsJson(): Flow<List<String>>
|
||||||
|
|
||||||
|
@Query("UPDATE captures SET body = :body WHERE id = :id")
|
||||||
|
suspend fun updateBody(id: String, body: String)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import me.hgsky.synq.data.db.CaptureDao
|
|||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
suspend fun syncPending(dao: CaptureDao, client: SynqApiClient, settings: SynqSettings) {
|
suspend fun syncPending(dao: CaptureDao, client: SynqApiClient, settings: SynqSettings): Int {
|
||||||
val pending = dao.getPendingAndFailed()
|
val pending = dao.getPendingAndFailed()
|
||||||
|
var synced = 0
|
||||||
for (capture in pending) {
|
for (capture in pending) {
|
||||||
dao.updateStatus(capture.id, "syncing", null)
|
dao.updateStatus(capture.id, "syncing", null)
|
||||||
val result = client.postCapture(capture, settings)
|
val result = client.postCapture(capture, settings)
|
||||||
@@ -16,8 +17,10 @@ suspend fun syncPending(dao: CaptureDao, client: SynqApiClient, settings: SynqSe
|
|||||||
is PostResult.Accepted, is PostResult.AlreadySeen -> {
|
is PostResult.Accepted, is PostResult.AlreadySeen -> {
|
||||||
val now = OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
|
val now = OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
|
||||||
dao.markSynced(capture.id, now)
|
dao.markSynced(capture.id, now)
|
||||||
|
synced++
|
||||||
}
|
}
|
||||||
is PostResult.Failed -> dao.updateStatus(capture.id, "failed", result.error)
|
is PostResult.Failed -> dao.updateStatus(capture.id, "failed", result.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return synced
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,18 +29,20 @@ class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
|
|||||||
companion object {
|
companion object {
|
||||||
private const val WORK_NAME = "synq_periodic_sync"
|
private const val WORK_NAME = "synq_periodic_sync"
|
||||||
|
|
||||||
fun schedule(context: Context) {
|
fun schedule(context: Context, intervalMinutes: Int = 15) {
|
||||||
val constraints = Constraints.Builder()
|
val constraints = Constraints.Builder()
|
||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val request = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
|
val request = PeriodicWorkRequestBuilder<SyncWorker>(
|
||||||
|
intervalMinutes.toLong().coerceAtLeast(15), TimeUnit.MINUTES,
|
||||||
|
)
|
||||||
.setConstraints(constraints)
|
.setConstraints(constraints)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||||
WORK_NAME,
|
WORK_NAME,
|
||||||
ExistingPeriodicWorkPolicy.KEEP,
|
ExistingPeriodicWorkPolicy.REPLACE,
|
||||||
request,
|
request,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
package me.hgsky.synq.ui.capture
|
package me.hgsky.synq.ui.capture
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.History
|
import androidx.compose.material.icons.filled.History
|
||||||
@@ -18,12 +23,15 @@ import androidx.compose.material.icons.filled.Sync
|
|||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@@ -41,17 +49,47 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CaptureScreen(nav: NavController, vm: CaptureViewModel = viewModel()) {
|
fun CaptureScreen(
|
||||||
|
nav: NavController,
|
||||||
|
prefill: String? = null,
|
||||||
|
vm: CaptureViewModel = viewModel(),
|
||||||
|
) {
|
||||||
val state by vm.state.collectAsState()
|
val state by vm.state.collectAsState()
|
||||||
|
val recentTags by vm.recentTags.collectAsState()
|
||||||
|
val lastSyncedAt by vm.lastSyncedAt.collectAsState()
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val snackbarState = remember { SnackbarHostState() }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
vm.snackbar.collect { msg -> snackbarState.showSnackbar(msg) }
|
||||||
|
}
|
||||||
|
LaunchedEffect(prefill) {
|
||||||
|
if (!prefill.isNullOrEmpty()) vm.setBody(prefill)
|
||||||
|
}
|
||||||
|
|
||||||
|
val activeTags = remember(state.tags) {
|
||||||
|
state.tags.split(Regex("[,\\s]+")).filter { it.isNotEmpty() }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
val lastSyncedText = remember(lastSyncedAt) {
|
||||||
|
lastSyncedAt?.let { iso ->
|
||||||
|
runCatching {
|
||||||
|
val dt = OffsetDateTime.parse(iso)
|
||||||
|
"synced " + dt.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT))
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarState, modifier = Modifier.imePadding()) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("synq") },
|
title = { Text("synq") },
|
||||||
@@ -83,6 +121,7 @@ fun CaptureScreen(nav: NavController, vm: CaptureViewModel = viewModel()) {
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
|
.imePadding()
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
@@ -120,6 +159,29 @@ fun CaptureScreen(nav: NavController, vm: CaptureViewModel = viewModel()) {
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (recentTags.isNotEmpty()) {
|
||||||
|
FlowRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
recentTags.forEach { tag ->
|
||||||
|
FilterChip(
|
||||||
|
selected = tag in activeTags,
|
||||||
|
onClick = { vm.toggleTag(tag) },
|
||||||
|
label = { Text(tag, style = MaterialTheme.typography.labelSmall) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastSyncedText != null) {
|
||||||
|
Text(
|
||||||
|
lastSyncedText,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import android.app.Application
|
|||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -34,10 +39,34 @@ class CaptureViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
private val _state = MutableStateFlow(CaptureUiState())
|
private val _state = MutableStateFlow(CaptureUiState())
|
||||||
val state: StateFlow<CaptureUiState> = _state.asStateFlow()
|
val state: StateFlow<CaptureUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _snackbar = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||||
|
val snackbar = _snackbar.asSharedFlow()
|
||||||
|
|
||||||
|
val recentTags: StateFlow<List<String>> = dao.getRecentTagsJson()
|
||||||
|
.map { jsonList ->
|
||||||
|
val seen = LinkedHashSet<String>()
|
||||||
|
for (json in jsonList) {
|
||||||
|
runCatching { Json.decodeFromString<List<String>>(json) }
|
||||||
|
.getOrElse { emptyList() }
|
||||||
|
.forEach { seen.add(it) }
|
||||||
|
}
|
||||||
|
seen.take(20).toList()
|
||||||
|
}
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
|
val lastSyncedAt: StateFlow<String?> = dao.getLastSyncedAt()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
fun setBody(v: String) { _state.value = _state.value.copy(body = v) }
|
fun setBody(v: String) { _state.value = _state.value.copy(body = v) }
|
||||||
fun setTodo(v: Boolean) { _state.value = _state.value.copy(isTodo = v) }
|
fun setTodo(v: Boolean) { _state.value = _state.value.copy(isTodo = v) }
|
||||||
fun setTags(v: String) { _state.value = _state.value.copy(tags = v) }
|
fun setTags(v: String) { _state.value = _state.value.copy(tags = v) }
|
||||||
|
|
||||||
|
fun toggleTag(tag: String) {
|
||||||
|
val current = parseTags(_state.value.tags).toMutableList()
|
||||||
|
if (current.contains(tag)) current.remove(tag) else current.add(tag)
|
||||||
|
_state.value = _state.value.copy(tags = current.joinToString(" "))
|
||||||
|
}
|
||||||
|
|
||||||
fun save(onDone: () -> Unit = {}) {
|
fun save(onDone: () -> Unit = {}) {
|
||||||
val s = _state.value
|
val s = _state.value
|
||||||
val body = s.body.trim()
|
val body = s.body.trim()
|
||||||
@@ -69,9 +98,12 @@ class CaptureViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
try {
|
try {
|
||||||
val settings = synqApp.settings.settings.first()
|
val settings = synqApp.settings.settings.first()
|
||||||
val client = SynqApiClient()
|
val client = SynqApiClient()
|
||||||
if (client.checkHealth(settings.serverUrl)) {
|
if (!client.checkHealth(settings.serverUrl)) {
|
||||||
syncPending(dao, client, settings)
|
_snackbar.tryEmit("server unreachable")
|
||||||
|
return@launch
|
||||||
}
|
}
|
||||||
|
val synced = syncPending(dao, client, settings)
|
||||||
|
_snackbar.tryEmit(if (synced == 0) "nothing to sync" else "synced $synced")
|
||||||
} finally {
|
} finally {
|
||||||
_state.value = _state.value.copy(isSyncing = false)
|
_state.value = _state.value.copy(isSyncing = false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package me.hgsky.synq.ui.history
|
package me.hgsky.synq.ui.history
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -12,18 +13,26 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -32,16 +41,40 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import me.hgsky.synq.data.db.CaptureEntity
|
import me.hgsky.synq.data.db.CaptureEntity
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HistoryScreen(nav: NavController, vm: HistoryViewModel = viewModel()) {
|
fun HistoryScreen(nav: NavController, vm: HistoryViewModel = viewModel()) {
|
||||||
val captures by vm.captures.collectAsState(initial = emptyList())
|
val captures by vm.captures.collectAsState(initial = emptyList())
|
||||||
|
val lastSyncedAt by vm.lastSyncedAt.collectAsState()
|
||||||
|
|
||||||
|
val lastSyncedText = remember(lastSyncedAt) {
|
||||||
|
lastSyncedAt?.let { iso ->
|
||||||
|
runCatching {
|
||||||
|
val dt = OffsetDateTime.parse(iso)
|
||||||
|
"last synced " + dt.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT))
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("history") },
|
title = {
|
||||||
|
Column {
|
||||||
|
Text("history")
|
||||||
|
if (lastSyncedText != null) {
|
||||||
|
Text(
|
||||||
|
lastSyncedText,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { nav.navigateUp() }) {
|
IconButton(onClick = { nav.navigateUp() }) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "back")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "back")
|
||||||
@@ -62,7 +95,11 @@ fun HistoryScreen(nav: NavController, vm: HistoryViewModel = viewModel()) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
items(captures, key = { it.id }) { capture ->
|
items(captures, key = { it.id }) { capture ->
|
||||||
CaptureRow(capture, onRetry = { vm.retryCapture(capture) })
|
CaptureRow(
|
||||||
|
capture = capture,
|
||||||
|
onRetry = { vm.retryCapture(capture) },
|
||||||
|
onEditConfirm = { newBody -> vm.updateBody(capture, newBody) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,14 +107,56 @@ fun HistoryScreen(nav: NavController, vm: HistoryViewModel = viewModel()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CaptureRow(capture: CaptureEntity, onRetry: () -> Unit) {
|
private fun CaptureRow(
|
||||||
|
capture: CaptureEntity,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onEditConfirm: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by rememberSaveable(capture.id) { mutableStateOf(false) }
|
||||||
|
var showEdit by remember { mutableStateOf(false) }
|
||||||
|
var editBody by remember(capture.body) { mutableStateOf(capture.body) }
|
||||||
|
|
||||||
val statusColor = when (capture.status) {
|
val statusColor = when (capture.status) {
|
||||||
"synced" -> Color(0xFF2E7D32)
|
"synced" -> Color(0xFF2E7D32)
|
||||||
"failed" -> MaterialTheme.colorScheme.error
|
"failed" -> MaterialTheme.colorScheme.error
|
||||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
}
|
||||||
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
val editable = capture.status == "pending" || capture.status == "failed"
|
||||||
|
|
||||||
|
if (showEdit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showEdit = false },
|
||||||
|
title = { Text("edit capture") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = editBody,
|
||||||
|
onValueChange = { editBody = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("body") },
|
||||||
|
minLines = 3,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onEditConfirm(editBody)
|
||||||
|
showEdit = false
|
||||||
|
},
|
||||||
|
enabled = editBody.isNotBlank(),
|
||||||
|
) { Text("save") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showEdit = false }) { Text("cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { expanded = !expanded },
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(12.dp),
|
modifier = Modifier.padding(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
@@ -96,18 +175,29 @@ private fun CaptureRow(capture: CaptureEntity, onRetry: () -> Unit) {
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = statusColor,
|
color = statusColor,
|
||||||
)
|
)
|
||||||
if (capture.status == "pending" || capture.status == "failed") {
|
if (editable) {
|
||||||
Button(onClick = onRetry, contentPadding = PaddingValues(horizontal = 8.dp)) {
|
OutlinedButton(
|
||||||
|
onClick = onRetry,
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||||
|
) {
|
||||||
Text("retry", style = MaterialTheme.typography.labelSmall)
|
Text("retry", style = MaterialTheme.typography.labelSmall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (editable && expanded) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showEdit = true },
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||||
|
) {
|
||||||
|
Text("edit", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
capture.body,
|
capture.body,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = 2,
|
maxLines = if (expanded) Int.MAX_VALUE else 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import android.app.Application
|
|||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.hgsky.synq.SynqApp
|
import me.hgsky.synq.SynqApp
|
||||||
import me.hgsky.synq.data.db.CaptureEntity
|
import me.hgsky.synq.data.db.CaptureEntity
|
||||||
@@ -14,9 +17,18 @@ class HistoryViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
|
|
||||||
val captures: Flow<List<CaptureEntity>> = dao.observeAll()
|
val captures: Flow<List<CaptureEntity>> = dao.observeAll()
|
||||||
|
|
||||||
|
val lastSyncedAt: StateFlow<String?> = dao.getLastSyncedAt()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
fun retryCapture(capture: CaptureEntity) {
|
fun retryCapture(capture: CaptureEntity) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
dao.updateStatus(capture.id, "pending", null)
|
dao.updateStatus(capture.id, "pending", null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateBody(capture: CaptureEntity, newBody: String) {
|
||||||
|
val trimmed = newBody.trim()
|
||||||
|
if (trimmed.isEmpty()) return
|
||||||
|
viewModelScope.launch { dao.updateBody(capture.id, trimmed) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -29,6 +30,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
@@ -43,9 +45,17 @@ fun SettingsScreen(nav: NavController, vm: SettingsViewModel = viewModel()) {
|
|||||||
var url by remember(saved.serverUrl) { mutableStateOf(saved.serverUrl) }
|
var url by remember(saved.serverUrl) { mutableStateOf(saved.serverUrl) }
|
||||||
var token by remember(saved.token) { mutableStateOf(saved.token) }
|
var token by remember(saved.token) { mutableStateOf(saved.token) }
|
||||||
var device by remember(saved.deviceLabel) { mutableStateOf(saved.deviceLabel) }
|
var device by remember(saved.deviceLabel) { mutableStateOf(saved.deviceLabel) }
|
||||||
|
var interval by remember(saved.syncIntervalMinutes) { mutableStateOf(saved.syncIntervalMinutes.toString()) }
|
||||||
|
|
||||||
|
fun buildSettings() = SynqSettings(
|
||||||
|
serverUrl = url.trim(),
|
||||||
|
token = token.trim(),
|
||||||
|
deviceLabel = device.trim().ifEmpty { "android" },
|
||||||
|
syncIntervalMinutes = interval.toIntOrNull()?.coerceAtLeast(15) ?: 15,
|
||||||
|
)
|
||||||
|
|
||||||
fun saveAndBack() {
|
fun saveAndBack() {
|
||||||
vm.save(SynqSettings(url.trim(), token.trim(), device.trim().ifEmpty { "android" }))
|
vm.save(buildSettings())
|
||||||
nav.navigateUp()
|
nav.navigateUp()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +99,15 @@ fun SettingsScreen(nav: NavController, vm: SettingsViewModel = viewModel()) {
|
|||||||
label = { Text("device label") },
|
label = { Text("device label") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
)
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = interval,
|
||||||
|
onValueChange = { v -> if (v.length <= 4 && v.all { it.isDigit() }) interval = v },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("sync interval (minutes, min 15)") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
supportingText = { Text("WorkManager minimum is 15 min") },
|
||||||
|
)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -97,7 +116,7 @@ fun SettingsScreen(nav: NavController, vm: SettingsViewModel = viewModel()) {
|
|||||||
) {
|
) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
vm.save(SynqSettings(url.trim(), token.trim(), device.trim().ifEmpty { "android" }))
|
vm.save(buildSettings())
|
||||||
vm.checkConnection(url.trim())
|
vm.checkConnection(url.trim())
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.launch
|
|||||||
import me.hgsky.synq.SynqApp
|
import me.hgsky.synq.SynqApp
|
||||||
import me.hgsky.synq.data.SynqSettings
|
import me.hgsky.synq.data.SynqSettings
|
||||||
import me.hgsky.synq.data.api.SynqApiClient
|
import me.hgsky.synq.data.api.SynqApiClient
|
||||||
|
import me.hgsky.synq.data.sync.SyncWorker
|
||||||
|
|
||||||
sealed class PingState {
|
sealed class PingState {
|
||||||
object Idle : PingState()
|
object Idle : PingState()
|
||||||
@@ -23,7 +24,8 @@ sealed class PingState {
|
|||||||
|
|
||||||
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
private val repo = (app as SynqApp).settings
|
private val synqApp = app as SynqApp
|
||||||
|
private val repo = synqApp.settings
|
||||||
|
|
||||||
val settings = repo.settings.stateIn(
|
val settings = repo.settings.stateIn(
|
||||||
viewModelScope,
|
viewModelScope,
|
||||||
@@ -35,7 +37,10 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val ping: StateFlow<PingState> = _ping.asStateFlow()
|
val ping: StateFlow<PingState> = _ping.asStateFlow()
|
||||||
|
|
||||||
fun save(settings: SynqSettings) {
|
fun save(settings: SynqSettings) {
|
||||||
viewModelScope.launch { repo.save(settings) }
|
viewModelScope.launch {
|
||||||
|
repo.save(settings)
|
||||||
|
SyncWorker.schedule(synqApp, settings.syncIntervalMinutes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun checkConnection(url: String) {
|
fun checkConnection(url: String) {
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
package me.hgsky.synq.ui.theme
|
package me.hgsky.synq.ui.theme
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SynqTheme(content: @Composable () -> Unit) {
|
fun SynqTheme(content: @Composable () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val colorScheme = dynamicLightColorScheme(context)
|
val colorScheme = dynamicLightColorScheme(context)
|
||||||
|
|
||||||
|
val view = LocalView.current
|
||||||
|
if (!view.isInEditMode) {
|
||||||
|
SideEffect {
|
||||||
|
val window = (view.context as Activity).window
|
||||||
|
// light theme → dark icons so clock/battery/wifi are visible on light TopAppBar
|
||||||
|
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||||
}
|
}
|
||||||
|
|||||||
25
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright (C) 2026 The Android Open Source Project
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="512"
|
||||||
|
android:viewportHeight="512">
|
||||||
|
<path
|
||||||
|
android:pathData="M0,0.96h512v512h-512z"
|
||||||
|
android:strokeWidth="1.00157"
|
||||||
|
android:fillColor="#22569d"/>
|
||||||
|
</vector>
|
||||||
52
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright (C) 2026 The Android Open Source Project
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="512"
|
||||||
|
android:viewportHeight="512">
|
||||||
|
<path
|
||||||
|
android:pathData="M0,0.96h512v512h-512z"
|
||||||
|
android:strokeWidth="1.00157"
|
||||||
|
android:fillColor="#22569d"/>
|
||||||
|
<path
|
||||||
|
android:pathData="m189.14,130.52h133.72c7.25,0 13.08,5.83 13.08,13.08v220.93c0,7.25 -5.83,13.08 -13.08,13.08H189.14c-7.25,0 -13.08,-5.83 -13.08,-13.08V143.6c0,-7.25 5.83,-13.08 13.08,-13.08z"
|
||||||
|
android:strokeWidth="2.90698"
|
||||||
|
android:fillColor="#ffffff"
|
||||||
|
android:strokeColor="#00000000"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="m222.27,361.68h66.86c1.61,0 2.91,1.3 2.91,2.91 0,1.61 -1.3,2.91 -2.91,2.91h-66.86c-1.61,0 -2.91,-1.3 -2.91,-2.91 0,-1.61 1.3,-2.91 2.91,-2.91z"
|
||||||
|
android:strokeWidth="2.90698"
|
||||||
|
android:strokeColor="#000000"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M193.63,145.03H318.63c1.61,0 2.91,1.3 2.91,2.91v197.67c0,1.61 -1.3,2.91 -2.91,2.91H193.63c-1.61,0 -2.91,-1.3 -2.91,-2.91V147.94c0,-1.61 1.3,-2.91 2.91,-2.91z"
|
||||||
|
android:strokeWidth="2.90698"
|
||||||
|
android:fillColor="#22569d"
|
||||||
|
android:strokeColor="#00000000"/>
|
||||||
|
<path
|
||||||
|
android:pathData="m210.25,196.07h-11.2v-42.2h11.2v3.18h-7.43v35.84h7.43zM241.81,186.76h-5.11l-7.22,-11.99 -7.24,11.99h-5.04l9.88,-15.17 -9.08,-14.51h4.79l6.79,11.17 6.84,-11.17h4.66l-9.11,14.33zM260.1,196.07h-11.2v-3.18h7.38v-35.84h-7.38v-3.18h11.2zM280.54,179.61q0.77,0 1.45,0.3 0.7,0.3 1.2,0.82 0.52,0.52 0.82,1.23 0.3,0.68 0.3,1.48 0,0.77 -0.3,1.45 -0.3,0.68 -0.82,1.2 -0.5,0.5 -1.2,0.79 -0.68,0.3 -1.45,0.3 -0.79,0 -1.48,-0.3 -0.68,-0.3 -1.2,-0.79 -0.5,-0.52 -0.79,-1.2 -0.3,-0.68 -0.3,-1.45 0,-0.79 0.3,-1.48 0.3,-0.7 0.79,-1.23 0.52,-0.52 1.2,-0.82 0.68,-0.3 1.48,-0.3zM306.11,179.61q0.77,0 1.45,0.3 0.7,0.3 1.2,0.82 0.52,0.52 0.82,1.23 0.3,0.68 0.3,1.48 0,0.77 -0.3,1.45 -0.3,0.68 -0.82,1.2 -0.5,0.5 -1.2,0.79 -0.68,0.3 -1.45,0.3 -0.79,0 -1.48,-0.3 -0.68,-0.3 -1.2,-0.79 -0.5,-0.52 -0.79,-1.2 -0.3,-0.68 -0.3,-1.45 0,-0.79 0.3,-1.48 0.3,-0.7 0.79,-1.23 0.52,-0.52 1.2,-0.82 0.68,-0.3 1.48,-0.3z"
|
||||||
|
android:strokeWidth="0.726744"
|
||||||
|
android:fillColor="#00e219"/>
|
||||||
|
<path
|
||||||
|
android:pathData="m210.25,256.17h-11.2L199.05,213.98h11.2v3.18h-7.43v35.84h7.43zM241.81,246.86h-5.11l-7.22,-11.99 -7.24,11.99h-5.04l9.88,-15.17 -9.08,-14.51h4.79l6.79,11.17 6.84,-11.17h4.66l-9.11,14.33zM260.1,256.17h-11.2v-3.18h7.38v-35.84h-7.38v-3.18h11.2zM280.54,239.71q0.77,0 1.45,0.3 0.7,0.3 1.2,0.82 0.52,0.52 0.82,1.23 0.3,0.68 0.3,1.48 0,0.77 -0.3,1.45 -0.3,0.68 -0.82,1.2 -0.5,0.5 -1.2,0.79 -0.68,0.3 -1.45,0.3 -0.79,0 -1.48,-0.3 -0.68,-0.3 -1.2,-0.79 -0.5,-0.52 -0.79,-1.2 -0.3,-0.68 -0.3,-1.45 0,-0.79 0.3,-1.48 0.3,-0.7 0.79,-1.23 0.52,-0.52 1.2,-0.82 0.68,-0.3 1.48,-0.3zM306.11,239.71q0.77,0 1.45,0.3 0.7,0.3 1.2,0.82 0.52,0.52 0.82,1.23 0.3,0.68 0.3,1.48 0,0.77 -0.3,1.45 -0.3,0.68 -0.82,1.2 -0.5,0.5 -1.2,0.79 -0.68,0.3 -1.45,0.3 -0.79,0 -1.48,-0.3 -0.68,-0.3 -1.2,-0.79 -0.5,-0.52 -0.79,-1.2 -0.3,-0.68 -0.3,-1.45 0,-0.79 0.3,-1.48 0.3,-0.7 0.79,-1.23 0.52,-0.52 1.2,-0.82 0.68,-0.3 1.48,-0.3z"
|
||||||
|
android:strokeWidth="0.726744"
|
||||||
|
android:fillColor="#00e219"/>
|
||||||
|
<path
|
||||||
|
android:pathData="m210.25,316.28h-11.2v-42.2h11.2v3.18h-7.43v35.84h7.43zM260.1,316.28h-11.2v-3.18h7.38v-35.84h-7.38v-3.18h11.2zM280.54,299.81q0.77,0 1.45,0.3 0.7,0.3 1.2,0.82 0.52,0.52 0.82,1.23 0.3,0.68 0.3,1.48 0,0.77 -0.3,1.45 -0.3,0.68 -0.82,1.2 -0.5,0.5 -1.2,0.79 -0.68,0.3 -1.45,0.3 -0.79,0 -1.48,-0.3 -0.68,-0.3 -1.2,-0.79 -0.5,-0.52 -0.79,-1.2 -0.3,-0.68 -0.3,-1.45 0,-0.79 0.3,-1.48 0.3,-0.7 0.79,-1.23 0.52,-0.52 1.2,-0.82 0.68,-0.3 1.48,-0.3zM306.11,299.81q0.77,0 1.45,0.3 0.7,0.3 1.2,0.82 0.52,0.52 0.82,1.23 0.3,0.68 0.3,1.48 0,0.77 -0.3,1.45 -0.3,0.68 -0.82,1.2 -0.5,0.5 -1.2,0.79 -0.68,0.3 -1.45,0.3 -0.79,0 -1.48,-0.3 -0.68,-0.3 -1.2,-0.79 -0.5,-0.52 -0.79,-1.2 -0.3,-0.68 -0.3,-1.45 0,-0.79 0.3,-1.48 0.3,-0.7 0.79,-1.23 0.52,-0.52 1.2,-0.82 0.68,-0.3 1.48,-0.3z"
|
||||||
|
android:strokeWidth="0.726744"
|
||||||
|
android:fillColor="#00e219"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 1004 B |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 872 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 652 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
docs/history-v1.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
docs/home-v1.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
docs/settings-v1.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
150
docs/spec.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# Specifications
|
||||||
|
|
||||||
|
## v1 behavior
|
||||||
|
|
||||||
|
the app launches into a focused text box. the user can type immediately.
|
||||||
|
|
||||||
|
controls:
|
||||||
|
|
||||||
|
- note/todo toggle
|
||||||
|
- optional tags field
|
||||||
|
- save
|
||||||
|
- save and close
|
||||||
|
- sync now
|
||||||
|
- small history view showing pending, synced, and failed entries
|
||||||
|
|
||||||
|
each capture has:
|
||||||
|
|
||||||
|
- stable client-generated id
|
||||||
|
- created timestamp with timezone
|
||||||
|
- kind: `note` or `todo`
|
||||||
|
- body text
|
||||||
|
- tags
|
||||||
|
- device name
|
||||||
|
- sync status
|
||||||
|
- optional last error
|
||||||
|
|
||||||
|
## org output
|
||||||
|
|
||||||
|
todo:
|
||||||
|
|
||||||
|
```org
|
||||||
|
* TODO buy printer paper :home:errands:
|
||||||
|
:PROPERTIES:
|
||||||
|
:CREATED: [2026-05-17 sun 14:31]
|
||||||
|
:SOURCE: android
|
||||||
|
:ID: phone-20260517-143122-a8f2
|
||||||
|
:END:
|
||||||
|
```
|
||||||
|
|
||||||
|
note:
|
||||||
|
|
||||||
|
```org
|
||||||
|
* note :retcon:
|
||||||
|
:PROPERTIES:
|
||||||
|
:CREATED: [2026-05-17 sun 14:33]
|
||||||
|
:SOURCE: android
|
||||||
|
:ID: phone-20260517-143322-b91c
|
||||||
|
:END:
|
||||||
|
|
||||||
|
mobile capture should stay dumb and append-only.
|
||||||
|
```
|
||||||
|
|
||||||
|
## server paths
|
||||||
|
|
||||||
|
default container paths:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/synq.org
|
||||||
|
/data/capture.sqlite3
|
||||||
|
/data/rejected.log
|
||||||
|
```
|
||||||
|
|
||||||
|
recommended host mapping:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/mnt/user/synq/phone-capture:/data
|
||||||
|
```
|
||||||
|
|
||||||
|
or map `/data/synq.org` directly to wherever the real org file lives. prefer a dedicated capture file first; emacs can include it in agenda later.
|
||||||
|
|
||||||
|
## security model
|
||||||
|
|
||||||
|
v1 is lan-only. bind the service to the host lan and do not expose it through swag/cloudflare/public dns.
|
||||||
|
|
||||||
|
minimum useful controls:
|
||||||
|
|
||||||
|
- shared bearer token in app and server env
|
||||||
|
- server only accepts json
|
||||||
|
- server rejects empty body
|
||||||
|
- server dedupes ids
|
||||||
|
- server writes append-only org
|
||||||
|
- server never edits or parses existing org
|
||||||
|
- docker volume is backed up
|
||||||
|
|
||||||
|
## repo layout
|
||||||
|
|
||||||
|
suggested monorepo:
|
||||||
|
|
||||||
|
```text
|
||||||
|
synq/
|
||||||
|
android/
|
||||||
|
app/
|
||||||
|
server/
|
||||||
|
app/
|
||||||
|
main.py
|
||||||
|
models.py
|
||||||
|
org_writer.py
|
||||||
|
store.py
|
||||||
|
tests/
|
||||||
|
Dockerfile
|
||||||
|
pyproject.toml
|
||||||
|
docs/
|
||||||
|
api.md
|
||||||
|
android-notes.md
|
||||||
|
server-notes.md
|
||||||
|
docker-compose.example.yml
|
||||||
|
.env.example
|
||||||
|
tasks.org
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
## build order
|
||||||
|
|
||||||
|
1. implement server first using curl tests.
|
||||||
|
2. implement android local capture with room.
|
||||||
|
3. implement manual sync.
|
||||||
|
4. add workmanager opportunistic sync.
|
||||||
|
5. add history screen and resend handling.
|
||||||
|
6. polish launch speed and widget/share-target only after core path works.
|
||||||
|
|
||||||
|
## non-goals
|
||||||
|
- no org parser on android
|
||||||
|
- no agenda on android
|
||||||
|
- no sync provider dependency
|
||||||
|
- no nextcloud file editing
|
||||||
|
- no conflict resolution
|
||||||
|
- no public internet exposure
|
||||||
|
- no ai tagging in v1
|
||||||
|
- no account system in v1
|
||||||
|
|
||||||
|
|
||||||
|
## backup
|
||||||
|
|
||||||
|
### what to back up
|
||||||
|
|
||||||
|
the server writes to one directory (the `/data` volume). back up the whole thing:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/data/synq.org ← the canonical org capture file
|
||||||
|
/data/capture.sqlite3 ← idempotency store (dedup ids)
|
||||||
|
/data/token.txt ← auto-generated token (if not using PHONE_CAPTURE_TOKEN env var)
|
||||||
|
```
|
||||||
|
|
||||||
|
on unraid the host path is whatever you mapped in compose, e.g. `/mnt/user/ben/synq/phone-capture`.
|
||||||
|
|
||||||
|
### restore behavior
|
||||||
|
|
||||||
|
- restore the `/data` volume to a new container and start it. the server will pick up where it left off.
|
||||||
|
- `synq.org` is append-only plain text — it is human-readable and recoverable even without the sqlite db.
|
||||||
|
- if `capture.sqlite3` is lost but `synq.org` is intact, the server will accept re-posted captures that were already in the org file (no dedup). the android app marks them synced either way (`already_seen` or `accepted`), so the only side effect is duplicate org entries for anything re-synced. restore the db from backup to avoid this.
|
||||||
|
- if `token.txt` is lost, delete it and let the server generate a new one on next start, then update the app settings.
|
||||||
86
logo/icon-512.svg
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<title
|
||||||
|
id="title4">synq</title>
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
id="layer1">
|
||||||
|
<rect
|
||||||
|
style="display:inline;fill:#22569d;fill-opacity:1;stroke-width:1.00157;stroke-dasharray:none"
|
||||||
|
id="rect5"
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
x="0"
|
||||||
|
y="0.95533592"
|
||||||
|
rx="96"
|
||||||
|
ry="96" />
|
||||||
|
<path
|
||||||
|
id="rect6"
|
||||||
|
style="fill:#ffffff;stroke:#000000;stroke-width:2.90698"
|
||||||
|
d="m 189.13953,130.52304 h 133.72094 c 7.24709,0 13.08139,5.8343 13.08139,13.0814 v 220.93022 c 0,7.2471 -5.8343,13.0814 -13.08139,13.0814 H 189.13953 c -7.24709,0 -13.08139,-5.8343 -13.08139,-13.0814 V 143.60444 c 0,-7.2471 5.8343,-13.0814 13.08139,-13.0814 z" />
|
||||||
|
<path
|
||||||
|
id="rect8"
|
||||||
|
style="display:inline;stroke:#000000;stroke-width:2.90698"
|
||||||
|
d="m 222.26527,361.685 h 66.86046 c 1.61047,0 2.90698,1.29651 2.90698,2.90697 0,1.61047 -1.29651,2.90698 -2.90698,2.90698 h -66.86046 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90698 0,-1.61046 1.29651,-2.90697 2.90698,-2.90697 z" />
|
||||||
|
<path
|
||||||
|
id="rect9"
|
||||||
|
style="display:inline;fill:#22569d;stroke:#000000;stroke-width:2.90698"
|
||||||
|
d="M 193.63011,145.02974 H 318.6301 c 1.61047,0 2.90698,1.29651 2.90698,2.90698 v 197.67442 c 0,1.61046 -1.29651,2.90697 -2.90698,2.90697 H 193.63011 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90697 V 147.93672 c 0,-1.61047 1.29651,-2.90698 2.90698,-2.90698 z" />
|
||||||
|
<g
|
||||||
|
id="text10"
|
||||||
|
style="font-size:46.5116px;line-height:1.25;letter-spacing:0px;word-spacing:0px;stroke-width:0.726744"
|
||||||
|
aria-label="[X].. [X].. [ ].. ">
|
||||||
|
<path
|
||||||
|
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
|
||||||
|
d="m 210.24567,196.0705 h -11.1964 v -42.19656 h 11.1964 v 3.1795 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.31141 h -5.10992 l -7.22201,-11.99127 -7.24473,11.99127 h -5.04178 l 9.87917,-15.17077 -9.0843,-14.51217 h 4.79197 l 6.79051,11.17369 6.83593,-11.17369 h 4.65571 l -9.10701,14.33048 z m 18.28215,9.31141 h -11.1964 v -3.17951 h 7.38099 v -35.83755 h -7.38099 v -3.1795 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
|
||||||
|
id="path16" />
|
||||||
|
<path
|
||||||
|
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
|
||||||
|
d="m 210.24567,256.17276 h -11.1964 V 213.9762 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.3114 h -5.10992 l -7.22201,-11.99128 -7.24473,11.99128 h -5.04178 l 9.87917,-15.17078 -9.0843,-14.51216 h 4.79197 l 6.79051,11.17368 6.83593,-11.17368 h 4.65571 l -9.10701,14.33047 z m 18.28215,9.3114 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52234 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52234 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
|
||||||
|
id="path17" />
|
||||||
|
<path
|
||||||
|
style="font-family:Consolas;-inkscape-font-specification:Consolas;display:inline;fill:#00e219"
|
||||||
|
d="m 210.24567,316.27502 h -11.1964 v -42.19656 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 49.85008,0 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46528 q 0.77216,0 1.45348,0.29523 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29523 1.4762,-0.29523 z m 25.57229,0 q 0.77217,0 1.45349,0.29523 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29523 1.4762,-0.29523 z"
|
||||||
|
id="path18" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="layer2"
|
||||||
|
style="display:none;opacity:0.355">
|
||||||
|
<rect
|
||||||
|
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="rect15"
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
x="0"
|
||||||
|
y="0" />
|
||||||
|
<circle
|
||||||
|
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path15"
|
||||||
|
cx="256"
|
||||||
|
cy="256"
|
||||||
|
r="256" />
|
||||||
|
</g>
|
||||||
|
<metadata
|
||||||
|
id="metadata4">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:title>synq</dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.2 KiB |
BIN
logo/icon-bg-512.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
85
logo/icon-bg-512.svg
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<title
|
||||||
|
id="title4">synq</title>
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<rect
|
||||||
|
style="display:inline;fill:#22569d;fill-opacity:1;stroke-width:1.00157;stroke-dasharray:none"
|
||||||
|
id="rect5"
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
x="0"
|
||||||
|
y="0.95533592" />
|
||||||
|
<g
|
||||||
|
id="layer1"
|
||||||
|
style="display:none">
|
||||||
|
<path
|
||||||
|
id="rect6"
|
||||||
|
style="fill:#ffffff;stroke:none;stroke-width:2.90698"
|
||||||
|
d="m 189.13953,130.52304 h 133.72094 c 7.24709,0 13.08139,5.8343 13.08139,13.0814 v 220.93022 c 0,7.2471 -5.8343,13.0814 -13.08139,13.0814 H 189.13953 c -7.24709,0 -13.08139,-5.8343 -13.08139,-13.0814 V 143.60444 c 0,-7.2471 5.8343,-13.0814 13.08139,-13.0814 z" />
|
||||||
|
<path
|
||||||
|
id="rect8"
|
||||||
|
style="display:inline;stroke:#000000;stroke-width:2.90698"
|
||||||
|
d="m 222.26527,361.685 h 66.86046 c 1.61047,0 2.90698,1.29651 2.90698,2.90697 0,1.61047 -1.29651,2.90698 -2.90698,2.90698 h -66.86046 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90698 0,-1.61046 1.29651,-2.90697 2.90698,-2.90697 z" />
|
||||||
|
<path
|
||||||
|
id="rect9"
|
||||||
|
style="display:inline;fill:#22569d;stroke:none;stroke-width:2.90698"
|
||||||
|
d="M 193.63011,145.02974 H 318.6301 c 1.61047,0 2.90698,1.29651 2.90698,2.90698 v 197.67442 c 0,1.61046 -1.29651,2.90697 -2.90698,2.90697 H 193.63011 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90697 V 147.93672 c 0,-1.61047 1.29651,-2.90698 2.90698,-2.90698 z" />
|
||||||
|
<g
|
||||||
|
id="text10"
|
||||||
|
style="font-size:46.5116px;line-height:1.25;letter-spacing:0px;word-spacing:0px;stroke-width:0.726744"
|
||||||
|
aria-label="[X].. [X].. [ ].. ">
|
||||||
|
<path
|
||||||
|
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
|
||||||
|
d="m 210.24567,196.0705 h -11.1964 v -42.19656 h 11.1964 v 3.1795 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.31141 h -5.10992 l -7.22201,-11.99127 -7.24473,11.99127 h -5.04178 l 9.87917,-15.17077 -9.0843,-14.51217 h 4.79197 l 6.79051,11.17369 6.83593,-11.17369 h 4.65571 l -9.10701,14.33048 z m 18.28215,9.31141 h -11.1964 v -3.17951 h 7.38099 v -35.83755 h -7.38099 v -3.1795 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
|
||||||
|
id="path16" />
|
||||||
|
<path
|
||||||
|
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
|
||||||
|
d="m 210.24567,256.17276 h -11.1964 V 213.9762 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.3114 h -5.10992 l -7.22201,-11.99128 -7.24473,11.99128 h -5.04178 l 9.87917,-15.17078 -9.0843,-14.51216 h 4.79197 l 6.79051,11.17368 6.83593,-11.17368 h 4.65571 l -9.10701,14.33047 z m 18.28215,9.3114 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52234 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52234 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
|
||||||
|
id="path17" />
|
||||||
|
<path
|
||||||
|
style="font-family:Consolas;-inkscape-font-specification:Consolas;display:inline;fill:#00e219"
|
||||||
|
d="m 210.24567,316.27502 h -11.1964 v -42.19656 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 49.85008,0 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46528 q 0.77216,0 1.45348,0.29523 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29523 1.4762,-0.29523 z m 25.57229,0 q 0.77217,0 1.45349,0.29523 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29523 1.4762,-0.29523 z"
|
||||||
|
id="path18" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="layer2"
|
||||||
|
style="display:none;opacity:0.355">
|
||||||
|
<rect
|
||||||
|
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="rect15"
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
x="0"
|
||||||
|
y="0" />
|
||||||
|
<circle
|
||||||
|
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path15"
|
||||||
|
cx="256"
|
||||||
|
cy="256"
|
||||||
|
r="256" />
|
||||||
|
</g>
|
||||||
|
<metadata
|
||||||
|
id="metadata4">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:title>synq</dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.1 KiB |
86
logo/icon-fg-512.svg
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<title
|
||||||
|
id="title4">synq</title>
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
id="layer1">
|
||||||
|
<rect
|
||||||
|
style="display:none;fill:#22569d;fill-opacity:1;stroke-width:1.00157;stroke-dasharray:none"
|
||||||
|
id="rect5"
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
x="0"
|
||||||
|
y="0.95533592"
|
||||||
|
rx="96"
|
||||||
|
ry="96" />
|
||||||
|
<path
|
||||||
|
id="rect6"
|
||||||
|
style="fill:#ffffff;stroke:#000000;stroke-width:2.90698"
|
||||||
|
d="m 189.13953,130.52304 h 133.72094 c 7.24709,0 13.08139,5.8343 13.08139,13.0814 v 220.93022 c 0,7.2471 -5.8343,13.0814 -13.08139,13.0814 H 189.13953 c -7.24709,0 -13.08139,-5.8343 -13.08139,-13.0814 V 143.60444 c 0,-7.2471 5.8343,-13.0814 13.08139,-13.0814 z" />
|
||||||
|
<path
|
||||||
|
id="rect8"
|
||||||
|
style="display:inline;stroke:#000000;stroke-width:2.90698"
|
||||||
|
d="m 222.26527,361.685 h 66.86046 c 1.61047,0 2.90698,1.29651 2.90698,2.90697 0,1.61047 -1.29651,2.90698 -2.90698,2.90698 h -66.86046 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90698 0,-1.61046 1.29651,-2.90697 2.90698,-2.90697 z" />
|
||||||
|
<path
|
||||||
|
id="rect9"
|
||||||
|
style="display:inline;fill:#22569d;stroke:#000000;stroke-width:2.90698"
|
||||||
|
d="M 193.63011,145.02974 H 318.6301 c 1.61047,0 2.90698,1.29651 2.90698,2.90698 v 197.67442 c 0,1.61046 -1.29651,2.90697 -2.90698,2.90697 H 193.63011 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90697 V 147.93672 c 0,-1.61047 1.29651,-2.90698 2.90698,-2.90698 z" />
|
||||||
|
<g
|
||||||
|
id="text10"
|
||||||
|
style="font-size:46.5116px;line-height:1.25;letter-spacing:0px;word-spacing:0px;stroke-width:0.726744"
|
||||||
|
aria-label="[X].. [X].. [ ].. ">
|
||||||
|
<path
|
||||||
|
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
|
||||||
|
d="m 210.24567,196.0705 h -11.1964 v -42.19656 h 11.1964 v 3.1795 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.31141 h -5.10992 l -7.22201,-11.99127 -7.24473,11.99127 h -5.04178 l 9.87917,-15.17077 -9.0843,-14.51217 h 4.79197 l 6.79051,11.17369 6.83593,-11.17369 h 4.65571 l -9.10701,14.33048 z m 18.28215,9.31141 h -11.1964 v -3.17951 h 7.38099 v -35.83755 h -7.38099 v -3.1795 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
|
||||||
|
id="path16" />
|
||||||
|
<path
|
||||||
|
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
|
||||||
|
d="m 210.24567,256.17276 h -11.1964 V 213.9762 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.3114 h -5.10992 l -7.22201,-11.99128 -7.24473,11.99128 h -5.04178 l 9.87917,-15.17078 -9.0843,-14.51216 h 4.79197 l 6.79051,11.17368 6.83593,-11.17368 h 4.65571 l -9.10701,14.33047 z m 18.28215,9.3114 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52234 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52234 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
|
||||||
|
id="path17" />
|
||||||
|
<path
|
||||||
|
style="font-family:Consolas;-inkscape-font-specification:Consolas;display:inline;fill:#00e219"
|
||||||
|
d="m 210.24567,316.27502 h -11.1964 v -42.19656 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 49.85008,0 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46528 q 0.77216,0 1.45348,0.29523 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29523 1.4762,-0.29523 z m 25.57229,0 q 0.77217,0 1.45349,0.29523 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29523 1.4762,-0.29523 z"
|
||||||
|
id="path18" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="layer2"
|
||||||
|
style="display:none;opacity:0.355">
|
||||||
|
<rect
|
||||||
|
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="rect15"
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
x="0"
|
||||||
|
y="0" />
|
||||||
|
<circle
|
||||||
|
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path15"
|
||||||
|
cx="256"
|
||||||
|
cy="256"
|
||||||
|
r="256" />
|
||||||
|
</g>
|
||||||
|
<metadata
|
||||||
|
id="metadata4">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:title>synq</dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.2 KiB |
BIN
logo/icon-fg-ns-512.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
85
logo/icon-fg-ns-512.svg
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<title
|
||||||
|
id="title4">synq</title>
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<rect
|
||||||
|
style="display:inline;fill:#22569d;fill-opacity:1;stroke-width:1.00157;stroke-dasharray:none"
|
||||||
|
id="rect5"
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
x="0"
|
||||||
|
y="0.95533592" />
|
||||||
|
<g
|
||||||
|
id="layer1"
|
||||||
|
style="display:inline">
|
||||||
|
<path
|
||||||
|
id="rect6"
|
||||||
|
style="fill:#ffffff;stroke:none;stroke-width:2.90698"
|
||||||
|
d="m 189.13953,130.52304 h 133.72094 c 7.24709,0 13.08139,5.8343 13.08139,13.0814 v 220.93022 c 0,7.2471 -5.8343,13.0814 -13.08139,13.0814 H 189.13953 c -7.24709,0 -13.08139,-5.8343 -13.08139,-13.0814 V 143.60444 c 0,-7.2471 5.8343,-13.0814 13.08139,-13.0814 z" />
|
||||||
|
<path
|
||||||
|
id="rect8"
|
||||||
|
style="display:inline;stroke:#000000;stroke-width:2.90698"
|
||||||
|
d="m 222.26527,361.685 h 66.86046 c 1.61047,0 2.90698,1.29651 2.90698,2.90697 0,1.61047 -1.29651,2.90698 -2.90698,2.90698 h -66.86046 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90698 0,-1.61046 1.29651,-2.90697 2.90698,-2.90697 z" />
|
||||||
|
<path
|
||||||
|
id="rect9"
|
||||||
|
style="display:inline;fill:#22569d;stroke:none;stroke-width:2.90698"
|
||||||
|
d="M 193.63011,145.02974 H 318.6301 c 1.61047,0 2.90698,1.29651 2.90698,2.90698 v 197.67442 c 0,1.61046 -1.29651,2.90697 -2.90698,2.90697 H 193.63011 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90697 V 147.93672 c 0,-1.61047 1.29651,-2.90698 2.90698,-2.90698 z" />
|
||||||
|
<g
|
||||||
|
id="text10"
|
||||||
|
style="font-size:46.5116px;line-height:1.25;letter-spacing:0px;word-spacing:0px;stroke-width:0.726744"
|
||||||
|
aria-label="[X].. [X].. [ ].. ">
|
||||||
|
<path
|
||||||
|
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
|
||||||
|
d="m 210.24567,196.0705 h -11.1964 v -42.19656 h 11.1964 v 3.1795 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.31141 h -5.10992 l -7.22201,-11.99127 -7.24473,11.99127 h -5.04178 l 9.87917,-15.17077 -9.0843,-14.51217 h 4.79197 l 6.79051,11.17369 6.83593,-11.17369 h 4.65571 l -9.10701,14.33048 z m 18.28215,9.31141 h -11.1964 v -3.17951 h 7.38099 v -35.83755 h -7.38099 v -3.1795 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
|
||||||
|
id="path16" />
|
||||||
|
<path
|
||||||
|
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
|
||||||
|
d="m 210.24567,256.17276 h -11.1964 V 213.9762 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.3114 h -5.10992 l -7.22201,-11.99128 -7.24473,11.99128 h -5.04178 l 9.87917,-15.17078 -9.0843,-14.51216 h 4.79197 l 6.79051,11.17368 6.83593,-11.17368 h 4.65571 l -9.10701,14.33047 z m 18.28215,9.3114 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52234 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52234 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
|
||||||
|
id="path17" />
|
||||||
|
<path
|
||||||
|
style="font-family:Consolas;-inkscape-font-specification:Consolas;display:inline;fill:#00e219"
|
||||||
|
d="m 210.24567,316.27502 h -11.1964 v -42.19656 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 49.85008,0 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46528 q 0.77216,0 1.45348,0.29523 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29523 1.4762,-0.29523 z m 25.57229,0 q 0.77217,0 1.45349,0.29523 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29523 1.4762,-0.29523 z"
|
||||||
|
id="path18" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="layer2"
|
||||||
|
style="display:none;opacity:0.355">
|
||||||
|
<rect
|
||||||
|
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="rect15"
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
x="0"
|
||||||
|
y="0" />
|
||||||
|
<circle
|
||||||
|
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path15"
|
||||||
|
cx="256"
|
||||||
|
cy="256"
|
||||||
|
r="256" />
|
||||||
|
</g>
|
||||||
|
<metadata
|
||||||
|
id="metadata4">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:title>synq</dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.1 KiB |
154
logo/icon.svg
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
|
||||||
|
sodipodi:docname="icon.svg"
|
||||||
|
inkscape:export-filename="icon-fg-ns-512.svg"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<title
|
||||||
|
id="title4">synq</title>
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="true"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
showgrid="true"
|
||||||
|
inkscape:zoom="1.4803312"
|
||||||
|
inkscape:cx="259.73917"
|
||||||
|
inkscape:cy="253.65945"
|
||||||
|
inkscape:window-width="1620"
|
||||||
|
inkscape:window-height="1068"
|
||||||
|
inkscape:window-x="1979"
|
||||||
|
inkscape:window-y="146"
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:current-layer="svg1">
|
||||||
|
<inkscape:grid
|
||||||
|
id="grid1"
|
||||||
|
units="px"
|
||||||
|
originx="0"
|
||||||
|
originy="0"
|
||||||
|
spacingx="4.2333333"
|
||||||
|
spacingy="4.2333333"
|
||||||
|
empcolor="#3f3fff"
|
||||||
|
empopacity="0.25098039"
|
||||||
|
color="#3f3fff"
|
||||||
|
opacity="0.1254902"
|
||||||
|
empspacing="5"
|
||||||
|
enabled="true"
|
||||||
|
visible="true"
|
||||||
|
dotted="false" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<defs
|
||||||
|
id="defs1">
|
||||||
|
<inkscape:path-effect
|
||||||
|
effect="fillet_chamfer"
|
||||||
|
id="path-effect5"
|
||||||
|
is_visible="true"
|
||||||
|
lpeversion="1"
|
||||||
|
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
|
||||||
|
radius="0"
|
||||||
|
unit="px"
|
||||||
|
method="auto"
|
||||||
|
mode="F"
|
||||||
|
chamfer_steps="1"
|
||||||
|
flexible="false"
|
||||||
|
use_knot_distance="true"
|
||||||
|
apply_no_radius="true"
|
||||||
|
apply_with_radius="true"
|
||||||
|
only_selected="false"
|
||||||
|
hide_knots="false" />
|
||||||
|
</defs>
|
||||||
|
<rect
|
||||||
|
style="display:inline;fill:#22569d;fill-opacity:1;stroke-width:1.00157;stroke-dasharray:none"
|
||||||
|
id="rect5"
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
x="0"
|
||||||
|
y="0.95533592"
|
||||||
|
inkscape:label="bg" />
|
||||||
|
<g
|
||||||
|
inkscape:label="icon"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
style="display:inline">
|
||||||
|
<path
|
||||||
|
id="rect6"
|
||||||
|
style="fill:#ffffff;stroke:none;stroke-width:2.90698"
|
||||||
|
inkscape:label="phone"
|
||||||
|
d="m 189.13953,130.52304 h 133.72094 c 7.24709,0 13.08139,5.8343 13.08139,13.0814 v 220.93022 c 0,7.2471 -5.8343,13.0814 -13.08139,13.0814 H 189.13953 c -7.24709,0 -13.08139,-5.8343 -13.08139,-13.0814 V 143.60444 c 0,-7.2471 5.8343,-13.0814 13.08139,-13.0814 z" />
|
||||||
|
<path
|
||||||
|
id="rect8"
|
||||||
|
style="display:inline;stroke:#000000;stroke-width:2.90698"
|
||||||
|
inkscape:label="phone-navbar"
|
||||||
|
d="m 222.26527,361.685 h 66.86046 c 1.61047,0 2.90698,1.29651 2.90698,2.90697 0,1.61047 -1.29651,2.90698 -2.90698,2.90698 h -66.86046 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90698 0,-1.61046 1.29651,-2.90697 2.90698,-2.90697 z" />
|
||||||
|
<path
|
||||||
|
id="rect9"
|
||||||
|
style="display:inline;fill:#22569d;stroke:none;stroke-width:2.90698"
|
||||||
|
inkscape:label="phone-screen"
|
||||||
|
d="M 193.63011,145.02974 H 318.6301 c 1.61047,0 2.90698,1.29651 2.90698,2.90698 v 197.67442 c 0,1.61046 -1.29651,2.90697 -2.90698,2.90697 H 193.63011 c -1.61047,0 -2.90698,-1.29651 -2.90698,-2.90697 V 147.93672 c 0,-1.61047 1.29651,-2.90698 2.90698,-2.90698 z" />
|
||||||
|
<g
|
||||||
|
id="text10"
|
||||||
|
style="font-size:46.5116px;line-height:1.25;letter-spacing:0px;word-spacing:0px;stroke-width:0.726744"
|
||||||
|
aria-label="[X].. [X].. [ ].. ">
|
||||||
|
<path
|
||||||
|
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
|
||||||
|
d="m 210.24567,196.0705 h -11.1964 v -42.19656 h 11.1964 v 3.1795 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.31141 h -5.10992 l -7.22201,-11.99127 -7.24473,11.99127 h -5.04178 l 9.87917,-15.17077 -9.0843,-14.51217 h 4.79197 l 6.79051,11.17369 6.83593,-11.17369 h 4.65571 l -9.10701,14.33048 z m 18.28215,9.31141 h -11.1964 v -3.17951 h 7.38099 v -35.83755 h -7.38099 v -3.1795 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52234 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70404 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
|
||||||
|
id="path16" />
|
||||||
|
<path
|
||||||
|
style="font-family:Consolas;-inkscape-font-specification:Consolas;fill:#00e219"
|
||||||
|
d="m 210.24567,256.17276 h -11.1964 V 213.9762 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 31.56793,-9.3114 h -5.10992 l -7.22201,-11.99128 -7.24473,11.99128 h -5.04178 l 9.87917,-15.17078 -9.0843,-14.51216 h 4.79197 l 6.79051,11.17368 6.83593,-11.17368 h 4.65571 l -9.10701,14.33047 z m 18.28215,9.3114 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46529 q 0.77216,0 1.45348,0.29524 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49963,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49964,-0.52234 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29524 1.4762,-0.29524 z m 25.57229,0 q 0.77217,0 1.45349,0.29524 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77216 -0.29524,1.45348 -0.29524,0.68133 -0.81759,1.20367 -0.49964,0.49964 -1.20367,0.79488 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79488 -0.49963,-0.52234 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45348 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29524 1.4762,-0.29524 z"
|
||||||
|
id="path17" />
|
||||||
|
<path
|
||||||
|
style="font-family:Consolas;-inkscape-font-specification:Consolas;display:inline;fill:#00e219"
|
||||||
|
d="m 210.24567,316.27502 h -11.1964 v -42.19656 h 11.1964 v 3.17951 h -7.42642 v 35.83755 h 7.42642 z m 49.85008,0 h -11.1964 v -3.1795 h 7.38099 v -35.83755 h -7.38099 v -3.17951 h 11.1964 z m 20.43967,-16.46528 q 0.77216,0 1.45348,0.29523 0.70404,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49963,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45348,0.29524 -0.79488,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49964,-0.52235 -0.79488,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79488,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68132,-0.29523 1.4762,-0.29523 z m 25.57229,0 q 0.77217,0 1.45349,0.29523 0.70403,0.29524 1.20367,0.81759 0.52235,0.52235 0.81759,1.22638 0.29524,0.68132 0.29524,1.4762 0,0.77217 -0.29524,1.45349 -0.29524,0.68132 -0.81759,1.20367 -0.49964,0.49963 -1.20367,0.79487 -0.68132,0.29524 -1.45349,0.29524 -0.79487,0 -1.4762,-0.29524 -0.68132,-0.29524 -1.20367,-0.79487 -0.49963,-0.52235 -0.79487,-1.20367 -0.29524,-0.68132 -0.29524,-1.45349 0,-0.79488 0.29524,-1.4762 0.29524,-0.70403 0.79487,-1.22638 0.52235,-0.52235 1.20367,-0.81759 0.68133,-0.29523 1.4762,-0.29523 z"
|
||||||
|
id="path18" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer2"
|
||||||
|
inkscape:label="circle-mask"
|
||||||
|
style="display:none;opacity:0.355">
|
||||||
|
<rect
|
||||||
|
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="rect15"
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
x="0"
|
||||||
|
y="0" />
|
||||||
|
<circle
|
||||||
|
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path15"
|
||||||
|
cx="256"
|
||||||
|
cy="256"
|
||||||
|
r="256" />
|
||||||
|
</g>
|
||||||
|
<metadata
|
||||||
|
id="metadata4">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:title>synq</dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.1 KiB |
@@ -17,7 +17,10 @@ logger = logging.getLogger("synq")
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI):
|
async def lifespan(_: FastAPI):
|
||||||
_load_token()
|
token = _load_token()
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("synq token: %s", token)
|
||||||
|
logger.info("=" * 60)
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@@ -45,7 +48,7 @@ def _load_token() -> str:
|
|||||||
logger.info("synq token loaded from %s", token_file)
|
logger.info("synq token loaded from %s", token_file)
|
||||||
return _token_cache
|
return _token_cache
|
||||||
|
|
||||||
generated = secrets.token_hex(32)
|
generated = _generate_passphrase()
|
||||||
token_file.parent.mkdir(parents=True, exist_ok=True)
|
token_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
token_file.write_text(generated)
|
token_file.write_text(generated)
|
||||||
logger.info("")
|
logger.info("")
|
||||||
@@ -58,6 +61,72 @@ def _load_token() -> str:
|
|||||||
return _token_cache
|
return _token_cache
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_passphrase(words: int = 3) -> str:
|
||||||
|
wordlist = [
|
||||||
|
"able","acid","aged","also","area","army","away","baby","back","ball",
|
||||||
|
"band","bank","base","bath","bear","beat","been","bell","best","bill",
|
||||||
|
"bird","blow","blue","bold","bolt","bone","book","born","both","bowl",
|
||||||
|
"bulk","burn","calm","came","card","care","cart","case","cash","cast",
|
||||||
|
"cave","cell","chat","chip","city","clam","clay","clip","club","coal",
|
||||||
|
"coat","code","coil","cold","come","cook","cool","cope","cord","core",
|
||||||
|
"corn","cost","cove","crew","crop","curl","dare","dark","data","date",
|
||||||
|
"dawn","days","dead","deal","dean","dear","deck","deep","deer","deft",
|
||||||
|
"deny","desk","dial","diet","disc","dish","disk","dive","dock","dome",
|
||||||
|
"door","dose","down","draw","drew","drip","drop","drum","dual","dusk",
|
||||||
|
"dust","duty","each","earn","ease","east","edge","else","even","ever",
|
||||||
|
"evil","exam","fact","fail","fair","fall","fame","farm","fast","fate",
|
||||||
|
"feed","feel","feet","fell","felt","file","fill","film","find","fire",
|
||||||
|
"firm","fish","fist","flag","flat","flew","flip","flow","foam","fold",
|
||||||
|
"folk","fond","font","food","foot","ford","fork","form","fort","free",
|
||||||
|
"from","fuel","full","fund","fuse","gain","game","gaze","gear","give",
|
||||||
|
"glad","glow","glue","goal","gold","golf","gone","good","grab","gray",
|
||||||
|
"grid","grim","grip","grow","gulf","gust","hack","hail","half","hall",
|
||||||
|
"halt","hand","hang","hard","harm","harp","hash","haul","have","hawk",
|
||||||
|
"head","heal","heap","heat","heel","held","helm","help","herb","here",
|
||||||
|
"hide","high","hill","hint","hold","hole","home","hook","hope","horn",
|
||||||
|
"host","hour","hull","hunt","hurt","icon","idea","idle","inch","into",
|
||||||
|
"iris","iron","isle","item","jade","jail","jazz","jest","join","jump",
|
||||||
|
"just","keen","keep","kelp","kern","keys","kick","kill","kind","king",
|
||||||
|
"knit","know","lack","lake","lamp","land","lane","lark","lash","last",
|
||||||
|
"late","lead","leaf","lean","leap","left","lend","lens","life","lift",
|
||||||
|
"like","lime","line","link","lion","list","live","load","loan","lock",
|
||||||
|
"loft","long","look","loop","lore","loss","loud","love","luck","lung",
|
||||||
|
"lure","mail","main","make","male","malt","many","mark","mask","mast",
|
||||||
|
"math","maze","meal","mean","meet","melt","mesh","mild","milk","mill",
|
||||||
|
"mind","mine","mint","miss","mist","mode","moon","more","moss","most",
|
||||||
|
"move","much","mule","must","nail","name","navy","near","neat","need",
|
||||||
|
"nest","news","next","nice","node","none","noon","norm","note","null",
|
||||||
|
"numb","oath","obey","odds","once","only","open","oral","over","pace",
|
||||||
|
"pack","page","paid","pain","pair","palm","park","part","pass","past",
|
||||||
|
"path","pave","peak","peel","peer","pick","pier","pile","pine","pipe",
|
||||||
|
"plan","play","plea","plot","plow","plum","plunge","plus","poem","poet",
|
||||||
|
"pole","poll","pond","pool","port","pose","post","pour","prey","pull",
|
||||||
|
"pump","pure","push","quit","race","rack","raid","rail","rain","ramp",
|
||||||
|
"rang","rank","rare","rate","read","real","reed","reel","rely","rent",
|
||||||
|
"rest","rice","rich","ride","ring","rise","risk","road","roam","roar",
|
||||||
|
"rock","role","roll","roof","root","rope","rose","rout","rule","rush",
|
||||||
|
"rust","safe","sage","sail","salt","same","sand","sang","save","scan",
|
||||||
|
"seal","seam","seed","seek","self","sell","send","sent","shed","ship",
|
||||||
|
"shop","shot","show","shut","sick","side","sign","silk","sing","sink",
|
||||||
|
"site","size","skip","slab","slam","slap","slim","slip","slot","slow",
|
||||||
|
"snap","snow","soak","soar","sock","soil","sold","sole","some","song",
|
||||||
|
"soon","sort","soul","soup","span","spin","spit","spot","spur","star",
|
||||||
|
"stay","stem","step","stop","stub","such","suit","sunk","sure","surf",
|
||||||
|
"swap","swim","tail","take","talk","tall","tank","tape","task","team",
|
||||||
|
"tell","tend","tent","term","test","text","than","then","thin","tide",
|
||||||
|
"tile","time","tint","tiny","tips","tire","toad","told","toll","tomb",
|
||||||
|
"tool","tops","toss","tour","town","trap","tree","trim","trip","trod",
|
||||||
|
"true","tube","tuck","tune","turf","turn","twin","type","upon","used",
|
||||||
|
"vary","vast","veil","vein","very","vest","view","vine","void","volt",
|
||||||
|
"vote","wade","wake","walk","wall","wand","ward","warm","warp","wary",
|
||||||
|
"wave","ways","weld","well","went","west","what","when","wide","wiki",
|
||||||
|
"wild","will","wind","wine","wire","wise","wish","with","wolf","wood",
|
||||||
|
"word","work","worn","wrap","wren","yard","year","yell","your","zero",
|
||||||
|
"zest","zinc","zone","zoom",
|
||||||
|
]
|
||||||
|
return "-".join(secrets.choice(wordlist) for _ in range(words))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_store() -> IdempotencyStore:
|
def get_store() -> IdempotencyStore:
|
||||||
global _store
|
global _store
|
||||||
@@ -82,6 +151,7 @@ async def check_token(request: Request) -> None:
|
|||||||
auth = request.headers.get("authorization", "")
|
auth = request.headers.get("authorization", "")
|
||||||
scheme, _, provided = auth.partition(" ")
|
scheme, _, provided = auth.partition(" ")
|
||||||
if scheme.lower() != "bearer" or provided != expected:
|
if scheme.lower() != "bearer" or provided != expected:
|
||||||
|
logger.warning("rejected request: invalid token from %s", request.client.host if request.client else "unknown")
|
||||||
raise HTTPException(status_code=401, detail="unauthorized")
|
raise HTTPException(status_code=401, detail="unauthorized")
|
||||||
|
|
||||||
|
|
||||||
@@ -101,7 +171,10 @@ async def capture(payload: CaptureRequest, store: IdempotencyStore = Depends(get
|
|||||||
|
|
||||||
lock = get_file_lock()
|
lock = get_file_lock()
|
||||||
with lock:
|
with lock:
|
||||||
Path(org_path).parent.mkdir(parents=True, exist_ok=True)
|
org_file = Path(org_path)
|
||||||
|
org_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not org_file.exists():
|
||||||
|
org_file.write_text("#+title: synq captures\n#+startup: overview\n\n", encoding="utf-8")
|
||||||
with open(org_path, "a", encoding="utf-8") as f:
|
with open(org_path, "a", encoding="utf-8") as f:
|
||||||
f.write(entry)
|
f.write(entry)
|
||||||
store.mark_seen(payload.id, payload.created_at)
|
store.mark_seen(payload.id, payload.created_at)
|
||||||
@@ -113,6 +186,8 @@ async def capture(payload: CaptureRequest, store: IdempotencyStore = Depends(get
|
|||||||
@app.exception_handler(RequestValidationError)
|
@app.exception_handler(RequestValidationError)
|
||||||
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||||
errors = exc.errors()
|
errors = exc.errors()
|
||||||
|
fields = [str(e.get("loc", "")) for e in errors]
|
||||||
|
logger.warning("rejected payload: validation failed fields=%s from=%s", fields, request.client.host if request.client else "unknown")
|
||||||
for err in errors:
|
for err in errors:
|
||||||
if "body" in err.get("loc", ()):
|
if "body" in err.get("loc", ()):
|
||||||
return JSONResponse(status_code=400, content={"detail": "body must not be empty"})
|
return JSONResponse(status_code=400, content={"detail": "body must not be empty"})
|
||||||
|
|||||||
200
tasks.org
@@ -43,8 +43,10 @@ the app must be instant-feeling, capture-only, and boring. the phone stores note
|
|||||||
*** acceptance
|
*** acceptance
|
||||||
- `android/`, `server/`, and `docs/` directories exist.
|
- `android/`, `server/`, and `docs/` directories exist.
|
||||||
- root `README.md`, `tasks.org`, `.env.example`, and `docker-compose.example.yml` exist.
|
- root `README.md`, `tasks.org`, `.env.example`, and `docker-compose.example.yml` exist.
|
||||||
|
|
||||||
*** notes
|
*** notes
|
||||||
Skeleton committed in initial commit. android/ dir will be created in milestone 2.
|
Skeleton committed in initial commit. android/ dir will be created in milestone 2.
|
||||||
|
|
||||||
*** evidence
|
*** evidence
|
||||||
- commit: c08b3fe
|
- commit: c08b3fe
|
||||||
- tests: n/a (directory structure)
|
- tests: n/a (directory structure)
|
||||||
@@ -328,48 +330,228 @@ SettingsScreen + SettingsViewModel from milestone 2. DataStore-backed. Default s
|
|||||||
- datetime: [2026-05-18 Sun 18:00]
|
- datetime: [2026-05-18 Sun 18:00]
|
||||||
|
|
||||||
* milestone 4: polish and hardening
|
* milestone 4: polish and hardening
|
||||||
** TODO make launch path fast
|
** DONE make launch path fast
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- app opens directly to capture field.
|
- app opens directly to capture field.
|
||||||
- no network call blocks launch.
|
- no network call blocks launch.
|
||||||
- no history loading blocks text entry.
|
- no history loading blocks text entry.
|
||||||
** TODO normalize tags
|
*** notes
|
||||||
|
LaunchedEffect(Unit) { focusRequester.requestFocus() } fires immediately on composition. No network calls in MainActivity or CaptureScreen init path. History loads via Room Flow on a background coroutine — never blocks text entry.
|
||||||
|
*** evidence
|
||||||
|
- commit: 19b05a8
|
||||||
|
- tests: manual — cold launch, keyboard appears without any loading state
|
||||||
|
- datetime: [2026-05-18 Sun 19:00]
|
||||||
|
|
||||||
|
** DONE normalize tags
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- whitespace-separated and comma-separated tags both work.
|
- whitespace-separated and comma-separated tags both work.
|
||||||
- invalid org tag chars are stripped or replaced.
|
- invalid org tag chars are stripped or replaced.
|
||||||
- duplicate tags collapse.
|
- duplicate tags collapse.
|
||||||
** TODO handle multiline notes well
|
*** notes
|
||||||
|
Two-layer normalization: parseTags() in CaptureViewModel splits on [,\s]+ before storing to Room. normalize_tags() in server/app/org_writer.py lowercases, strips #, replaces spaces with _, removes non-[a-z0-9_] chars, deduplicates. 7 unit tests cover all cases.
|
||||||
|
*** evidence
|
||||||
|
- commit: e873a00 (server), 19b05a8 (android)
|
||||||
|
- tests: pytest tests/test_org_writer.py::TestNormalizeTags — 7 passed
|
||||||
|
- datetime: [2026-05-18 Sun 19:00]
|
||||||
|
|
||||||
|
** DONE fix org formatting
|
||||||
|
*** acceptance
|
||||||
|
- add `#+startup: overview` to synq.org
|
||||||
|
*** notes
|
||||||
|
Append writer in main.py checks if org file exists before first write. If missing, creates it with #+title and #+startup: overview header. Existing files are never modified.
|
||||||
|
*** evidence
|
||||||
|
- commit: fd28a45
|
||||||
|
- tests: manual — delete synq.org, post a capture, verify header present
|
||||||
|
- datetime: [2026-05-18 Sun 19:00]
|
||||||
|
|
||||||
|
** DONE handle multiline notes well
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- first line can become note heading if body is multiline.
|
- first line can become note heading if body is multiline.
|
||||||
- full body is preserved under heading.
|
- full body is preserved under heading.
|
||||||
- todo body remains usable as a single todo heading, with extra lines under it if present.
|
- todo body remains usable as a single todo heading, with extra lines under it if present.
|
||||||
** TODO add basic logs
|
*** notes
|
||||||
|
format_capture() in org_writer.py: multiline note uses first line as heading suffix ("* note: <first line>"), full body preserved below drawer. Single-line note uses "* note" heading, body below. Covered by test_note_multiline.
|
||||||
|
*** evidence
|
||||||
|
- commit: e873a00
|
||||||
|
- tests: pytest tests/test_org_writer.py::TestFormatCapture::test_note_multiline — passed
|
||||||
|
- datetime: [2026-05-17 Sat 17:00]
|
||||||
|
|
||||||
|
** DONE add basic logs
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- server logs accepted capture id.
|
- server logs accepted capture id.
|
||||||
- server logs duplicate capture id.
|
- server logs duplicate capture id.
|
||||||
- server logs rejected payload without dumping secrets.
|
- server logs rejected payload without dumping secrets.
|
||||||
** TODO create backup note
|
*** notes
|
||||||
|
logger.info for accepted and duplicate (id only, no body). logger.warning for invalid token (client IP only, no token value logged). logger.warning for validation errors (field names only, no body content).
|
||||||
|
*** evidence
|
||||||
|
- commit: fd28a45
|
||||||
|
- tests: manual — send bad token, send empty body, check uvicorn stdout
|
||||||
|
- datetime: [2026-05-18 Sun 19:00]
|
||||||
|
|
||||||
|
** DONE create backup note
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- readme documents which paths need backup.
|
- readme documents which paths need backup.
|
||||||
- readme documents restore behavior.
|
- readme documents restore behavior.
|
||||||
|
*** notes
|
||||||
|
Added "backup" section to README.md covering synq.org, capture.sqlite3, token.txt. Restore section documents behavior when each file is lost individually.
|
||||||
|
*** evidence
|
||||||
|
- commit: 66155f6
|
||||||
|
- tests: n/a
|
||||||
|
- datetime: [2026-05-18 Sun 19:00]
|
||||||
|
|
||||||
* milestone 5: optional v1.5
|
* milestone 5: optional v1.5 improvements
|
||||||
** TODO add android share target
|
** DONE add android share target
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- sharing text to app opens prefilled capture.
|
- sharing text to app opens prefilled capture.
|
||||||
- user can save as note or todo.
|
- user can save as note or todo.
|
||||||
|
*** notes
|
||||||
|
ACTION_SEND text/plain intent-filter added to MainActivity in manifest. MainActivity.onCreate extracts EXTRA_TEXT and passes it as a URL-encoded nav argument to the capture/{prefill} route. CaptureScreen applies it via LaunchedEffect on first composition.
|
||||||
|
*** evidence
|
||||||
|
- commit: 66155f6
|
||||||
|
- tests: manual — share any text from browser/notes to synq, verify it pre-fills capture body
|
||||||
|
- datetime: [2026-05-18 Sun 20:00]
|
||||||
|
|
||||||
** TODO add home screen quick capture widget
|
** TODO add home screen quick capture widget
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- widget opens capture screen quickly.
|
- widget opens capture screen quickly.
|
||||||
- does not require sync to work.
|
- does not require sync to work.
|
||||||
** TODO add recent tag chips
|
|
||||||
|
** DONE add recent tag chips
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- app shows recent tags.
|
- app shows recent tags.
|
||||||
- tapping chip adds/removes tag.
|
- tapping chip adds/removes tag.
|
||||||
** TODO add edit-before-sync
|
*** notes
|
||||||
|
CaptureViewModel.recentTags collects the last 100 capture tagsJson rows, decodes each JSON array, flattens and deduplicates (LinkedHashSet order), limits to 20. CaptureScreen shows a FlowRow of FilterChip below the tags text field. Tapping a chip calls toggleTag() which adds/removes it from the tags string. Active chips are highlighted via FilterChip selected=true.
|
||||||
|
*** evidence
|
||||||
|
- commit: 66155f6
|
||||||
|
- tests: manual — save a few captures with tags, open capture screen, verify chips appear and toggle
|
||||||
|
- datetime: [2026-05-18 Sun 20:00]
|
||||||
|
|
||||||
|
** DONE add edit-before-sync
|
||||||
*** acceptance
|
*** acceptance
|
||||||
- pending/failed captures can be edited.
|
- pending/failed captures can be edited.
|
||||||
- synced captures are read-only unless explicitly duplicated.
|
- synced captures are read-only unless explicitly duplicated.
|
||||||
|
*** notes
|
||||||
|
CaptureDao.updateBody() new query. HistoryViewModel.updateBody() calls it. HistoryScreen CaptureRow shows "edit" OutlinedButton only when expanded AND status is pending/failed. Tapping edit opens an AlertDialog with an OutlinedTextField pre-filled with current body. Save calls updateBody; cancel dismisses.
|
||||||
|
*** evidence
|
||||||
|
- commit: 66155f6
|
||||||
|
- tests: manual — save a capture, open history, expand row, tap edit, change body, save, verify updated
|
||||||
|
- datetime: [2026-05-18 Sun 20:00]
|
||||||
|
|
||||||
|
** DONE tap to expand history item
|
||||||
|
*** acceptance
|
||||||
|
- tapping a history row expands/collapses it.
|
||||||
|
- expanded row shows full body.
|
||||||
|
- edit button is only visible when expanded.
|
||||||
|
*** notes
|
||||||
|
CaptureRow uses rememberSaveable expanded: Boolean keyed on capture.id. Card has clickable { expanded = !expanded }. maxLines switches between 2 and Int.MAX_VALUE. Edit button only renders when expanded && editable.
|
||||||
|
*** evidence
|
||||||
|
- commit: 66155f6
|
||||||
|
- tests: manual — tap history rows to expand/collapse, verify full body visible when expanded
|
||||||
|
- datetime: [2026-05-18 Sun 20:00]
|
||||||
|
|
||||||
|
** DONE add "Last synced" display
|
||||||
|
*** acceptance
|
||||||
|
- "last synced at" visible in history top bar and capture screen.
|
||||||
|
*** notes
|
||||||
|
CaptureDao.getLastSyncedAt() returns Flow<String?> of MAX(syncedAt). HistoryViewModel and CaptureViewModel both stateIn this flow. HistoryScreen shows it as a subtitle under "history" in the TopAppBar. CaptureScreen shows it as a small label above the save buttons. Both parse the ISO string via OffsetDateTime and format with DateTimeFormatter.ofLocalizedDateTime(SHORT).
|
||||||
|
*** evidence
|
||||||
|
- commit: 66155f6
|
||||||
|
- tests: manual — sync a capture, verify timestamp appears in history header and capture screen
|
||||||
|
- datetime: [2026-05-18 Sun 20:00]
|
||||||
|
|
||||||
|
** DONE add settings for sync frequency
|
||||||
|
*** acceptance
|
||||||
|
- user-specified poll rate in minutes (min 15).
|
||||||
|
- changing interval reschedules WorkManager immediately.
|
||||||
|
*** notes
|
||||||
|
SynqSettings gained syncIntervalMinutes: Int = 15 field with DataStore key sync_interval_minutes. SettingsScreen added OutlinedTextField with number keyboard for interval, shows supporting text "WorkManager minimum is 15 min". buildSettings() coerces to max(value, 15). SettingsViewModel.save() now also calls SyncWorker.schedule(app, settings.syncIntervalMinutes). SyncWorker.schedule() uses REPLACE policy so new interval takes effect immediately.
|
||||||
|
*** evidence
|
||||||
|
- commit: 66155f6
|
||||||
|
- tests: manual — change interval to 30, save, verify WorkManager re-queues (check adb shell dumpsys jobscheduler)
|
||||||
|
- datetime: [2026-05-18 Sun 20:00]
|
||||||
|
|
||||||
|
* milestone 6: post-ship cleanup
|
||||||
|
** DONE fix resource XML comment before <?xml declaration
|
||||||
|
*** acceptance
|
||||||
|
- android build succeeds with custom icon assets.
|
||||||
|
*** notes
|
||||||
|
Android's AAPT resource compiler requires <?xml to be the first byte of the file. Image Asset tool generated ic_launcher_background.xml, ic_launcher.xml, and ic_launcher_round.xml with an Apache license comment block before <?xml, causing ParseError at row 16. Fixed by stripping the comment from those three files. ic_launcher_foreground.xml was unaffected (no <?xml declaration). Do not use Image Asset tool for tweaks — it re-adds the comment on reimport; edit XML directly.
|
||||||
|
*** evidence
|
||||||
|
- commit: a6af2fe
|
||||||
|
- tests: manual — gradle assembleDebug succeeds
|
||||||
|
- datetime: [2026-05-19 Mon]
|
||||||
|
|
||||||
|
** DONE fix manifest missing icon references
|
||||||
|
*** acceptance
|
||||||
|
- app icon appears on device launcher after install.
|
||||||
|
*** notes
|
||||||
|
AndroidManifest.xml was missing android:icon and android:roundIcon attributes on the <application> element. Added @mipmap/ic_launcher and @mipmap/ic_launcher_round. Also committed all mipmap-* webp files and drawable/ic_launcher_background.xml (solid #22569d) and ic_launcher_foreground.xml generated by Image Asset tool.
|
||||||
|
*** evidence
|
||||||
|
- commit: a6af2fe
|
||||||
|
- tests: manual — install APK on device, verify icon appears in launcher
|
||||||
|
- datetime: [2026-05-19 Mon]
|
||||||
|
|
||||||
|
** DONE fix status bar icons washed out (API 35 edge-to-edge)
|
||||||
|
*** acceptance
|
||||||
|
- system clock, battery, wifi icons are visible over the app TopAppBar.
|
||||||
|
*** notes
|
||||||
|
Android 15 (API 35) enforces edge-to-edge regardless of enableEdgeToEdge() call — removing it had no effect. Real fix: keep enableEdgeToEdge() in MainActivity and add a SideEffect in Theme.kt that calls WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = true. This tells the system to render dark (visible) status bar icons on the light TopAppBar background.
|
||||||
|
*** evidence
|
||||||
|
- commit: 68deb5c
|
||||||
|
- tests: manual — open app, verify clock/battery/wifi icons are dark and readable
|
||||||
|
- datetime: [2026-05-19 Mon]
|
||||||
|
|
||||||
|
** DONE fix keyboard covering Save button
|
||||||
|
*** acceptance
|
||||||
|
- Save and Save & Close buttons are visible above the software keyboard.
|
||||||
|
*** notes
|
||||||
|
Added imePadding() to the Column modifier in CaptureScreen. The column shrinks to fit above the keyboard rather than being obscured by it.
|
||||||
|
*** evidence
|
||||||
|
- commit: 23df95d
|
||||||
|
- tests: manual — tap body field, keyboard opens, verify save buttons remain visible
|
||||||
|
- datetime: [2026-05-19 Mon]
|
||||||
|
|
||||||
|
** DONE add sync result snackbar
|
||||||
|
*** acceptance
|
||||||
|
- brief snackbar appears after tapping sync: "synced N", "nothing to sync", or "server unreachable".
|
||||||
|
*** notes
|
||||||
|
Added MutableSharedFlow<String> snackbar emitter to CaptureViewModel. syncNow() now calls checkHealth() first and emits "server unreachable" on failure. syncPending() return type changed from Unit to Int (count of successfully synced captures). SnackbarHost added to CaptureScreen Scaffold; LaunchedEffect collects the flow and calls snackbarState.showSnackbar().
|
||||||
|
*** evidence
|
||||||
|
- commit: 4597f92
|
||||||
|
- tests: manual — tap sync with server up (see "synced N"), with server down (see "server unreachable"), with empty queue (see "nothing to sync")
|
||||||
|
- datetime: [2026-05-19 Mon]
|
||||||
|
|
||||||
|
** DONE log token on every server startup
|
||||||
|
*** acceptance
|
||||||
|
- token is clearly visible in container logs on every start, not just first run.
|
||||||
|
*** notes
|
||||||
|
lifespan() in server/app/main.py now logs the token between === lines on every startup. Previously only logged "token loaded from token.txt" quietly; now logs the token value itself so it's easy to find with docker logs synq.
|
||||||
|
*** evidence
|
||||||
|
- commit: a6af2fe
|
||||||
|
- tests: manual — docker restart synq, docker logs synq | grep token
|
||||||
|
- datetime: [2026-05-19 Mon]
|
||||||
|
|
||||||
|
** DONE default device label to Build.MODEL
|
||||||
|
*** acceptance
|
||||||
|
- fresh install shows the actual device model name, not "android".
|
||||||
|
*** notes
|
||||||
|
SettingsRepository.kt: changed deviceLabel fallback from hardcoded "android" to Build.MODEL. No permissions needed. Existing users who have already saved a label are unaffected — DataStore only uses the fallback when no value is stored.
|
||||||
|
*** evidence
|
||||||
|
- commit: 628a28a
|
||||||
|
- tests: manual — fresh install, open settings, verify device label shows model name
|
||||||
|
- datetime: [2026-05-19 Mon]
|
||||||
|
|
||||||
|
** DONE replace hex token with 3-word passphrase
|
||||||
|
*** acceptance
|
||||||
|
- generated token is human-readable and fits on one log line.
|
||||||
|
- existing token.txt files are unaffected.
|
||||||
|
*** notes
|
||||||
|
secrets.token_hex(32) produced 64 chars — too long to read/verify visually. Replaced with _generate_passphrase() which picks 3 words from a 512-word embedded list using secrets.choice, hyphenated (e.g. "coral-drift-lamp"). ~27 bits entropy, sufficient for a LAN-only service. Existing token.txt files load unchanged — generation only runs when no token exists. To rotate: delete /data/token.txt and restart the container.
|
||||||
|
*** evidence
|
||||||
|
- commit: 628a28a
|
||||||
|
- tests: manual — delete token.txt, restart container, docker logs synq shows short readable token
|
||||||
|
- datetime: [2026-05-19 Mon]
|
||||||
|
|
||||||
* implementation notes for coding agents
|
* implementation notes for coding agents
|
||||||
- keep the server small and testable.
|
- keep the server small and testable.
|
||||||
|
|||||||