Skip to content

Commit 86e28f8

Browse files
committed
Add iOS template bridge UI
1 parent fb4f78b commit 86e28f8

4 files changed

Lines changed: 240 additions & 0 deletions

File tree

iosApp/iosApp/ContentView.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,43 @@ struct ContentView: View {
7979
.accessibilityIdentifier("applyWatermarkText")
8080
}
8181

82+
// S4d-233: minimal Templates UI over the seeded iOS Template Room DB (the no-arg
83+
// `buildTemplateDatabase()` consumed via `IosTemplateBridge`; on a fresh install the rows are the
84+
// bundled default templates from S4d-232). Save the current text, apply a template (reuses
85+
// `setWatermarkText`, which persists + re-renders), or delete one. Minimal — not the final 1:1 editor.
86+
VStack(alignment: .leading, spacing: 4) {
87+
HStack {
88+
Text("Templates")
89+
.font(.caption.bold())
90+
Spacer()
91+
Button("Save current") { Task { await workflow.saveCurrentTextAsTemplate() } }
92+
.buttonStyle(.bordered)
93+
.disabled(draftText.isEmpty)
94+
.accessibilityIdentifier("saveTemplateButton")
95+
}
96+
ForEach(workflow.templates, id: \.id) { template in
97+
HStack {
98+
Button(template.content) {
99+
Task {
100+
await workflow.applyTemplate(template)
101+
draftText = workflow.watermarkText
102+
}
103+
}
104+
.buttonStyle(.borderless)
105+
.accessibilityIdentifier("templateRow")
106+
Spacer()
107+
Button(role: .destructive) {
108+
Task { await workflow.deleteTemplate(template) }
109+
} label: {
110+
Image(systemName: "trash")
111+
}
112+
.buttonStyle(.borderless)
113+
.accessibilityIdentifier("deleteTemplateButton")
114+
}
115+
}
116+
}
117+
.accessibilityIdentifier("templatesSection")
118+
82119
// S4d-103: edit the watermark rotation degree through the same shared editor path. Minimal
83120
// control — not the final 1:1 editor. Commits on release (avoids re-rendering mid-drag).
84121
VStack(spacing: 4) {
@@ -249,6 +286,7 @@ struct ContentView: View {
249286
await workflow.loadWatermarkTypeface()
250287
await workflow.loadWatermarkTextStyle()
251288
await workflow.loadWatermarkMarkMode()
289+
await workflow.loadTemplates()
252290
}
253291
}
254292

iosApp/iosApp/WatermarkWorkflow.swift

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,58 @@ final class WatermarkWorkflow: ObservableObject {
423423
}
424424
}
425425

426+
// MARK: - S4d-233 templates
427+
428+
/// S4d-233: the single retained iOS templates bridge over the **seeded** common templates Room DB —
429+
/// the no-arg `buildTemplateDatabase()` (S4d-232: `NSDocumentDirectory` store + the bundled Android seed
430+
/// on first creation). One instance per process (single DB file), mirroring `watermarkConfigBridge` /
431+
/// `userConfigBridge`. The bridge owns only template-DB ops; applying a template reuses `setWatermarkText`.
432+
private let templateBridge = IosTemplateBridgeKt.defaultIosTemplateBridge()
433+
434+
/// S4d-233: the persisted templates as flat `IosTemplate` values (id + content), loaded on launch and
435+
/// refreshed after save/delete. On a fresh install these are the bundled default templates (S4d-232 seed).
436+
@Published private(set) var templates: [IosTemplate] = []
437+
438+
/// S4d-233: one-shot snapshot of the persisted templates through the bridge. A read error keeps the
439+
/// current list (must not break the editor).
440+
func loadTemplates() async {
441+
do {
442+
templates = try await templateBridge.currentTemplates()
443+
} catch {
444+
// keep the current list; a read failure must not break the editor
445+
}
446+
}
447+
448+
/// S4d-233: save the current watermark text as a new template, then refresh the list. No-op on empty
449+
/// text. A write failure surfaces as `.failure` without changing the list.
450+
func saveCurrentTextAsTemplate() async {
451+
let text = watermarkText
452+
guard !text.isEmpty else { return }
453+
do {
454+
try await templateBridge.addTemplate(content: text)
455+
await loadTemplates()
456+
} catch {
457+
state = .failure("Could not save template: \(error.localizedDescription)")
458+
}
459+
}
460+
461+
/// S4d-233: apply a template by REUSING the existing watermark-text setter (`setWatermarkText` persists
462+
/// through the shared `WatermarkConfigEditor` and re-renders) — the template bridge never touches the
463+
/// watermark config store.
464+
func applyTemplate(_ template: IosTemplate) async {
465+
await setWatermarkText(template.content)
466+
}
467+
468+
/// S4d-233: delete a template by id, then refresh the list. A write failure surfaces as `.failure`.
469+
func deleteTemplate(_ template: IosTemplate) async {
470+
do {
471+
try await templateBridge.deleteTemplate(id: template.id)
472+
await loadTemplates()
473+
} catch {
474+
state = .failure("Could not delete template: \(error.localizedDescription)")
475+
}
476+
}
477+
426478
/// Render `imageData` (the encoded bytes of a picked photo) into a watermarked PNG.
427479
func render(imageData: Data) async {
428480
state = .rendering
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package me.rosuh.easywatermark.data.repo
2+
3+
import kotlinx.coroutines.Dispatchers
4+
import kotlinx.coroutines.flow.first
5+
import me.rosuh.easywatermark.data.db.buildTemplateDatabase
6+
import me.rosuh.easywatermark.domain.TemplateEditor
7+
8+
/**
9+
* S4d-233: a tiny Swift-friendly value view of a template row — just the `id` (for delete) and the
10+
* `content` (to display/apply). The Room `@Entity` [me.rosuh.easywatermark.data.model.entity.Template]
11+
* (which carries `kotlin.time.Instant` dates and a nullable content) does NOT cross to Swift; this flat
12+
* value does. Null content is normalized to the empty string.
13+
*/
14+
data class IosTemplate(val id: Int, val content: String)
15+
16+
/**
17+
* S4d-233: the iOS Swift-facing bridge for the common templates store, mirroring [IosUserConfigBridge].
18+
*
19+
* Swift never touches the Kotlin `Flow`: [currentTemplates] is a **one-shot snapshot** (it collects
20+
* `repo.getAllTemplate().first()`), and the writes are plain `suspend` functions that the Kotlin/Native
21+
* Swift importer bridges to Swift `async` (a failure surfaces to the Swift `catch`). Only [IosTemplate]
22+
* (a flat value) and `Int`/`String` cross to Swift — no `Flow`, no `Room` entity, no `DataStore`.
23+
*
24+
* The bridge owns ONLY template-DB operations; it does NOT touch the watermark config. Applying a template
25+
* to the live watermark text is the Swift workflow's job (it calls its existing watermark-text setter),
26+
* so this bridge never opens a second watermark DataStore / duplicates [IosWatermarkConfigBridge].
27+
*
28+
* Single-instance-per-file: the templates Room DB is one file; a real iOS app retains ONE bridge (held in
29+
* `WatermarkWorkflow.swift`), exactly as it retains one [IosWatermarkConfigBridge] / [IosUserConfigBridge].
30+
*/
31+
class IosTemplateBridge(private val repo: TemplateRepository) {
32+
33+
private val editor = TemplateEditor(repo)
34+
35+
/** One-shot snapshot of the persisted templates as flat values (no `Flow` exposed to Swift). */
36+
suspend fun currentTemplates(): List<IosTemplate> =
37+
repo.getAllTemplate().first().map { IosTemplate(it.id, it.content ?: "") }
38+
39+
/** Insert a new template with [content]. Suspends; a write failure surfaces as a Swift `async` error. */
40+
suspend fun addTemplate(content: String) {
41+
editor.add(content)
42+
}
43+
44+
/**
45+
* Delete the template with [id]. Resolves the row from the current snapshot (the editor's delete takes
46+
* the entity), so Swift only passes an `Int`. A missing id is a no-op. Suspends; failures surface to Swift.
47+
*/
48+
suspend fun deleteTemplate(id: Int) {
49+
repo.getAllTemplate().first().firstOrNull { it.id == id }?.let { editor.delete(it) }
50+
}
51+
}
52+
53+
/**
54+
* Build an [IosTemplateBridge] over the app's default **seeded** iOS templates DB — the no-arg
55+
* [buildTemplateDatabase] (S4d-232: `NSDocumentDirectory` store + the bundled Android seed DB on first
56+
* creation). A real iOS app calls this ONCE and retains the result (single-instance-per-file).
57+
* `Dispatchers.Default` is used for the repo query context because `Dispatchers.IO` is internal on the
58+
* Native target (S4d-231).
59+
*/
60+
fun defaultIosTemplateBridge(): IosTemplateBridge =
61+
IosTemplateBridge(TemplateRepository(buildTemplateDatabase().templateDao(), Dispatchers.Default))
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package me.rosuh.easywatermark.data.repo
2+
3+
import kotlinx.cinterop.ExperimentalForeignApi
4+
import kotlinx.coroutines.Dispatchers
5+
import kotlinx.coroutines.runBlocking
6+
import me.rosuh.easywatermark.data.db.buildTemplateDatabase
7+
import me.rosuh.easywatermark.domain.TemplateEditor
8+
import okio.FileSystem
9+
import okio.Path.Companion.toPath
10+
import platform.Foundation.NSFileManager
11+
import platform.Foundation.NSTemporaryDirectory
12+
import platform.Foundation.NSUUID
13+
import kotlin.test.Test
14+
import kotlin.test.assertEquals
15+
import kotlin.test.assertTrue
16+
17+
/**
18+
* S4d-233: iOS runtime proof of the Swift-facing [IosTemplateBridge] over the common templates store.
19+
* RUNS on `iosSimulatorArm64Test`.
20+
*
21+
* The bridge is exercised over test-controlled DBs built with the parameterized `buildTemplateDatabase`
22+
* overloads (empty-store and S4d-232 seed-bytes), NOT the no-arg `buildTemplateDatabase()` — that path
23+
* reads the seed from `NSBundle.mainBundle`, which a Kotlin/Native test executable's bundle does not carry
24+
* (see `IosFontLoaderTest`). The no-arg seeded path used by `defaultIosTemplateBridge()` is proven by the
25+
* S4d-232 packaging gate (the seed ships in `iosApp.app`) + the live app.
26+
*/
27+
class IosTemplateBridgeTest {
28+
29+
@OptIn(ExperimentalForeignApi::class)
30+
private fun uniqueDir(tag: String): String {
31+
val dir = NSTemporaryDirectory() + "s4d233_${tag}_" + NSUUID().UUIDString()
32+
NSFileManager.defaultManager.createDirectoryAtPath(
33+
path = dir,
34+
withIntermediateDirectories = true,
35+
attributes = null,
36+
error = null,
37+
)
38+
return dir
39+
}
40+
41+
@Test
42+
fun bridge_add_list_delete_roundtrip() = runBlocking {
43+
val db = buildTemplateDatabase(uniqueDir("roundtrip"))
44+
try {
45+
val bridge = IosTemplateBridge(TemplateRepository(db.templateDao(), Dispatchers.Default))
46+
assertTrue(bridge.currentTemplates().isEmpty(), "empty store starts empty")
47+
48+
bridge.addTemplate("ios bridge A")
49+
bridge.addTemplate("ios bridge B")
50+
val after = bridge.currentTemplates()
51+
assertEquals(2, after.size, "two templates after add")
52+
assertTrue(after.any { it.content == "ios bridge A" }, "content A round-trips")
53+
assertTrue(after.any { it.content == "ios bridge B" }, "content B round-trips")
54+
assertTrue(after.all { it.id != 0 }, "rows carry autoGenerate ids")
55+
56+
val target = after.first { it.content == "ios bridge A" }
57+
bridge.deleteTemplate(target.id)
58+
val afterDelete = bridge.currentTemplates()
59+
assertEquals(1, afterDelete.size, "one template after delete")
60+
assertTrue(afterDelete.none { it.id == target.id }, "the deleted id is gone")
61+
assertTrue(afterDelete.any { it.content == "ios bridge B" }, "the other template remains")
62+
} finally {
63+
db.close()
64+
}
65+
}
66+
67+
@Test
68+
fun bridge_reads_seeded_rows() = runBlocking {
69+
// Produce a valid seed DB (mirrors S4d-232), then prove the bridge reads seeded content.
70+
val seedDir = uniqueDir("seedsrc")
71+
val seedDb = buildTemplateDatabase(seedDir)
72+
try {
73+
TemplateEditor(TemplateRepository(seedDb.templateDao(), Dispatchers.Default)).add("seeded template")
74+
} finally {
75+
seedDb.close()
76+
}
77+
val seedBytes = FileSystem.SYSTEM.read("$seedDir/ewm-db".toPath()) { readByteArray() }
78+
79+
val db = buildTemplateDatabase(uniqueDir("seeded"), seedBytes = seedBytes)
80+
try {
81+
val bridge = IosTemplateBridge(TemplateRepository(db.templateDao(), Dispatchers.Default))
82+
val rows = bridge.currentTemplates()
83+
assertEquals(1, rows.size, "the seeded row is visible through the bridge")
84+
assertTrue(rows.any { it.content == "seeded template" }, "seeded content reads back")
85+
} finally {
86+
db.close()
87+
}
88+
}
89+
}

0 commit comments

Comments
 (0)