This commit is contained in:
team3
2026-09-19 23:50:36 +02:00
parent e0c615c73a
commit a62b587340
26 changed files with 1654 additions and 1 deletions

5
.gitignore vendored
View File

@@ -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

View File

@@ -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."

View File

@@ -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
View 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

View 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")
}

View 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>

View 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)
}
}
}

View 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() }

View 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))

View 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),
)
}
}
}

View File

@@ -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,
)
}
}

View 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)
}

View File

@@ -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))
}
}
}

View 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>

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
<resources>
<color name="ic_launcher_background">#1A1A1A</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Planer</string>
</resources>

View 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()))
}
}

View 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("![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)
}
}

View 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
View 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
View 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
View 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
View 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

View File

@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx3g
android.useAndroidX=true
kotlin.code.style=official
org.gradle.caching=true

View File

@@ -0,0 +1,15 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Planer"
include(":app")