update
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -4,3 +4,8 @@ data/
|
|||||||
.env
|
.env
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
android/.gradle/
|
||||||
|
android/.kotlin/
|
||||||
|
android/build/
|
||||||
|
android/app/build/
|
||||||
|
android/local.properties
|
||||||
|
|||||||
22
Makefile
22
Makefile
@@ -1,5 +1,6 @@
|
|||||||
# Extension: make install | make update
|
# Extension: make install | make update
|
||||||
# Server: make dev (lokal, Port $(PORT)) | make prod (auf dem Server) | make stop | make logs | make down | make test
|
# 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
|
UUID = desktop@marha.de
|
||||||
SRC = $(CURDIR)/$(UUID)
|
SRC = $(CURDIR)/$(UUID)
|
||||||
DEST = $(HOME)/.local/share/gnome-shell/extensions/$(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
|
SYNC_JSON = $(HOME)/.local/share/desktop-app/sync.json
|
||||||
|
|
||||||
.ONESHELL:
|
.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:
|
install:
|
||||||
@if [ -e "$(DEST)" ]; then
|
@if [ -e "$(DEST)" ]; then
|
||||||
@@ -94,3 +95,22 @@ down:
|
|||||||
|
|
||||||
test: venv
|
test: venv
|
||||||
cd backend && .venv/bin/python -m pytest -q
|
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."
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ unter allen Fenstern, über dem Wallpaper.
|
|||||||
- `store.js` – Ablage, Tombstones, Übernahme vom Server
|
- `store.js` – Ablage, Tombstones, Übernahme vom Server
|
||||||
- `sync.js` – Abgleich mit dem Server (Soup, Timer)
|
- `sync.js` – Abgleich mit dem Server (Soup, Timer)
|
||||||
- `backend/` – Sync-Server (FastAPI, SQLite), `Dockerfile`, `docker-compose.yml`
|
- `backend/` – Sync-Server (FastAPI, SQLite), `Dockerfile`, `docker-compose.yml`
|
||||||
|
- `android/` – native Android-App mit denselben drei Bereichen, siehe `android/README.md`
|
||||||
- `dev/` – Test-Werkzeuge
|
- `dev/` – Test-Werkzeuge
|
||||||
|
|
||||||
## Daten
|
## 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 prod # auf dem Server: Docker Compose hinter Traefik, https://planer.marha.de
|
||||||
make stop | make logs | make down
|
make stop | make logs | make down
|
||||||
make test # Server-Tests
|
make test # Server-Tests
|
||||||
|
make apk # Android-App bauen, make apk-install schickt sie aufs Handy (android/README.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|||||||
42
android/README.md
Normal file
42
android/README.md
Normal file
@@ -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
|
||||||
66
android/app/build.gradle.kts
Normal file
66
android/app/build.gradle.kts
Normal file
@@ -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")
|
||||||
|
}
|
||||||
19
android/app/src/main/AndroidManifest.xml
Normal file
19
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<application
|
||||||
|
android:label="Planer"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:theme="@android:style/Theme.DeviceDefault.NoActionBar">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
93
android/app/src/main/kotlin/de/marha/planer/MainActivity.kt
Normal file
93
android/app/src/main/kotlin/de/marha/planer/MainActivity.kt
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
408
android/app/src/main/kotlin/de/marha/planer/ui/CalendarScreen.kt
Normal file
408
android/app/src/main/kotlin/de/marha/planer/ui/CalendarScreen.kt
Normal file
@@ -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<Int, Int> {
|
||||||
|
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<Block>) {
|
||||||
|
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<Lane>): Int {
|
||||||
|
val laneEnds = mutableListOf<Int>()
|
||||||
|
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<Event?>(null) }
|
||||||
|
var todoDialog by remember { mutableStateOf<Todo?>(null) }
|
||||||
|
// Menü „Termin/Todo erstellen“: Tag, Uhrzeit und Position auf dem Bildschirm
|
||||||
|
var createMenu by remember { mutableStateOf<Triple<String, String?, DpOffset>?>(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<String?>(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<String, String> {
|
||||||
|
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() }
|
||||||
264
android/app/src/main/kotlin/de/marha/planer/ui/Dialogs.kt
Normal file
264
android/app/src/main/kotlin/de/marha/planer/ui/Dialogs.kt
Normal file
@@ -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<String?>(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<String?>(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<String?>(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))
|
||||||
217
android/app/src/main/kotlin/de/marha/planer/ui/NotesScreen.kt
Normal file
217
android/app/src/main/kotlin/de/marha/planer/ui/NotesScreen.kt
Normal file
@@ -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<String?>(null) }
|
||||||
|
var expanded by rememberSaveable { mutableStateOf(setOf<String>()) }
|
||||||
|
var menuFor by remember { mutableStateOf<Row?>(null) }
|
||||||
|
var fabMenu by remember { mutableStateOf(false) }
|
||||||
|
// Offener Dialog: Triple(Art, Ordner oder Pfad, Name)
|
||||||
|
var dialog by remember { mutableStateOf<Triple<String, String, String>?>(null) }
|
||||||
|
var confirmDelete by remember { mutableStateOf<Row?>(null) }
|
||||||
|
|
||||||
|
val opened = open?.let { store.note(it) }
|
||||||
|
if (opened != null) {
|
||||||
|
NoteView(store, opened.id, onClose = { open = null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val rows = mutableListOf<Row>()
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
android/app/src/main/kotlin/de/marha/planer/ui/Theme.kt
Normal file
31
android/app/src/main/kotlin/de/marha/planer/ui/Theme.kt
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<Todo?>(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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
10
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp" android:height="108dp"
|
||||||
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
|
<path android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M30,34 h48 a4,4 0 0 1 4,4 v40 a4,4 0 0 1 -4,4 h-48 a4,4 0 0 1 -4,-4 v-40 a4,4 0 0 1 4,-4 z" />
|
||||||
|
<path android:fillColor="#E9672E"
|
||||||
|
android:pathData="M26,38 a4,4 0 0 1 4,-4 h48 a4,4 0 0 1 4,4 v10 h-56 z" />
|
||||||
|
<path android:fillColor="#E9672E"
|
||||||
|
android:pathData="M36,56 h10 v10 h-10 z M50,56 h10 v10 h-10 z M64,56 h10 v10 h-10 z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
3
android/app/src/main/res/values/colors.xml
Normal file
3
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#1A1A1A</color>
|
||||||
|
</resources>
|
||||||
3
android/app/src/main/res/values/strings.xml
Normal file
3
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Planer</string>
|
||||||
|
</resources>
|
||||||
40
android/app/src/test/kotlin/de/marha/planer/DatesTest.kt
Normal file
40
android/app/src/test/kotlin/de/marha/planer/DatesTest.kt
Normal file
@@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
45
android/app/src/test/kotlin/de/marha/planer/MarkdownTest.kt
Normal file
45
android/app/src/test/kotlin/de/marha/planer/MarkdownTest.kt
Normal file
@@ -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("").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)
|
||||||
|
}
|
||||||
|
}
|
||||||
103
android/app/src/test/kotlin/de/marha/planer/StoreTest.kt
Normal file
103
android/app/src/test/kotlin/de/marha/planer/StoreTest.kt
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
5
android/build.gradle.kts
Normal file
5
android/build.gradle.kts
Normal file
@@ -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
|
||||||
|
}
|
||||||
6
android/dev/env.sh
Normal file
6
android/dev/env.sh
Normal file
@@ -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"
|
||||||
43
android/dev/setup.sh
Executable file
43
android/dev/setup.sh
Executable file
@@ -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}')"
|
||||||
78
android/dev/ui.sh
Executable file
78
android/dev/ui.sh
Executable file
@@ -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"<node [^>]*>", 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
|
||||||
4
android/gradle.properties
Normal file
4
android/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx3g
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
org.gradle.caching=true
|
||||||
15
android/settings.gradle.kts
Normal file
15
android/settings.gradle.kts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "Planer"
|
||||||
|
include(":app")
|
||||||
Reference in New Issue
Block a user