diff --git a/.forgejo/workflows/android.yml b/.forgejo/workflows/android.yml new file mode 100644 index 0000000..ae97cbf --- /dev/null +++ b/.forgejo/workflows/android.yml @@ -0,0 +1,109 @@ +name: Android + +# The native Kotlin/Compose client over the shared Rust core (M12). +# +# Replaces the Tauri-mobile lane deleted in step 2. What changed is what this +# builds, not that Android has a lane: the UI is Compose, and the store and sync +# engine are `thoughtsync-core` cross-compiled by cargo-ndk and loaded through +# uniffi. +# +# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything +# about feel, touch or on-device correctness is an operator pass on an emulator or +# phone. + +on: + push: + branches: [dev, main] + paths: + - "android/**" + # The Rust the .so is built from. A core change reaches the phone exactly + # as it reaches the desktop, so this lane has to rebuild on it. + - "core/**" + - "Cargo.toml" + - "Cargo.lock" + - ".forgejo/workflows/android.yml" + workflow_dispatch: + +concurrency: + group: android-${{ github.ref }} + cancel-in-progress: true + +env: + # Silences the JDK 22+ "restricted method in java.lang.System has been called" + # warning that Gradle 9.1's bundled native-platform jar trips at launch. This + # targets the LAUNCHER JVM, which is why org.gradle.jvmargs in + # gradle.properties is not enough on its own (Minstrel hit the same thing). + JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED" + +jobs: + build: + name: Kotlin + Rust (debug APK) + # runs-on is only a scheduling label (Label Model B). flutter-ci is the + # proven-working label that can pull our container images. + runs-on: flutter-ci + container: + # The image repurposed from ci-tauri-android in M12 step 3: Rust + the four + # Android ABIs + cargo-ndk + SDK/NDK + JDK 25 + ktlint + detekt. + image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97 + + defaults: + run: + working-directory: android + + steps: + - uses: actions/checkout@v4 + + - name: Cache Gradle and Cargo + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + ~/.kotlin + target + key: android-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts', 'Cargo.lock') }} + restore-keys: | + android- + + - name: Make gradlew executable + run: chmod +x ./gradlew + + # Fails loudly here if the wrapper and the image's JDK disagree, rather + # than thirty seconds into a compile with an opaque version message. + - name: Gradle wrapper check + run: ./gradlew --version + + # Cross-compiles the core for four ABIs and generates the Kotlin bindings + # from the built .so. Run as its own step so a Rust failure is legible as a + # Rust failure instead of arriving inside a Gradle stack trace. + - name: Build the native library and bindings + run: ./gradlew generateUniffiBindings + + - name: ktlint + run: ./gradlew ktlintCheck + + - name: detekt + run: ./gradlew detekt + + - name: Unit tests + # Host-JVM tests only. Anything touching the core needs an Android + # runtime to load the .so, so those are instrumented tests and belong on + # an emulator, not here — the Rust side is covered by the workspace + # tests in the desktop lane. + run: ./gradlew testDebugUnitTest + + - name: Assemble debug APK + run: ./gradlew assembleDebug + + - name: Upload debug APK + # Mirrored action, never actions/upload-artifact. @v4+ throws + # GHESNotSupportedError client-side on this hostname, and @v3 is worse — + # it reports success while Gitea serves artifacts back only through the + # v4 API, so the upload is stored and invisible. Pinned by SHA because + # the mirror auto-syncs; full URL because DEFAULT_ACTIONS_URL sends bare + # owner/repo to github.com. See Scribe issues 2255 / 2270. + uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245 + with: + name: thoughtsync-android-debug-${{ github.sha }} + path: android/app/build/outputs/apk/debug/app-debug.apk + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 46a9c41..c22f3c4 100644 --- a/.gitignore +++ b/.gitignore @@ -177,3 +177,15 @@ cython_debug/ # Rust workspace build output (one target dir for core + desktop + android) /target/ + +# Android / Gradle build output. +# +# `local.properties` holds the machine's SDK path — it is per-workstation and +# must never be committed; CI gets the SDK from ANDROID_HOME in the image. +# The wrapper JAR is deliberately NOT ignored: it is how a clean checkout gets +# the right Gradle without one installed first. +android/.gradle/ +android/build/ +android/app/build/ +android/local.properties +.kotlin/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..95bf552 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,243 @@ +import javax.inject.Inject + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.ktlint) + alias(libs.plugins.detekt) +} + +// The Cargo workspace root — two levels up from android/app. +val workspaceRoot: Directory = layout.projectDirectory.dir("../..") + +// The ABIs a release APK carries. arm64 is essentially every real device; armv7 +// covers older 32-bit hardware; the two x86 targets are what emulators run on, and +// dropping them would make the app untestable on a desktop emulator (the reason +// x86_64 was added to the old Tauri lane in task 1864). +val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") + +/** + * Cross-compile `thoughtsync-ffi` for each Android ABI and drop the resulting + * `.so` into jniLibs, where AGP packages it. + * + * `ExecOperations` injected rather than `project.exec`: the latter was REMOVED in + * Gradle 9, and reaching for `project` at execution time is also what breaks the + * configuration cache this build has enabled. + */ +abstract class CargoNdkBuild : DefaultTask() { + @get:Inject + abstract val execOps: ExecOperations + + @get:InputFiles + abstract val rustSources: ConfigurableFileCollection + + @get:Input + abstract val abis: ListProperty + + @get:Input + abstract val cargoProfile: Property + + @get:Internal + abstract val workspaceDir: DirectoryProperty + + @get:OutputDirectory + abstract val jniLibsDir: DirectoryProperty + + @TaskAction + fun build() { + val args = mutableListOf("ndk") + abis.get().forEach { abi -> + args += "-t" + args += abi + } + args += listOf("-o", jniLibsDir.get().asFile.absolutePath, "build", "-p", "thoughtsync-ffi") + // --locked so an Android build cannot silently re-resolve the workspace + // lockfile the desktop lanes are gated on. + args += "--locked" + if (cargoProfile.get() == "release") args += "--release" + + execOps.exec { + commandLine(listOf("cargo") + args) + workingDir = workspaceDir.get().asFile + } + } +} + +/** + * Generate the Kotlin bindings FROM the freshly built `.so`. + * + * `--library` mode reads uniffi's metadata straight out of the compiled artifact, + * so the bindings can never describe a different version of the Rust than the one + * being packaged — which is the failure the whole in-workspace generator setup + * exists to prevent. + */ +abstract class UniffiBindgen : DefaultTask() { + @get:Inject + abstract val execOps: ExecOperations + + @get:InputFile + abstract val libraryFile: RegularFileProperty + + @get:Internal + abstract val workspaceDir: DirectoryProperty + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val out = outputDir.get().asFile + out.deleteRecursively() + out.mkdirs() + execOps.exec { + commandLine( + "cargo", + "run", + "--locked", + "--features", + "bindgen", + "--bin", + "uniffi-bindgen", + "--", + "generate", + "--library", + libraryFile.get().asFile.absolutePath, + "--language", + "kotlin", + "--out-dir", + out.absolutePath, + ) + workingDir = workspaceDir.get().asFile + } + } +} + +// Only the Rust that actually affects the .so. Deliberately NOT the workspace +// directory: that would make Gradle hash target/, which is gigabytes. +val rustInputs = + files( + workspaceRoot.dir("core/src"), + workspaceRoot.dir("android/ffi/src"), + workspaceRoot.file("core/Cargo.toml"), + workspaceRoot.file("android/ffi/Cargo.toml"), + workspaceRoot.file("Cargo.toml"), + workspaceRoot.file("Cargo.lock"), + ) + +val jniLibsOut = layout.buildDirectory.dir("rustJniLibs") +val bindingsOut = layout.buildDirectory.dir("generated/uniffi") + +val cargoNdkDebug = + tasks.register("cargoNdkDebug") { + description = "Cross-compile thoughtsync-ffi for the Android ABIs (debug)." + rustSources.from(rustInputs) + abis.set(androidAbis) + cargoProfile.set("debug") + workspaceDir.set(workspaceRoot) + jniLibsDir.set(jniLibsOut) + } + +val generateBindings = + tasks.register("generateUniffiBindings") { + description = "Generate the Kotlin bindings from the compiled .so." + dependsOn(cargoNdkDebug) + // arm64 is arbitrary — every ABI carries the same uniffi metadata, and + // reading one is cheaper than reading four. + libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libthoughtsync_ffi.so") }) + workspaceDir.set(workspaceRoot) + outputDir.set(bindingsOut) + } + +android { + namespace = "com.fabledsword.thoughtsync" + compileSdk = 36 + + defaultConfig { + applicationId = "com.fabledsword.thoughtsync" + // 26 (Android 8, 2017) matches Minstrel and clears the NDK's floor with + // room to spare. + minSdk = 26 + targetSdk = 36 + // Injected by CI from the git tag + commit count for a release; "dev" + // locally so the About screen reads honestly rather than claiming 1.0. + val nameOverride = + (project.findProperty("THOUGHTSYNC_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() } + val codeOverride = + (project.findProperty("THOUGHTSYNC_VERSION_CODE") as String?)?.toIntOrNull() + versionCode = codeOverride ?: 1 + versionName = nameOverride ?: "dev" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + // Signing is deliberately absent. A release keystore that has passed + // through an agent session or shell history is compromised by + // construction (Scribe task 2136) — it has to be generated by the + // operator and reach CI only as a secret. Until then a release build + // is unsigned and CI builds debug. + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } + + sourceSets { + getByName("main") { + // The .so and the bindings are build outputs, not checked-in sources. + jniLibs.srcDir(jniLibsOut) + java.srcDir(bindingsOut) + } + } + + packaging { + resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" + } +} + +// Kotlin compilation needs the generated bindings to exist first. +tasks.withType().configureEach { + dependsOn(generateBindings) +} +tasks.named("preBuild") { dependsOn(generateBindings) } + +// ktlint must not police generated code — it is uniffi's output, not ours, and +// there is no edit that would fix a complaint about it. +ktlint { + filter { + exclude { it.file.path.contains("generated") } + } +} + +detekt { + buildUponDefaultConfig = true + config.setFrom(files("$rootDir/config/detekt.yml")) +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.kotlinx.coroutines.android) + + // Required by the uniffi bindings — see the catalog note on the @aar + // classifier; the plain jar builds fine and fails at runtime. + implementation(variantOf(libs.jna) { artifactType("aar") }) + + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.material3) + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) + + testImplementation(libs.junit) +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..1721620 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,7 @@ +# JNA reaches the native library reflectively, so R8 must not rename or strip +# either it or the uniffi bindings that ride on it. Without these a minified +# build fails at runtime with UnsatisfiedLinkError — and only in release, which +# is the worst possible time to learn it. +-keep class com.sun.jna.** { *; } +-keepclassmembers class * extends com.sun.jna.** { public *; } +-keep class com.fabledsword.thoughtsync.core.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f1c66d5 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt new file mode 100644 index 0000000..d5cc812 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt @@ -0,0 +1,39 @@ +package com.fabledsword.thoughtsync + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.lifecycle.viewmodel.compose.viewModel +import com.fabledsword.thoughtsync.ui.BoardScreen +import com.fabledsword.thoughtsync.ui.BoardViewModel +import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen +import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val app = application as ThoughtSyncApplication + + setContent { + ThoughtSyncTheme { + val core = app.core + if (core == null) { + // The store never opened. There is no board to show and no + // action that would help, so say what happened plainly rather + // than render an empty board that looks like data loss. + StoreUnavailableScreen(reason = app.openFailure) + } else { + val model: BoardViewModel = viewModel(factory = BoardViewModel.factory(core)) + BoardScreen( + state = model.state, + onCapture = model::capture, + onDismissError = model::dismissError, + ) + } + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ThoughtSyncApplication.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ThoughtSyncApplication.kt new file mode 100644 index 0000000..1303297 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ThoughtSyncApplication.kt @@ -0,0 +1,49 @@ +package com.fabledsword.thoughtsync + +import android.app.Application +import android.util.Log +import com.fabledsword.thoughtsync.core.ThoughtSync + +/** + * Opens the shared Rust core once, for the process lifetime. + * + * The store is a single SQLite file behind a mutex, so one handle is both + * sufficient and correct — a second would be two connections racing for the same + * lock. This mirrors how the desktop manages it as Tauri app state. + * + * [filesDir] is app-private storage: readable by this app and nothing else, + * removed on uninstall, and never on external media. The core does not guess at + * platform paths; Android is the only thing that knows where this is. + */ +class ThoughtSyncApplication : Application() { + /** + * Null only if the store could not be opened — a corrupt or unwritable + * database. The UI reports that honestly rather than crashing on first + * touch, because a user whose notes won't open needs a message, not a + * stack trace. + */ + var core: ThoughtSync? = null + private set + + var openFailure: String? = null + private set + + override fun onCreate() { + super.onCreate() + try { + val handle = ThoughtSync(filesDir.absolutePath) + core = handle + Log.i(TAG, "local store ready — ${handle.summary()}") + } catch (e: Exception) { + // Deliberately broad: whatever went wrong, the app still has to + // start and say so. Narrowing this would mean an unanticipated + // failure mode takes the process down at launch instead. + openFailure = e.message ?: e.toString() + Log.e(TAG, "could not open the local store", e) + } + } + + private companion object { + const val TAG = "ThoughtSync" + } +} diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt new file mode 100644 index 0000000..6bf27cf --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt @@ -0,0 +1,222 @@ +package com.fabledsword.thoughtsync.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.fabledsword.thoughtsync.R +import com.fabledsword.thoughtsync.core.Note + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BoardScreen( + state: BoardState, + onCapture: (String) -> Unit, + onDismissError: () -> Unit, +) { + Scaffold( + topBar = { TopAppBar(title = { Text(stringResource(R.string.board_title)) }) }, + modifier = Modifier.imePadding(), + ) { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + state.error?.let { message -> + ErrorBanner(message = message, onDismiss = onDismissError) + } + + CaptureField(saving = state.saving, onCapture = onCapture) + + when { + state.loading -> LoadingBoard() + state.notes.isEmpty() -> EmptyBoard() + else -> NoteList(notes = state.notes) + } + } + } +} + +@Composable +private fun CaptureField( + saving: Boolean, + onCapture: (String) -> Unit, +) { + var text by remember { mutableStateOf("") } + + fun submit() { + if (text.isNotBlank()) { + onCapture(text) + text = "" + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.weight(1f), + placeholder = { Text(stringResource(R.string.capture_hint)) }, + // The north star is a thought captured in under a second, so the + // keyboard's action key saves rather than inserting a newline. + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { submit() }), + enabled = !saving, + singleLine = false, + maxLines = MAX_CAPTURE_LINES, + ) + Button(onClick = ::submit, enabled = !saving && text.isNotBlank()) { + Text(stringResource(R.string.capture_action)) + } + } +} + +@Composable +private fun NoteList(notes: List) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Keyed by id so Compose reuses rows across a refresh instead of + // rebuilding the list — and so a prepended note animates in rather than + // making every row below it flicker. + items(items = notes, key = { it.id }) { note -> NoteCard(note) } + } +} + +@Composable +private fun NoteCard(note: Note) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + // display_title is always present — the core derives it from the + // first body line when there is no title, so a body-only note is + // still nameable. The fallback is for a note with neither. + text = note.displayTitle.ifBlank { stringResource(R.string.board_untitled) }, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (note.body.isNotBlank() && note.body != note.displayTitle) { + Spacer(Modifier.height(4.dp)) + Text( + text = note.body, + style = MaterialTheme.typography.bodyMedium, + maxLines = MAX_PREVIEW_LINES, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun EmptyBoard() { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.board_empty_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.board_empty_body), + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun LoadingBoard() { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun ErrorBanner( + message: String, + onDismiss: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onDismiss) { Text(stringResource(R.string.error_dismiss)) } + } + } +} + +/** + * Shown when the store could not be opened at all. + * + * There is no retry: whatever stopped SQLite from opening will stop it again this + * launch. Saying so plainly beats a button that does nothing. + */ +@Composable +fun StoreUnavailableScreen(reason: String?) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.store_unavailable_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = reason ?: stringResource(R.string.store_unavailable_body), + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +private const val MAX_CAPTURE_LINES = 5 +private const val MAX_PREVIEW_LINES = 4 diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt new file mode 100644 index 0000000..c1803fe --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt @@ -0,0 +1,105 @@ +package com.fabledsword.thoughtsync.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.fabledsword.thoughtsync.core.Note +import com.fabledsword.thoughtsync.core.NoteDraft +import com.fabledsword.thoughtsync.core.NoteQuery +import com.fabledsword.thoughtsync.core.ThoughtSync +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** Everything the board renders from, in one immutable snapshot. */ +data class BoardState( + val notes: List = emptyList(), + val loading: Boolean = true, + val saving: Boolean = false, + val error: String? = null, +) + +/** + * Drives the board off the shared Rust core. + * + * Every core call is a BLOCKING FFI call — the store is synchronous SQLite behind + * a mutex — so they run on [Dispatchers.IO]. Doing otherwise would block the main + * thread on disk, which is exactly the jank a native client is supposed to avoid. + * (The sync methods are the exception: those are `suspend` on the Kotlin side + * already, because uniffi bridges the Rust async to coroutines.) + */ +class BoardViewModel(private val core: ThoughtSync) : ViewModel() { + var state by mutableStateOf(BoardState()) + private set + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + state = state.copy(loading = true) + state = + try { + val notes = withContext(Dispatchers.IO) { core.listNotes(BOARD_QUERY) } + state.copy(notes = notes, loading = false, error = null) + } catch (e: Exception) { + // Broad by intent: the board must render something for any + // failure, and the core reports most problems as one error + // type carrying a message meant to be shown. + state.copy(loading = false, error = e.message ?: FALLBACK_ERROR) + } + } + } + + /** + * Save a captured thought. + * + * Blank input is ignored rather than rejected with a message: an empty save is + * a slip, not a mistake worth interrupting someone over. + */ + fun capture(text: String) { + val trimmed = text.trim() + if (trimmed.isEmpty()) return + + viewModelScope.launch { + state = state.copy(saving = true) + state = + try { + // Title left empty on purpose — the core derives display_title + // from the first body line, so a captured thought is nameable + // without making the user name it. Same behaviour as the + // desktop's quick-add. + val draft = NoteDraft(title = "", body = trimmed, color = DEFAULT_COLOR, kind = null, items = null) + val created = withContext(Dispatchers.IO) { core.createNote(draft) } + // Prepend rather than re-query: the new note belongs at the top + // of an unsorted board, and a full reload would cost a round + // trip to tell us something we already know. + state.copy(notes = listOf(created) + state.notes, saving = false, error = null) + } catch (e: Exception) { + state.copy(saving = false, error = e.message ?: FALLBACK_ERROR) + } + } + } + + fun dismissError() { + state = state.copy(error = null) + } + + companion object { + private const val DEFAULT_COLOR = "default" + private const val FALLBACK_ERROR = "Something went wrong." + + /** The default board: everything not archived or trashed. */ + private val BOARD_QUERY = NoteQuery(view = "notes", labelId = null, sort = null, facets = null) + + fun factory(core: ThoughtSync): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = BoardViewModel(core) as T + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/Theme.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/Theme.kt new file mode 100644 index 0000000..e35b0b1 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/Theme.kt @@ -0,0 +1,55 @@ +package com.fabledsword.thoughtsync.ui + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +// The brand colour, the same #F5C518 the web app's manifest and the adaptive icon +// use. One answer to "what colour is ThoughtSync" across all three surfaces. +private val Brand = Color(0xFFF5C518) + +private val LightColors = + lightColorScheme( + primary = Brand, + // Black on gold, not white: the brand colour is bright enough that white + // text on it fails contrast badly. + onPrimary = Color(0xFF1A1A1A), + ) + +private val DarkColors = + darkColorScheme( + primary = Brand, + onPrimary = Color(0xFF1A1A1A), + ) + +/** + * Material 3, following the system light/dark setting. + * + * Dynamic colour is used where the platform offers it (Android 12+), because a + * phone user's expectation is that apps take the wallpaper palette — and falls + * back to the brand scheme below that. The desktop makes the equivalent choice by + * reading the live window theme rather than hardcoding one. + */ +@Composable +fun ThoughtSyncTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val context = LocalContext.current + val colors = + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + darkTheme -> DarkColors + else -> LightColors + } + + MaterialTheme(colorScheme = colors, content = content) +} diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..9e54375 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..9e54375 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..304847e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..2524d27 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + + + #F5C518 + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7ea8e39 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + ThoughtSync + + + Take a note… + Save + + + Notes + Nothing here yet + Your notes stay on this device. Connect a server later if you want them everywhere. + Untitled + + + Your notes couldn\'t be opened + The note store on this device could not be read. Reinstalling will start a fresh one, but anything not synced to a server would be lost. + + + Dismiss + Try again + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..5aa64e5 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,10 @@ + + + +