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

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