diff --git a/.gitignore b/.gitignore index 4b517ef..86263dc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ data/ .env __pycache__/ .pytest_cache/ +android/.gradle/ +android/.kotlin/ +android/build/ +android/app/build/ +android/local.properties diff --git a/Makefile b/Makefile index 1d80cbe..6d298fb 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ # Extension: make install | make update # Server: make dev (lokal, Port $(PORT)) | make prod (auf dem Server) | make stop | make logs | make down | make test +# Android: make apk (bauen) | make apk-install (aufs Handy per USB) UUID = desktop@marha.de SRC = $(CURDIR)/$(UUID) DEST = $(HOME)/.local/share/gnome-shell/extensions/$(UUID) @@ -9,7 +10,7 @@ SYNC_URL = https://planer.marha.de SYNC_JSON = $(HOME)/.local/share/desktop-app/sync.json .ONESHELL: -.PHONY: install update enable sync-config reload venv dev prod stop logs down test +.PHONY: install update enable sync-config reload venv dev prod stop logs down test apk apk-install install: @if [ -e "$(DEST)" ]; then @@ -94,3 +95,22 @@ down: test: venv cd backend && .venv/bin/python -m pytest -q + +# ── Android ────────────────────────────────────────────────────────────────── +APK = android/app/build/outputs/apk/debug/app-debug.apk + +# Werkzeuge nach ~/Android holen (einmalig, ohne Root), dann bauen +apk: + @grep -q "^SYNC_TOKEN=.\+" .env 2>/dev/null || echo "Hinweis: .env ohne SYNC_TOKEN, der Zugang muss dann in der App eingetragen werden." + [ -x "$$HOME/Android/jdk17/bin/java" ] || android/dev/setup.sh + . android/dev/env.sh + cd android && gradle assembleDebug -q + echo "APK: $(APK)" + +# Aufs per USB verbundene Handy (Entwickleroptionen, USB-Debugging, Dialog am Handy bestätigen) +apk-install: apk + @. android/dev/env.sh + PHONE=$$(adb devices | awk 'NR>1 && $$2=="device" && $$1 !~ /^emulator/ {print $$1}' | head -1) + [ -n "$$PHONE" ] || { echo "Kein Handy gefunden. adb devices zeigt:"; adb devices; exit 1; } + adb -s "$$PHONE" install -r "$(APK)" + echo "Installiert auf $$PHONE. Server-Zugang aus .env ist eingebaut." diff --git a/README.md b/README.md index 4f825a8..e62c1f6 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ unter allen Fenstern, über dem Wallpaper. - `store.js` – Ablage, Tombstones, Übernahme vom Server - `sync.js` – Abgleich mit dem Server (Soup, Timer) - `backend/` – Sync-Server (FastAPI, SQLite), `Dockerfile`, `docker-compose.yml` +- `android/` – native Android-App mit denselben drei Bereichen, siehe `android/README.md` - `dev/` – Test-Werkzeuge ## Daten @@ -80,6 +81,7 @@ make dev # lokal, http://localhost:8040 (anderer Port: make dev P make prod # auf dem Server: Docker Compose hinter Traefik, https://planer.marha.de make stop | make logs | make down make test # Server-Tests +make apk # Android-App bauen, make apk-install schickt sie aufs Handy (android/README.md) ``` ## Installation diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..d7f5001 --- /dev/null +++ b/android/README.md @@ -0,0 +1,42 @@ +# Planer für Android + +Native App (Kotlin, Jetpack Compose) mit drei Tabs: Kalender, Todos, Notizen. Gleicher Server, gleiche Daten wie die Desktop-Extension. + +## Bedienung + +- **Kalender:** Monat, Woche, Tag. Tipp auf einen Tag im Monat öffnet den Tag. Langes Drücken auf einen Tag oder eine Stunde: Termin oder Todo erstellen. Kurzer Tipp auf eine Stunde in der Woche: Termin ab dieser Stunde. Tipp auf einen Block bearbeitet, im Dialog steht auch Löschen. +- **Todos:** Häkchen erledigt, Tipp auf den Text bearbeitet, Plus legt an. +- **Notizen:** Tipp öffnet Ordner oder Notiz. Langes Drücken: Erstellen, Umbenennen, Löschen. In der Notiz bearbeitet ein Tipp auf den Text, gespeichert wird alle 5 Sekunden und bei „Fertig“. +- **Server:** Zugang steckt schon in der APK (aus der `.env` beim Bauen). Hier lässt er sich ändern. Der Sync läuft im Hintergrund, solange die App offen ist. + +## Bauen und installieren + +``` +make apk # holt beim ersten Mal die Werkzeuge nach ~/Android (JDK 17, SDK, Gradle), baut die APK mit URL und SYNC_TOKEN aus .env +make apk-install # installiert auf ein per USB verbundenes Handy +``` + +Am Handy: Entwickleroptionen, USB-Debugging an, beim Anstecken den Dialog „USB-Debugging zulassen“ bestätigen. +Alternativ die APK (`android/app/build/outputs/apk/debug/app-debug.apk`) aufs Handy kopieren und dort öffnen. + +## Entwicklung + +``` +source android/dev/env.sh # Werkzeuge in den PATH +cd android && gradle test # Unit-Tests (Dates, Markdown, Store) +android/dev/setup.sh # legt auch den Emulator „test“ an +emulator -avd test -no-window & # Emulator ohne Fenster (mit Fenster: ohne -no-window) +android/dev/ui.sh boot # wartet, bis er bereit ist +android/dev/ui.sh install # APK in den Emulator, App starten +android/dev/ui.sh tap "Todos" # Bedienen: tap, longtap, type, key, has, texts, shot, dump +``` + +Im Emulator erreicht `http://10.0.2.2:8040` den lokalen Server aus `make dev`. +Bei USB-Handy plus Emulator nimmt `ui.sh` immer den Emulator. + +## Aufbau + +- `data/Store.kt` – Ablage als JSON in den App-Dateien, Tombstones, Übernahme vom Server +- `data/Sync.kt` – Abgleich wie die Extension: sofort nach eigener Änderung, sonst 1s/5s/10s je nach Ruhe +- `data/Markdown.kt`, `data/Dates.kt` – gleiche Regeln wie `markdown.js` und `dates.js` +- `ui/` – Kalender, Todos, Notizen, Server-Einstellungen, Dialoge diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..493620b --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,66 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +// Server-Zugang aus der .env im Projektordner in die App einbauen, damit nichts eingetippt werden muss +val env = Properties().apply { + val file = rootProject.file("../.env") + if (file.exists()) file.reader().use { load(it) } +} +fun envValue(key: String, default: String) = env.getProperty(key)?.trim()?.trim('"', '\'')?.ifEmpty { null } ?: default +val syncUrl = envValue("SYNC_URL", "https://planer.marha.de") +val syncToken = envValue("SYNC_TOKEN", "") + +android { + namespace = "de.marha.planer" + compileSdk = 34 + + defaultConfig { + applicationId = "de.marha.planer" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + buildConfigField("String", "SYNC_URL", "\"$syncUrl\"") + buildConfigField("String", "SYNC_TOKEN", "\"$syncToken\"") + } + + buildTypes { + release { + isMinifyEnabled = false + signingConfig = signingConfigs.getByName("debug") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + buildConfig = true + } + testOptions { + unitTests.isReturnDefaultValues = true + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.09.02") + implementation(composeBom) + implementation("androidx.activity:activity-compose:1.9.2") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20240303") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0e1d96c --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/de/marha/planer/MainActivity.kt b/android/app/src/main/kotlin/de/marha/planer/MainActivity.kt new file mode 100644 index 0000000..dbd738b --- /dev/null +++ b/android/app/src/main/kotlin/de/marha/planer/MainActivity.kt @@ -0,0 +1,93 @@ +package de.marha.planer + +import android.content.Context +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import de.marha.planer.data.Store +import de.marha.planer.data.Sync +import de.marha.planer.ui.CalendarScreen +import de.marha.planer.ui.NotesScreen +import de.marha.planer.ui.PlanerTheme +import de.marha.planer.ui.SettingsScreen +import de.marha.planer.ui.TodosScreen +import java.io.File + +class MainActivity : ComponentActivity() { + private lateinit var store: Store + private lateinit var sync: Sync + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val dir = File(filesDir, "data") + store = Store(dir) + sync = Sync(store, getSharedPreferences("sync", Context.MODE_PRIVATE), dir, lifecycleScope) + setContent { + PlanerTheme { App(store, sync) } + } + } + + // Abgleich läuft, solange die App sichtbar ist + override fun onStart() { + super.onStart() + sync.start() + } + + override fun onStop() { + super.onStop() + sync.stop() + } +} + +private val tabs = listOf( + Triple("Kalender", Icons.Filled.CalendarMonth, 0), + Triple("Todos", Icons.Filled.Checklist, 1), + Triple("Notizen", Icons.Filled.Description, 2), + Triple("Server", Icons.Filled.Settings, 3), +) + +@Composable +fun App(store: Store, sync: Sync) { + // Ohne Server-Zugang zuerst die Einstellungen zeigen + var tab by rememberSaveable { mutableIntStateOf(if (sync.active) 0 else 3) } + Scaffold( + bottomBar = { + NavigationBar { + for ((label, icon, index) in tabs) { + NavigationBarItem( + selected = tab == index, + onClick = { tab = index }, + icon = { Icon(icon, contentDescription = label) }, + label = { Text(label) }, + ) + } + } + }, + ) { padding -> + val modifier = Modifier.padding(padding) + when (tab) { + 0 -> CalendarScreen(store, modifier) + 1 -> TodosScreen(store, modifier) + 2 -> NotesScreen(store, modifier) + else -> SettingsScreen(sync, modifier) + } + } +} diff --git a/android/app/src/main/kotlin/de/marha/planer/ui/CalendarScreen.kt b/android/app/src/main/kotlin/de/marha/planer/ui/CalendarScreen.kt new file mode 100644 index 0000000..8bbc64b --- /dev/null +++ b/android/app/src/main/kotlin/de/marha/planer/ui/CalendarScreen.kt @@ -0,0 +1,408 @@ +package de.marha.planer.ui + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import de.marha.planer.data.Dates +import de.marha.planer.data.Event +import de.marha.planer.data.Store +import de.marha.planer.data.Todo +import java.time.LocalDate +import java.time.LocalTime + +private val HOUR_HEIGHT = 48.dp +private val HOUR_LABEL_WIDTH = 40.dp +private val ALLDAY_LANE = 20.dp + +private fun pad(n: Int) = "%02d".format(n) + +fun endTimeOf(event: Event): String = event.end ?: Dates.minutesToTime(minOf(Dates.timeToMinutes(event.start!!) + 60, 23 * 60 + 59)) + +// 'ganztags', '09:00–10:30', bei mehrtägigen Terminen mit Enddatum. +fun timeRange(event: Event): String { + val multiDay = !event.singleDay + if (event.start == null) return if (multiDay) "ganztags bis ${Dates.formatDayMonth(event.endDateOrDate)}" else "ganztags" + if (multiDay) return "${event.start} – ${Dates.formatDayMonth(event.endDateOrDate)} ${endTimeOf(event)}" + return "${event.start}–${endTimeOf(event)}" +} + +// Anteil eines Termins an einem Tag in Minuten: [von, bis) +private fun minuteSpan(event: Event, day: String): Pair { + val from = if (day == event.date) Dates.timeToMinutes(event.start!!) else 0 + val to = if (day == event.endDateOrDate) Dates.timeToMinutes(endTimeOf(event)) else 24 * 60 + return from to maxOf(to, from + 30) +} + +private data class Block(val event: Event, val dayIndex: Int, val from: Int, val to: Int, var col: Int = 0, var cols: Int = 1) + +// Überlappende Termine bekommen Spalten nebeneinander. +private fun assignColumns(items: List) { + val sorted = items.sortedWith(compareBy({ it.from }, { -it.to })) + fun overlaps(a: Block, b: Block) = a.from < b.to && b.from < a.to + for (item in sorted) { + val used = sorted.filter { it !== item && it.col >= 0 && overlaps(it, item) && sorted.indexOf(it) < sorted.indexOf(item) }.map { it.col }.toSet() + var col = 0 + while (used.contains(col)) col++ + item.col = col + } + for (item in sorted) item.cols = (sorted.filter { overlaps(it, item) }.maxOfOrNull { it.col } ?: 0) + 1 +} + +private data class Lane(val event: Event, val from: Int, val to: Int, var lane: Int = 0) + +// Ganztägige Termine bekommen Zeilen untereinander, wenn sich ihre Tage überschneiden. +private fun assignLanes(items: List): Int { + val laneEnds = mutableListOf() + for (item in items.sortedWith(compareBy({ it.from }, { -it.to }))) { + var lane = laneEnds.indexOfFirst { it < item.from } + if (lane == -1) { + laneEnds.add(item.to) + lane = laneEnds.size - 1 + } else laneEnds[lane] = item.to + item.lane = lane + } + return laneEnds.size +} + +// Kalender: Monat, Woche, Tag. Tipp bearbeitet oder legt an, langes Drücken öffnet das Menü. +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarScreen(store: Store, modifier: Modifier = Modifier) { + store.version.intValue + var view by rememberSaveable { mutableStateOf("month") } + var cursor by rememberSaveable { mutableStateOf(Dates.today()) } + var eventDialog by remember { mutableStateOf(null) } + var todoDialog by remember { mutableStateOf(null) } + // Menü „Termin/Todo erstellen“: Tag, Uhrzeit und Position auf dem Bildschirm + var createMenu by remember { mutableStateOf?>(null) } + var rootOrigin by remember { mutableStateOf(Offset.Zero) } + val density = LocalDensity.current + + fun newEvent(day: String, time: String?) { + eventDialog = Event(id = "", title = "", date = day, start = time, end = time?.let { Dates.minutesToTime(minOf(Dates.timeToMinutes(it) + 60, 23 * 60 + 59)) }, created = "", updated = "") + } + fun newTodo(day: String, time: String?) { + todoDialog = Todo(id = "", title = "", date = day, time = time, created = "", updated = "") + } + fun openMenu(day: String, time: String?, positionInRoot: Offset) { + val local = positionInRoot - rootOrigin + createMenu = Triple(day, time, with(density) { DpOffset(local.x.toDp(), local.y.toDp()) }) + } + + fun shift(n: Int) { + cursor = when (view) { + "month" -> LocalDate.parse(cursor).withDayOfMonth(1).plusMonths(n.toLong()).toString() + "week" -> Dates.addDays(cursor, 7L * n) + else -> Dates.addDays(cursor, n.toLong()) + } + } + + val date = LocalDate.parse(cursor) + val title = when (view) { + "month" -> Dates.formatMonth(date.year, date.monthValue) + "week" -> Dates.weekDays(cursor).let { "${Dates.formatDayMonth(it[0])} – ${Dates.formatDateInput(it[6])}" } + else -> Dates.formatDate(cursor) + } + + Box(modifier.fillMaxSize().onGloballyPositioned { rootOrigin = it.positionInRoot() }) { + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { shift(-1) }) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Zurück") } + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + IconButton(onClick = { shift(1) }) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Weiter") } + Spacer(Modifier.weight(1f)) + TextButton(onClick = { cursor = Dates.today() }) { Text("Heute") } + } + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(horizontal = 12.dp)) { + listOf("month" to "Monat", "week" to "Woche", "day" to "Tag").forEachIndexed { index, (key, label) -> + SegmentedButton(selected = view == key, onClick = { view = key }, shape = SegmentedButtonDefaults.itemShape(index, 3)) { Text(label) } + } + } + when (view) { + "month" -> MonthView(store, cursor, onDay = { cursor = it; view = "day" }, onEvent = { eventDialog = it }, onTodo = { todoDialog = it }, onNewEvent = { newEvent(it, null) }, onNewTodo = { newTodo(it, null) }) + "week" -> WeekView(store, cursor, onEvent = { eventDialog = it }, onTodo = { todoDialog = it }, onTap = { day, time -> newEvent(day, time) }, onLongPress = { day, time, pos -> openMenu(day, time, pos) }) + else -> DayView(store, cursor, onEvent = { eventDialog = it }, onTodo = { todoDialog = it }, onNewEvent = { newEvent(cursor, null) }, onNewTodo = { newTodo(cursor, null) }) + } + } + createMenu?.let { (day, time, offset) -> + DropdownMenu(expanded = true, onDismissRequest = { createMenu = null }, offset = offset) { + DropdownMenuItem(text = { Text("Termin erstellen") }, onClick = { newEvent(day, time); createMenu = null }) + DropdownMenuItem(text = { Text("Todo erstellen") }, onClick = { newTodo(day, time); createMenu = null }) + } + } + } + + eventDialog?.let { EventDialog(store, it, onDismiss = { eventDialog = null }) } + todoDialog?.let { TodoDialog(store, it, onDismiss = { todoDialog = null }) } +} + +// ---- Monat ---- + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun MonthView( + store: Store, cursor: String, + onDay: (String) -> Unit, onEvent: (Event) -> Unit, onTodo: (Todo) -> Unit, + onNewEvent: (String) -> Unit, onNewTodo: (String) -> Unit, +) { + store.version.intValue // neu zeichnen, sobald sich Daten ändern (Strong Skipping überspringt sonst) + val date = LocalDate.parse(cursor) + val days = Dates.monthGrid(date.year, date.monthValue) + val today = Dates.today() + var menuDay by remember { mutableStateOf(null) } + + Column(Modifier.fillMaxSize().padding(horizontal = 4.dp)) { + Row(Modifier.fillMaxWidth()) { + for (name in Dates.WEEKDAYS) Text(name, Modifier.weight(1f), color = Muted, fontSize = 11.sp, textAlign = androidx.compose.ui.text.style.TextAlign.Center) + } + for (week in 0 until 6) { + Row(Modifier.fillMaxWidth().weight(1f)) { + for (day in days.subList(week * 7, week * 7 + 7)) { + val otherMonth = LocalDate.parse(day).monthValue != date.monthValue + val events = store.eventsOn(day) + val todos = store.todosOn(day) + Box( + Modifier.weight(1f).fillMaxSize().padding(1.dp) + .background(Color(0x0AFFFFFF), RoundedCornerShape(6.dp)) + .then(if (day == today) Modifier.border(1.dp, Orange, RoundedCornerShape(6.dp)) else Modifier) + .combinedClickable(onClick = { onDay(day) }, onLongClick = { menuDay = day }) + .padding(3.dp), + ) { + Column { + Text(day.substring(8).trimStart('0'), fontWeight = FontWeight.Bold, fontSize = 12.sp, color = if (otherMonth) Color(0xFF777777) else MaterialTheme.colorScheme.onBackground) + val entries = events.map { e -> (if (e.start != null && e.singleDay) "${e.start} ${e.title}" else e.title) to { onEvent(e) } } + + todos.map { t -> ((if (t.done) "☑ " else "☐ ") + (t.time?.let { "$it " } ?: "") + t.title) to { onTodo(t) } } + entries.take(2).forEachIndexed { index, (text, click) -> + val isTodo = index >= events.size + Text( + text, fontSize = 9.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White, + modifier = Modifier.padding(top = 2.dp).fillMaxWidth() + .background(if (isTodo) TodoBlue.copy(alpha = 0.35f) else Orange.copy(alpha = 0.5f), RoundedCornerShape(3.dp)) + .clickable { click() }.padding(horizontal = 3.dp), + ) + } + if (entries.size > 2) Text("+${entries.size - 2}", fontSize = 9.sp, color = Muted) + } + DropdownMenu(expanded = menuDay == day, onDismissRequest = { menuDay = null }) { + DropdownMenuItem(text = { Text("Termin erstellen") }, onClick = { onNewEvent(day); menuDay = null }) + DropdownMenuItem(text = { Text("Todo erstellen") }, onClick = { onNewTodo(day); menuDay = null }) + } + } + } + } + } + } +} + +// ---- Woche ---- + +@Composable +private fun WeekView( + store: Store, cursor: String, + onEvent: (Event) -> Unit, onTodo: (Todo) -> Unit, + onTap: (String, String?) -> Unit, onLongPress: (String, String?, Offset) -> Unit, +) { + store.version.intValue // neu zeichnen, sobald sich Daten ändern (Strong Skipping überspringt sonst) + val days = Dates.weekDays(cursor) + val today = Dates.today() + val todayIndex = days.indexOf(today) + val density = LocalDensity.current + val scroll = rememberScrollState() + + // Ganztägige Termine als Blöcke über ihre Tage + val allDay = days.flatMap { store.eventsOn(it) }.filter { it.start == null }.distinctBy { it.id } + .map { e -> Lane(e, days.indexOf(e.date).let { if (it < 0) 0 else it }, days.indexOf(e.endDateOrDate).let { if (it < 0) 6 else it }) } + val lanes = assignLanes(allDay) + + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxWidth().padding(top = 4.dp)) { + Text("KW ${Dates.isoWeek(days[0])}", Modifier.width(HOUR_LABEL_WIDTH), color = Muted, fontSize = 9.sp) + for (day in days) { + val d = LocalDate.parse(day) + Text("${Dates.WEEKDAYS[d.dayOfWeek.value - 1]} ${d.dayOfMonth}.", Modifier.weight(1f), fontSize = 11.sp, + color = if (day == today) Orange else Color(0xFFCCCCCC), fontWeight = if (day == today) FontWeight.Bold else null, + textAlign = androidx.compose.ui.text.style.TextAlign.Center) + } + } + // Ganztags-Zeile: Blöcke, darunter Todos ohne Uhrzeit. Tipp legt einen ganztägigen Termin an. + Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text("ganz", Modifier.width(HOUR_LABEL_WIDTH), color = Muted, fontSize = 9.sp) + BoxWithConstraints(Modifier.weight(1f).height(maxOf(22.dp, ALLDAY_LANE * lanes + 4.dp))) { + val colWidth = maxWidth / 7 + Row(Modifier.fillMaxSize()) { + for (day in days) Box(Modifier.weight(1f).fillMaxSize().padding(1.dp).background(Color(0x0AFFFFFF), RoundedCornerShape(3.dp)).pointerInput(day) { + detectTapGestures(onTap = { onTap(day, null) }, onLongPress = { onLongPress(day, null, it) }) + }) + } + for (lane in allDay) { + Box( + Modifier.offset(x = colWidth * lane.from, y = ALLDAY_LANE * lane.lane) + .width(colWidth * (lane.to - lane.from + 1)).height(ALLDAY_LANE - 2.dp).padding(horizontal = 1.dp) + .background(Orange.copy(alpha = 0.8f), RoundedCornerShape(3.dp)).clickable { onEvent(lane.event) }.padding(horizontal = 4.dp), + ) { Text(lane.event.title, fontSize = 10.sp, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis) } + } + } + } + // Stundenraster mit Terminblöcken, roter Linie und Todos mit Uhrzeit + Box(Modifier.weight(1f).verticalScroll(scroll)) { + BoxWithConstraints(Modifier.fillMaxWidth().height(HOUR_HEIGHT * 24)) { + val colWidth = (maxWidth - HOUR_LABEL_WIDTH) / 7 + var gridOrigin by remember { mutableStateOf(Offset.Zero) } + val hourPx = with(density) { HOUR_HEIGHT.toPx() } + val colPx = with(density) { colWidth.toPx() } + val labelPx = with(density) { HOUR_LABEL_WIDTH.toPx() } + fun cellAt(offset: Offset): Pair { + val dayIndex = ((offset.x - labelPx) / colPx).toInt().coerceIn(0, 6) + val hour = (offset.y / hourPx).toInt().coerceIn(0, 23) + return days[dayIndex] to "${pad(hour)}:00" + } + Column(Modifier.fillMaxSize().onGloballyPositioned { gridOrigin = it.positionInRoot() }.pointerInput(days) { + detectTapGestures( + onTap = { cellAt(it).let { (day, time) -> onTap(day, time) } }, + onLongPress = { cellAt(it).let { (day, time) -> onLongPress(day, time, gridOrigin + it) } }, + ) + }) { + for (hour in 0 until 24) { + Row(Modifier.fillMaxWidth().height(HOUR_HEIGHT)) { + Text("${pad(hour)}:00", Modifier.width(HOUR_LABEL_WIDTH).padding(top = 2.dp), color = Muted, fontSize = 9.sp) + for (i in 0 until 7) Box(Modifier.weight(1f).fillMaxSize().padding(1.dp).background(Color(0x0AFFFFFF), RoundedCornerShape(3.dp))) + } + } + } + // Termine als Blöcke, überlappende nebeneinander + days.forEachIndexed { dayIndex, day -> + val blocks = store.eventsOn(day).filter { it.start != null }.map { e -> minuteSpan(e, day).let { (f, t) -> Block(e, dayIndex, f, t) } } + assignColumns(blocks) + for (block in blocks) { + val width = colWidth / block.cols + Box( + Modifier.offset(x = HOUR_LABEL_WIDTH + colWidth * dayIndex + width * block.col, y = HOUR_HEIGHT * block.from / 60f) + .width(width).height(HOUR_HEIGHT * (block.to - block.from) / 60f - 2.dp).padding(horizontal = 1.dp) + .background(Orange.copy(alpha = 0.8f), RoundedCornerShape(4.dp)) + .border(1.dp, Color(0x40FFFFFF), RoundedCornerShape(4.dp)) + .clickable { onEvent(block.event) }.padding(horizontal = 3.dp, vertical = 1.dp), + ) { + Column { + Text(if (day == block.event.date) block.event.start!! else "…", fontSize = 8.sp, color = Color(0xDDFFFFFF)) + Text(block.event.title, fontSize = 10.sp, color = Color.White, fontWeight = FontWeight.Bold, overflow = TextOverflow.Clip) + } + } + } + for (todo in store.todosOn(day).filter { it.time != null }) { + Box( + Modifier.offset(x = HOUR_LABEL_WIDTH + colWidth * dayIndex, y = HOUR_HEIGHT * Dates.timeToMinutes(todo.time!!) / 60f) + .width(colWidth).height(16.dp).padding(horizontal = 1.dp) + .background(TodoBlue.copy(alpha = 0.5f), RoundedCornerShape(3.dp)).clickable { onTodo(todo) }.padding(horizontal = 2.dp), + ) { Text((if (todo.done) "☑ " else "☐ ") + todo.title, fontSize = 9.sp, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis) } + } + } + // Rote Linie an der aktuellen Uhrzeit in der Spalte von heute + if (todayIndex >= 0) { + val now = LocalTime.now() + Box(Modifier.offset(x = HOUR_LABEL_WIDTH + colWidth * todayIndex, y = HOUR_HEIGHT * (now.hour * 60 + now.minute) / 60f - 1.dp).width(colWidth).height(2.dp).background(NowRed)) + } + } + } + } + // Beim Öffnen die aktuelle Uhrzeit in die Mitte + LaunchedEffect(scroll.maxValue) { + if (scroll.maxValue > 0 && scroll.value == 0) { + val now = LocalTime.now() + val nowPx = with(density) { HOUR_HEIGHT.toPx() } * (now.hour * 60 + now.minute) / 60f + val viewport = with(density) { HOUR_HEIGHT.toPx() } * 24 - scroll.maxValue + scroll.scrollTo((nowPx - viewport / 2).toInt().coerceIn(0, scroll.maxValue)) + } + } +} + +// ---- Tag ---- + +@Composable +private fun DayView(store: Store, day: String, onEvent: (Event) -> Unit, onTodo: (Todo) -> Unit, onNewEvent: () -> Unit, onNewTodo: () -> Unit) { + store.version.intValue // neu zeichnen, sobald sich Daten ändern (Strong Skipping überspringt sonst) + val events = store.eventsOn(day) + val todos = store.todosOn(day) + LazyColumn(Modifier.fillMaxSize().padding(horizontal = 8.dp)) { + item { SectionHeader("Termine", "+ Termin", onNewEvent) } + if (events.isEmpty()) item { Text("Keine Termine", color = Muted, modifier = Modifier.padding(8.dp)) } + items(events, key = { it.id }) { event -> + Row( + Modifier.fillMaxWidth().padding(vertical = 2.dp).background(Color(0x0FFFFFFF), RoundedCornerShape(6.dp)).clickable { onEvent(event) }.padding(10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(timeRange(event), color = Orange, modifier = Modifier.width(110.dp)) + Text(event.title, fontWeight = FontWeight.Bold) + } + } + item { SectionHeader("Todos", "+ Todo", onNewTodo) } + if (todos.isEmpty()) item { Text("Keine Todos", color = Muted, modifier = Modifier.padding(8.dp)) } + items(todos, key = { it.id }) { todo -> TodoRow(todo, onToggle = { store.toggleTodo(todo.id) }, onEdit = { onTodo(todo) }) } + } +} + +@Composable +private fun SectionHeader(title: String, buttonLabel: String, onClick: () -> Unit) { + Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(title, color = Muted, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + TextButton(onClick = onClick) { Text(buttonLabel) } + } +} + +@Suppress("unused") +private fun Dp.px(density: androidx.compose.ui.unit.Density): Float = with(density) { toPx() } diff --git a/android/app/src/main/kotlin/de/marha/planer/ui/Dialogs.kt b/android/app/src/main/kotlin/de/marha/planer/ui/Dialogs.kt new file mode 100644 index 0000000..936e728 --- /dev/null +++ b/android/app/src/main/kotlin/de/marha/planer/ui/Dialogs.kt @@ -0,0 +1,264 @@ +package de.marha.planer.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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +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.unit.dp +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.runtime.LaunchedEffect +import de.marha.planer.data.Dates +import de.marha.planer.data.Event +import de.marha.planer.data.Store +import de.marha.planer.data.Todo +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset + +// Dialoge für Termin, Todo und Namen. Enter/Speichern übernimmt, Abbrechen verwirft. + +// Termin: Titel sowie Von und Bis (Datum und Uhrzeit). Ohne Ende gilt eine Stunde, ohne Uhrzeit ganztägig. +@Composable +fun EventDialog(store: Store, event: Event, onDismiss: () -> Unit) { + val isNew = event.id.isEmpty() + var title by remember { mutableStateOf(event.title) } + var date by remember { mutableStateOf(event.date) } + var start by remember { mutableStateOf(event.start) } + var endDate by remember { mutableStateOf(event.endDate) } + var end by remember { mutableStateOf(event.end) } + var error by remember { mutableStateOf(null) } + + fun save() { + val text = title.trim() + if (text.isEmpty()) { + error = "Titel fehlt" + return + } + var saved = event.copy(title = text, date = date, start = null, end = null, endDate = null) + val endDay = endDate + if (endDay != null && endDay != date) { + if (endDay < date) { + error = "Ende liegt vor dem Anfang" + return + } + saved = saved.copy(endDate = endDay) + } + val startTime = start + if (startTime != null) { + val endTime = end ?: Dates.minutesToTime(minOf(Dates.timeToMinutes(startTime) + 60, 23 * 60 + 59)) + if (saved.endDate == null && Dates.timeToMinutes(endTime) <= Dates.timeToMinutes(startTime)) { + error = "Ende liegt vor dem Anfang" + return + } + saved = saved.copy(start = startTime, end = endTime) + } + store.saveEvent(saved) + onDismiss() + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (isNew) "Neuer Termin" else "Termin bearbeiten") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TextInput("Titel", title) { title = it } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Von", modifier = Modifier.width(28.dp), color = Muted) + DateField(date, allowNone = false) { date = it ?: date } + TimeField(start) { start = it } + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Bis", modifier = Modifier.width(28.dp), color = Muted) + DateField(endDate ?: date, allowNone = false) { endDate = it } + TimeField(end) { end = it } + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { TextButton(onClick = { save() }) { Text("Speichern") } }, + dismissButton = { + Row { + if (!isNew) TextButton(onClick = { store.removeEvent(event.id); onDismiss() }) { Text("Löschen") } + TextButton(onClick = onDismiss) { Text("Abbrechen") } + } + }, + ) +} + +// Todo: Text sowie Datum und Uhrzeit. Eine Uhrzeit ohne Datum meint heute. +@Composable +fun TodoDialog(store: Store, todo: Todo, onDismiss: () -> Unit) { + val isNew = todo.id.isEmpty() + var title by remember { mutableStateOf(todo.title) } + var date by remember { mutableStateOf(todo.date) } + var time by remember { mutableStateOf(todo.time) } + var error by remember { mutableStateOf(null) } + + fun save() { + val text = title.trim() + if (text.isEmpty()) { + error = "Text fehlt" + return + } + store.saveTodo(todo.copy(title = text, date = date, time = time)) + onDismiss() + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (isNew) "Neues Todo" else "Todo bearbeiten") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TextInput("Was ist zu tun?", title) { title = it } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + DateField(date, allowNone = true) { + date = it + if (it == null) time = null + } + TimeField(time) { + time = it + if (it != null && date == null) date = Dates.today() + } + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { TextButton(onClick = { save() }) { Text("Speichern") } }, + dismissButton = { + Row { + if (!isNew) TextButton(onClick = { store.removeTodo(todo.id); onDismiss() }) { Text("Löschen") } + TextButton(onClick = onDismiss) { Text("Abbrechen") } + } + }, + ) +} + +// Einzeiliges Textfeld, das beim Öffnen den Fokus bekommt. Cursor am Ende, bei selectAll ist der Text markiert. +@Composable +private fun TextInput(label: String, initial: String, selectAll: Boolean = false, onChange: (String) -> Unit) { + var value by remember { + mutableStateOf(TextFieldValue(initial, selection = if (selectAll) TextRange(0, initial.length) else TextRange(initial.length))) + } + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { focus.requestFocus() } + OutlinedTextField( + value = value, onValueChange = { value = it; onChange(it.text) }, label = { Text(label) }, singleLine = true, + modifier = Modifier.fillMaxWidth().focusRequester(focus), + ) +} + +// Namensdialog für Notizen und Ordner. onConfirm wirft bei Problemen, die Meldung bleibt im Dialog. +@Composable +fun NameDialog(title: String, initial: String, confirmLabel: String = "Speichern", onDismiss: () -> Unit, onConfirm: (String) -> Unit) { + var name by remember { mutableStateOf(initial) } + var error by remember { mutableStateOf(null) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TextInput("Name", name, selectAll = true) { name = it } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { + TextButton(onClick = { + try { + onConfirm(name.trim()) + onDismiss() + } catch (e: IllegalArgumentException) { + error = e.message + } + }) { Text(confirmLabel) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Abbrechen") } }, + ) +} + +@Composable +fun ConfirmDialog(title: String, confirmLabel: String, onDismiss: () -> Unit, onConfirm: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + confirmButton = { TextButton(onClick = { onConfirm(); onDismiss() }) { Text(confirmLabel) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Abbrechen") } }, + ) +} + +// Datumsauswahl als Button, öffnet den Kalender. allowNone erlaubt „Kein Datum“. +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DateField(value: String?, allowNone: Boolean, onPick: (String?) -> Unit) { + var open by remember { mutableStateOf(false) } + OutlinedButton(onClick = { open = true }) { Text(value?.let { Dates.formatDate(it) } ?: "Kein Datum") } + if (open) { + val initial = (value ?: Dates.today()).let { LocalDate.parse(it).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() } + val state = rememberDatePickerState(initialSelectedDateMillis = initial) + DatePickerDialog( + onDismissRequest = { open = false }, + confirmButton = { + TextButton(onClick = { + state.selectedDateMillis?.let { onPick(Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate().toString()) } + open = false + }) { Text("OK") } + }, + dismissButton = { + Row { + if (allowNone) TextButton(onClick = { onPick(null); open = false }) { Text("Kein Datum") } + TextButton(onClick = { open = false }) { Text("Abbrechen") } + } + }, + ) { DatePicker(state = state) } + } +} + +// Uhrzeitauswahl als Button, öffnet die Uhr. „Keine Uhrzeit“ setzt zurück. +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimeField(value: String?, onPick: (String?) -> Unit) { + var open by remember { mutableStateOf(false) } + OutlinedButton(onClick = { open = true }) { Text(value ?: "Keine Uhrzeit") } + if (open) { + val minutes = value?.let { Dates.timeToMinutes(it) } ?: (8 * 60) + val state = rememberTimePickerState(initialHour = minutes / 60, initialMinute = minutes % 60, is24Hour = true) + AlertDialog( + onDismissRequest = { open = false }, + text = { TimePicker(state = state) }, + confirmButton = { TextButton(onClick = { onPick(Dates.minutesToTime(state.hour * 60 + state.minute)); open = false }) { Text("OK") } }, + dismissButton = { + Row { + TextButton(onClick = { onPick(null); open = false }) { Text("Keine Uhrzeit") } + TextButton(onClick = { open = false }) { Text("Abbrechen") } + } + }, + ) + } +} + +@Composable +fun Spacer8() = Spacer(Modifier.padding(4.dp)) diff --git a/android/app/src/main/kotlin/de/marha/planer/ui/NotesScreen.kt b/android/app/src/main/kotlin/de/marha/planer/ui/NotesScreen.kt new file mode 100644 index 0000000..5c60376 --- /dev/null +++ b/android/app/src/main/kotlin/de/marha/planer/ui/NotesScreen.kt @@ -0,0 +1,217 @@ +package de.marha.planer.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +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.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import de.marha.planer.data.Markdown +import de.marha.planer.data.Store +import de.marha.planer.data.TreeNode +import kotlinx.coroutines.delay + +private const val AUTOSAVE_MS = 5000L + +// Eine Zeile im Baum: Ordner oder Notiz mit Einrückung +private data class Row(val path: String, val name: String, val depth: Int, val isFolder: Boolean, val open: Boolean) + +// Notizen: Baum mit Ordnern, Tipp öffnet die Notiz. Langes Drücken: Erstellen, Umbenennen, Löschen. +@Composable +fun NotesScreen(store: Store, modifier: Modifier = Modifier) { + store.version.intValue + var open by rememberSaveable { mutableStateOf(null) } + var expanded by rememberSaveable { mutableStateOf(setOf()) } + var menuFor by remember { mutableStateOf(null) } + var fabMenu by remember { mutableStateOf(false) } + // Offener Dialog: Triple(Art, Ordner oder Pfad, Name) + var dialog by remember { mutableStateOf?>(null) } + var confirmDelete by remember { mutableStateOf(null) } + + val opened = open?.let { store.note(it) } + if (opened != null) { + NoteView(store, opened.id, onClose = { open = null }) + return + } + + val rows = mutableListOf() + fun collect(node: TreeNode, depth: Int) { + for (sub in node.folders) { + val isOpen = expanded.contains(sub.path) + rows.add(Row(sub.path, sub.name, depth, true, isOpen)) + if (isOpen) collect(sub, depth + 1) + } + for (note in node.notes) rows.add(Row(note.id, note.name, depth, false, false)) + } + collect(store.tree(), 0) + + Box(modifier.fillMaxSize()) { + if (rows.isEmpty()) Text("Keine Notizen", color = Muted, modifier = Modifier.padding(16.dp)) + LazyColumn(Modifier.fillMaxSize()) { + items(rows, key = { (if (it.isFolder) "f:" else "n:") + it.path }) { row -> + Box { + TreeRow( + row, + onClick = { + if (row.isFolder) expanded = if (row.open) expanded - row.path else expanded + row.path + else open = row.path + }, + onLongClick = { menuFor = row }, + ) + DropdownMenu(expanded = menuFor == row, onDismissRequest = { menuFor = null }) { + if (row.isFolder) { + DropdownMenuItem(text = { Text("Notiz erstellen") }, onClick = { dialog = Triple("note", row.path, ""); menuFor = null }) + DropdownMenuItem(text = { Text("Ordner erstellen") }, onClick = { dialog = Triple("folder", row.path, ""); menuFor = null }) + } + DropdownMenuItem(text = { Text("Umbenennen") }, onClick = { dialog = Triple(if (row.isFolder) "renameFolder" else "renameNote", row.path, row.name); menuFor = null }) + val deletable = !row.isFolder || store.folderEmpty(row.path) + DropdownMenuItem( + text = { Text(if (deletable) "Löschen" else "Löschen (Ordner ist nicht leer)") }, + enabled = deletable, + onClick = { confirmDelete = row; menuFor = null }, + ) + } + } + } + } + Box(Modifier.align(Alignment.BottomEnd).padding(16.dp)) { + FloatingActionButton(onClick = { fabMenu = true }) { Icon(Icons.Filled.Add, contentDescription = "Erstellen") } + DropdownMenu(expanded = fabMenu, onDismissRequest = { fabMenu = false }) { + DropdownMenuItem(text = { Text("Notiz erstellen") }, onClick = { dialog = Triple("note", "", ""); fabMenu = false }) + DropdownMenuItem(text = { Text("Ordner erstellen") }, onClick = { dialog = Triple("folder", "", ""); fabMenu = false }) + } + } + } + + dialog?.let { (kind, target, name) -> + when (kind) { + "note" -> NameDialog("Neue Notiz", "", "Anlegen", onDismiss = { dialog = null }) { + val id = store.createNote(target, it) + if (target.isNotEmpty()) expanded = expanded + target + open = id + } + "folder" -> NameDialog("Neuer Ordner", "", "Anlegen", onDismiss = { dialog = null }) { + val id = store.createFolder(target, it) + expanded = expanded + target + id + } + "renameNote" -> NameDialog("Umbenennen", name, "Umbenennen", onDismiss = { dialog = null }) { store.renameNote(target, it) } + else -> NameDialog("Umbenennen", name, "Umbenennen", onDismiss = { dialog = null }) { + val id = store.renameFolder(target, it) + if (expanded.contains(target)) expanded = expanded - target + id + } + } + } + confirmDelete?.let { row -> + ConfirmDialog("„${row.name}“ löschen?", "Löschen", onDismiss = { confirmDelete = null }) { + if (row.isFolder) store.deleteFolder(row.path) else store.deleteNote(row.path) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun TreeRow(row: Row, onClick: () -> Unit, onLongClick: () -> Unit) { + Row( + Modifier.fillMaxWidth() + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(start = (12 + row.depth * 20).dp, end = 12.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val icon = if (!row.isFolder) Icons.Filled.Description else if (row.open) Icons.Filled.FolderOpen else Icons.Filled.Folder + Icon(icon, contentDescription = null, tint = Muted, modifier = Modifier.padding(end = 10.dp)) + Text(row.name, style = if (row.isFolder) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyLarge) + } +} + +// Eine Notiz: formatiert anzeigen, Tipp in den Text bearbeitet. Gespeichert wird alle 5 Sekunden und beim Verlassen. +@Composable +fun NoteView(store: Store, id: String, onClose: () -> Unit) { + store.version.intValue + val note = store.note(id) + var editing by rememberSaveable { mutableStateOf(false) } + var text by rememberSaveable { mutableStateOf(note?.content ?: "") } + val focus = remember { FocusRequester() } + + fun flush() { + if (editing) store.writeNote(id, text) + } + fun stopEdit() { + flush() + editing = false + store.lockedNote = null + } + + BackHandler { if (editing) stopEdit() else onClose() } + DisposableEffect(Unit) { onDispose { flush(); store.lockedNote = null } } + LaunchedEffect(editing) { + if (editing) { + store.lockedNote = id + text = store.note(id)?.content ?: "" + focus.requestFocus() + while (true) { + delay(AUTOSAVE_MS) + flush() + } + } + } + + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxWidth().padding(4.dp), verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { if (editing) stopEdit() else onClose() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") } + Text(note?.name ?: "", style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f)) + if (editing) Text("Fertig", color = Orange, modifier = Modifier.clickable { stopEdit() }.padding(12.dp)) + } + if (editing) { + BasicTextField( + value = text, + onValueChange = { text = it }, + textStyle = TextStyle(color = MaterialTheme.colorScheme.onBackground, fontFamily = FontFamily.Monospace, fontSize = MaterialTheme.typography.bodyMedium.fontSize), + cursorBrush = SolidColor(Orange), + modifier = Modifier.fillMaxSize().padding(16.dp).focusRequester(focus), + ) + } else { + Text( + Markdown.render(note?.content ?: ""), + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).clickable { editing = true }.padding(16.dp), + ) + } + } +} diff --git a/android/app/src/main/kotlin/de/marha/planer/ui/SettingsScreen.kt b/android/app/src/main/kotlin/de/marha/planer/ui/SettingsScreen.kt new file mode 100644 index 0000000..3f06ee1 --- /dev/null +++ b/android/app/src/main/kotlin/de/marha/planer/ui/SettingsScreen.kt @@ -0,0 +1,44 @@ +package de.marha.planer.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +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.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import de.marha.planer.data.Sync + +// Server-Zugang: URL und Token, wie sync.json auf dem Desktop. +@Composable +fun SettingsScreen(sync: Sync, modifier: Modifier = Modifier) { + var url by remember { mutableStateOf(sync.url) } + var token by remember { mutableStateOf(sync.token) } + var saved by remember { mutableStateOf(false) } + + Column(modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Server", style = MaterialTheme.typography.titleLarge) + OutlinedTextField(value = url, onValueChange = { url = it; saved = false }, label = { Text("URL") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField( + value = token, onValueChange = { token = it; saved = false }, label = { Text("Token") }, singleLine = true, + visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), + ) + Button(onClick = { sync.configure(url, token); saved = true }) { Text("Speichern") } + if (saved) Text("Gespeichert. Abgleich läuft.", color = Muted) + sync.status?.let { Text("Letzter Fehler: $it", color = MaterialTheme.colorScheme.error) } + Text( + "Ohne Zugang läuft die App rein lokal. Der Token steht auf dem Server in der .env und auf dem Desktop in sync.json.", + color = Muted, style = MaterialTheme.typography.bodySmall, + ) + } +} diff --git a/android/app/src/main/kotlin/de/marha/planer/ui/Theme.kt b/android/app/src/main/kotlin/de/marha/planer/ui/Theme.kt new file mode 100644 index 0000000..6774c94 --- /dev/null +++ b/android/app/src/main/kotlin/de/marha/planer/ui/Theme.kt @@ -0,0 +1,31 @@ +package de.marha.planer.ui + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +// Dunkel mit Orange, wie die Desktop-Extension +val Orange = Color(0xFFE9672E) +val Panel = Color(0xFF1C1C1C) +val Muted = Color(0xFF999999) +val NowRed = Color(0xFFFF3B30) +val TodoBlue = Color(0xFF8AB4F8) + +private val scheme = darkColorScheme( + primary = Orange, + onPrimary = Color.White, + secondary = TodoBlue, + background = Color(0xFF121212), + onBackground = Color(0xFFE6E6E6), + surface = Panel, + onSurface = Color(0xFFE6E6E6), + surfaceVariant = Color(0xFF2A2A2A), + onSurfaceVariant = Color(0xFFBBBBBB), + outline = Color(0xFF444444), +) + +@Composable +fun PlanerTheme(content: @Composable () -> Unit) { + MaterialTheme(colorScheme = scheme, content = content) +} diff --git a/android/app/src/main/kotlin/de/marha/planer/ui/TodosScreen.kt b/android/app/src/main/kotlin/de/marha/planer/ui/TodosScreen.kt new file mode 100644 index 0000000..7d72b00 --- /dev/null +++ b/android/app/src/main/kotlin/de/marha/planer/ui/TodosScreen.kt @@ -0,0 +1,82 @@ +package de.marha.planer.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Checkbox +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.graphics.Color +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import de.marha.planer.data.Dates +import de.marha.planer.data.Store +import de.marha.planer.data.Todo + +// Todo-Liste: Häkchen hakt ab, Tipp auf den Text bearbeitet, Plus legt neu an. +@Composable +fun TodosScreen(store: Store, modifier: Modifier = Modifier) { + store.version.intValue + var editing by remember { mutableStateOf(null) } + val todos = store.sortedTodos() + + Box(modifier.fillMaxSize()) { + if (todos.isEmpty()) { + Text("Keine Todos", color = Muted, modifier = Modifier.padding(16.dp)) + } + LazyColumn(Modifier.fillMaxSize()) { + items(todos, key = { it.id }) { todo -> + TodoRow(todo, onToggle = { store.toggleTodo(todo.id) }, onEdit = { editing = todo }) + } + } + FloatingActionButton( + onClick = { editing = Todo(id = "", title = "", created = "", updated = "") }, + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + ) { Icon(Icons.Filled.Add, contentDescription = "Neues Todo") } + } + + editing?.let { todo -> TodoDialog(store, todo, onDismiss = { editing = null }) } +} + +// '17.09. 14:00', '17.09.' oder '' bei allgemeinen Todos. +fun todoWhen(todo: Todo): String { + val date = todo.date ?: return "" + return if (todo.time != null) "${Dates.formatDayMonth(date)} ${todo.time}" else Dates.formatDayMonth(date) +} + +@Composable +fun TodoRow(todo: Todo, onToggle: () -> Unit, onEdit: () -> Unit) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onEdit).padding(horizontal = 8.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = todo.done, onCheckedChange = { onToggle() }) + Text( + todo.title.lineSequence().first(), + modifier = Modifier.weight(1f), + color = if (todo.done) Color(0xFF888888) else MaterialTheme.colorScheme.onBackground, + textDecoration = if (todo.done) TextDecoration.LineThrough else null, + ) + val when_ = todoWhen(todo) + if (when_.isNotEmpty()) { + val overdue = !todo.done && (todo.date ?: "") < Dates.today() + Text(when_, color = if (overdue) Color(0xFFFF6B6B) else Muted, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(end = 8.dp)) + } + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..322a990 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,10 @@ + + + + + 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..a8a8fa5 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + 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..e1a443e --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,3 @@ + + #1A1A1A + 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..e74f2d9 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Planer + diff --git a/android/app/src/test/kotlin/de/marha/planer/DatesTest.kt b/android/app/src/test/kotlin/de/marha/planer/DatesTest.kt new file mode 100644 index 0000000..e21ad31 --- /dev/null +++ b/android/app/src/test/kotlin/de/marha/planer/DatesTest.kt @@ -0,0 +1,40 @@ +package de.marha.planer + +import de.marha.planer.data.Dates +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DatesTest { + @Test fun monthGridStartsMondayBefore() { + val grid = Dates.monthGrid(2026, 9) + assertEquals(42, grid.size) + assertEquals("2026-08-31", grid[0]) + assertEquals("2026-06-01", Dates.monthGrid(2026, 6)[0]) + } + + @Test fun weekStartsMonday() { + assertEquals("2026-09-14", Dates.startOfWeek("2026-09-17")) + assertEquals("2026-09-14", Dates.startOfWeek("2026-09-20")) + assertEquals("2026-09-14", Dates.startOfWeek("2026-09-14")) + assertEquals(7, Dates.weekDays("2026-09-17").size) + } + + @Test fun isoWeek() { + assertEquals(38, Dates.isoWeek("2026-09-17")) + assertEquals(53, Dates.isoWeek("2027-01-01")) + } + + @Test fun formats() { + assertEquals("Do, 17.09.2026", Dates.formatDate("2026-09-17")) + assertEquals("17.09.", Dates.formatDayMonth("2026-09-17")) + assertEquals("September 2026", Dates.formatMonth(2026, 9)) + assertEquals(90, Dates.timeToMinutes("01:30")) + assertEquals("01:30", Dates.minutesToTime(90)) + } + + // Gleiches Format wie die Extension, damit sich Zeitstempel als Strings vergleichen lassen + @Test fun nowIsoHasMillisAndZ() { + assertTrue(Regex("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$").matches(Dates.nowIso())) + } +} diff --git a/android/app/src/test/kotlin/de/marha/planer/MarkdownTest.kt b/android/app/src/test/kotlin/de/marha/planer/MarkdownTest.kt new file mode 100644 index 0000000..28c5efb --- /dev/null +++ b/android/app/src/test/kotlin/de/marha/planer/MarkdownTest.kt @@ -0,0 +1,45 @@ +package de.marha.planer + +import androidx.compose.ui.text.font.FontWeight +import de.marha.planer.data.Markdown +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MarkdownTest { + @Test fun headingIsBoldAndStripped() { + val out = Markdown.render("# Titel") + assertEquals("Titel", out.text) + assertTrue(out.spanStyles.any { it.item.fontWeight == FontWeight.Bold && it.start == 0 && it.end == 5 }) + } + + @Test fun inlineStyles() { + assertEquals("A b c", Markdown.render("A **b** c").text) + assertEquals("a kursiv b", Markdown.render("a *kursiv* b").text) + assertEquals("snake_case_name", Markdown.render("snake_case_name").text) + assertEquals("2 * 3 * 4", Markdown.render("2 * 3 * 4").text) + assertEquals("Link", Markdown.render("[Link](http://x)").text) + assertEquals("Wiki", Markdown.render("[[Wiki]]").text) + assertEquals("[Bild: Bild]", Markdown.render("![Bild](a.png)").text) + assertEquals("weg", Markdown.render("~~weg~~").text) + } + + @Test fun codeKeepsStars() { + val out = Markdown.render("`**nicht fett**`") + assertEquals("**nicht fett**", out.text) + assertTrue(out.spanStyles.none { it.item.fontWeight == FontWeight.Bold }) + } + + @Test fun lists() { + assertEquals(" • eins\n • zwei", Markdown.render("- eins\n - zwei").text) + assertEquals(" 1. a\n 2. b", Markdown.render("1. a\n2. b").text) + assertEquals(" ☐ offen\n ☑ fertig", Markdown.render("- [ ] offen\n- [x] fertig").text) + } + + @Test fun blocks() { + assertEquals("let a = 1;", Markdown.render("```\nlet a = 1;\n```").text.trim()) + assertEquals("▎ zitat", Markdown.render("> zitat").text) + assertTrue(Markdown.render("---").text.startsWith("────")) + assertEquals("a\n\nb", Markdown.render("a\n\nb").text) + } +} diff --git a/android/app/src/test/kotlin/de/marha/planer/StoreTest.kt b/android/app/src/test/kotlin/de/marha/planer/StoreTest.kt new file mode 100644 index 0000000..1b14404 --- /dev/null +++ b/android/app/src/test/kotlin/de/marha/planer/StoreTest.kt @@ -0,0 +1,103 @@ +package de.marha.planer + +import de.marha.planer.data.Event +import de.marha.planer.data.Store +import de.marha.planer.data.Todo +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class StoreTest { + @get:Rule val tmp = TemporaryFolder() + + private fun store() = Store(tmp.root) + private fun event(title: String, date: String, start: String? = null, end: String? = null, endDate: String? = null) = + Event(id = "", title = title, date = date, start = start, end = end, endDate = endDate, created = "", updated = "") + + @Test fun eventsGetIdAndTombstone() { + val s = store() + s.saveEvent(event("Zahnarzt", "2026-09-17", "09:30", "10:30")) + val saved = s.events.single() + assertTrue(saved.id.isNotEmpty() && saved.updated.endsWith("Z")) + s.removeEvent(saved.id) + assertTrue(s.events.single().deleted) + assertEquals(0, s.eventsOn("2026-09-17").size) + // Neu laden: Tombstone bleibt in der Datei + assertTrue(store().events.single().deleted) + } + + @Test fun multiDayEventTouchesEveryDay() { + val s = store() + s.saveEvent(event("Messe", "2026-09-15", "14:00", "11:00", "2026-09-16")) + assertEquals(1, s.eventsOn("2026-09-15").size) + assertEquals(1, s.eventsOn("2026-09-16").size) + assertEquals(0, s.eventsOn("2026-09-17").size) + } + + @Test fun todosSorted() { + val s = store() + s.saveTodo(Todo(id = "", title = "allgemein", created = "", updated = "")) + s.saveTodo(Todo(id = "", title = "morgen", date = "2026-09-18", created = "", updated = "")) + s.saveTodo(Todo(id = "", title = "heute", date = "2026-09-17", time = "11:00", created = "", updated = "")) + s.toggleTodo(s.todos.first { it.title == "morgen" }.id) + assertEquals(listOf("heute", "allgemein", "morgen"), s.sortedTodos().map { it.title }) + s.removeTodo(s.todos.first { it.title == "heute" }.id) + assertEquals(listOf("allgemein", "morgen"), s.sortedTodos().map { it.title }) + } + + @Test fun notesAndFolders() { + val s = store() + val folder = s.createFolder("", "Projekte") + val sub = s.createFolder(folder, "App") + val note = s.createNote(sub, "Plan") + assertEquals("Projekte/App/Plan.md", note) + s.writeNote(note, "# Plan") + assertEquals("# Plan", s.note(note)!!.content) + assertEquals(listOf("Projekte"), s.tree().folders.map { it.name }) + assertEquals(listOf("Plan"), s.tree().folders[0].folders[0].notes.map { it.name }) + + val renamed = s.renameNote(note, "Neu") + assertEquals("Projekte/App/Neu.md", renamed) + assertNull(s.note(note)) + assertEquals("# Plan", s.note(renamed)!!.content) + + val moved = s.renameFolder(folder, "Arbeit") + assertEquals("Arbeit", moved) + assertNotNull(s.note("Arbeit/App/Neu.md")) + assertTrue(s.folders.any { it.id == "Arbeit/App" && !it.deleted }) + assertTrue(s.folders.any { it.id == "Projekte" && it.deleted }) + + try { + s.deleteFolder("Arbeit") + throw AssertionError("voller Ordner darf nicht löschbar sein") + } catch (e: IllegalArgumentException) { + assertEquals("Der Ordner ist nicht leer", e.message) + } + s.deleteNote("Arbeit/App/Neu.md") + s.deleteFolder("Arbeit/App") + s.deleteFolder("Arbeit") + assertTrue(s.tree().folders.isEmpty()) + } + + @Test fun remoteNewerWins() { + val s = store() + s.saveTodo(Todo(id = "", title = "lokal", created = "", updated = "")) + val id = s.todos.single().id + val older = JSONObject().put("id", id).put("title", "alt").put("updated", "2000-01-01T00:00:00.000Z") + assertEquals(false, s.applyRemote("todos", listOf(older))) + assertEquals("lokal", s.todos.single().title) + val newer = JSONObject().put("id", id).put("title", "neu").put("updated", "2099-01-01T00:00:00.000Z").put("done", true) + assertEquals(true, s.applyRemote("todos", listOf(newer))) + assertEquals("neu", s.todos.single().title) + assertTrue(s.todos.single().done) + // Notiz vom Server samt Ordner + s.applyRemote("folders", listOf(JSONObject().put("id", "Remote").put("updated", "2026-01-01T00:00:00.000Z"))) + s.applyRemote("notes", listOf(JSONObject().put("id", "Remote/Idee.md").put("content", "hi").put("updated", "2026-01-01T00:00:00.000Z"))) + assertEquals("hi", s.tree().folders.single().notes.single().content) + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dec584c --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "2.0.20" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.0.20" apply false +} diff --git a/android/dev/env.sh b/android/dev/env.sh new file mode 100644 index 0000000..cf09cdf --- /dev/null +++ b/android/dev/env.sh @@ -0,0 +1,6 @@ +# source android/dev/env.sh – JDK, SDK, Emulator und Gradle aus ~/Android in den PATH +export ANDROID_ROOT=${ANDROID_ROOT:-$HOME/Android} +export JAVA_HOME="$ANDROID_ROOT/jdk17" +export ANDROID_HOME="$ANDROID_ROOT/Sdk" +export ANDROID_SDK_ROOT="$ANDROID_HOME" +export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$ANDROID_ROOT/gradle/bin:$PATH" diff --git a/android/dev/setup.sh b/android/dev/setup.sh new file mode 100755 index 0000000..7d4bcf2 --- /dev/null +++ b/android/dev/setup.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Android-Werkzeuge ohne Root nach ~/Android: JDK 17, SDK, Emulator, Gradle. +# Danach: android/dev/env.sh einbinden (source), dann gradle und adb im PATH. +set -e +ROOT=${ANDROID_ROOT:-$HOME/Android} +mkdir -p "$ROOT" && cd "$ROOT" + +if [ ! -x jdk17/bin/java ]; then + echo "== JDK 17" + curl -sL -o jdk.tar.gz "https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse?project=jdk" + tar xzf jdk.tar.gz && rm jdk.tar.gz + mv jdk-17* jdk17 +fi +export JAVA_HOME="$ROOT/jdk17" +export ANDROID_HOME="$ROOT/Sdk" +export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$ROOT/gradle/bin:$PATH" + +if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then + echo "== Android Kommandozeilen-Tools" + curl -sL -o cmdline-tools.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip + mkdir -p "$ANDROID_HOME/cmdline-tools" + unzip -q cmdline-tools.zip -d "$ANDROID_HOME/cmdline-tools" && rm cmdline-tools.zip + mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" +fi + +echo "== SDK-Pakete" +yes | sdkmanager --licenses >/dev/null 2>&1 || true +sdkmanager --install "platform-tools" "platforms;android-34" "build-tools;34.0.0" "emulator" \ + "system-images;android-34;google_apis;x86_64" > /dev/null + +if ! avdmanager list avd 2>/dev/null | grep -q "Name: test"; then + echo "== Emulator-Gerät" + echo no | avdmanager create avd -n test -k "system-images;android-34;google_apis;x86_64" -d pixel_6 > /dev/null +fi + +if [ ! -x gradle/bin/gradle ]; then + echo "== Gradle" + curl -sL -o gradle.zip https://services.gradle.org/distributions/gradle-8.9-bin.zip + unzip -q gradle.zip && rm gradle.zip + mv gradle-8.9 gradle +fi + +echo "fertig: $(java -version 2>&1 | head -1); gradle $(gradle --version 2>/dev/null | grep -m1 Gradle | awk '{print $2}')" diff --git a/android/dev/ui.sh b/android/dev/ui.sh new file mode 100755 index 0000000..3e7c255 --- /dev/null +++ b/android/dev/ui.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Bedienung des Emulators über adb, für Tests und Screenshots: +# dev/ui.sh boot wartet, bis der Emulator hochgefahren ist +# dev/ui.sh install baut nicht, installiert app/build/outputs/apk/debug/app-debug.apk und startet die App +# dev/ui.sh start App (neu) starten +# dev/ui.sh tap "Text" tippt auf das erste Element mit diesem Text (oder Beschreibung) +# dev/ui.sh longtap "Text" langes Drücken +# dev/ui.sh tapxy X Y tippt auf Koordinaten +# dev/ui.sh type "Text" tippt Text ins fokussierte Feld +# dev/ui.sh key KEYCODE z.B. BACK, ENTER +# dev/ui.sh has "Text" gibt true/false aus, ob der Text auf dem Bildschirm steht +# dev/ui.sh texts alle Texte auf dem Bildschirm +# dev/ui.sh shot name Screenshot nach /tmp/desktop-app-android/name.png +# dev/ui.sh dump Oberfläche als XML +DIR=$(cd "$(dirname "$0")" && pwd) +source "$DIR/env.sh" +OUT=/tmp/desktop-app-android +mkdir -p "$OUT" +APP=de.marha.planer +# Tests laufen im Emulator, auch wenn ein Handy per USB hängt +export ANDROID_SERIAL=${ANDROID_SERIAL:-emulator-5554} + +dump() { + adb shell uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 + adb shell cat /sdcard/ui.xml +} + +# Mittelpunkt des ersten Knotens mit Text oder content-desc +center() { + dump | python3 -c ' +import re, sys +wanted = sys.argv[1] +xml = sys.stdin.read() +hits = [] +for node in re.finditer(r"]*>", xml): + n = node.group(0) + text = re.search(r" text=\"([^\"]*)\"", n).group(1) + desc = re.search(r" content-desc=\"([^\"]*)\"", n).group(1) + if text == wanted or desc == wanted: + hits.append(n) +# Bei mehreren Treffern (Dialogtitel und Button) den letzten nehmen, Buttons stehen unten +if not hits: + sys.exit(1) +x1, y1, x2, y2 = map(int, re.search(r"bounds=\"\[(\d+),(\d+)\]\[(\d+),(\d+)\]\"", hits[-1]).groups()) +print((x1 + x2) // 2, (y1 + y2) // 2) +' "$1" +} + +case "$1" in + boot) + adb wait-for-device + until [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; do sleep 2; done + adb shell settings put global window_animation_scale 0 >/dev/null + adb shell settings put global transition_animation_scale 0 >/dev/null + adb shell settings put global animator_duration_scale 0 >/dev/null + echo "bereit" ;; + install) + adb install -r "$DIR/../app/build/outputs/apk/debug/app-debug.apk" | tail -1 + "$0" start ;; + start) + adb shell am force-stop $APP + adb shell am start -n $APP/.MainActivity >/dev/null + sleep 2 ;; + tap) + XY=$(center "$2") || { echo "nicht gefunden: $2"; exit 1; } + adb shell input tap $XY; sleep 0.8 ;; + longtap) + XY=$(center "$2") || { echo "nicht gefunden: $2"; exit 1; } + adb shell input swipe $XY $XY 900; sleep 0.8 ;; + tapxy) adb shell input tap "$2" "$3"; sleep 0.8 ;; + type) adb shell input text "$(printf '%s' "$2" | sed 's/ /%s/g')"; sleep 0.5 ;; + key) adb shell input keyevent "$2"; sleep 0.8 ;; + has) dump | grep -q "text=\"$2\"\|content-desc=\"$2\"" && echo true || echo false ;; + texts) dump | grep -o 'text="[^"]*"' | grep -v 'text=""' | sed 's/text=//' | tr '\n' ' '; echo ;; + shot) adb exec-out screencap -p > "$OUT/$2.png"; echo "$OUT/$2.png" ;; + dump) dump ;; + *) echo "unbekannt: $1"; exit 1 ;; +esac diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..53ac8e6 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx3g +android.useAndroidX=true +kotlin.code.style=official +org.gradle.caching=true diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..c1863e0 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,15 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} +rootProject.name = "Planer" +include(":app")