update
This commit is contained in:
66
android/app/build.gradle.kts
Normal file
66
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,66 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
// Server-Zugang aus der .env im Projektordner in die App einbauen, damit nichts eingetippt werden muss
|
||||
val env = Properties().apply {
|
||||
val file = rootProject.file("../.env")
|
||||
if (file.exists()) file.reader().use { load(it) }
|
||||
}
|
||||
fun envValue(key: String, default: String) = env.getProperty(key)?.trim()?.trim('"', '\'')?.ifEmpty { null } ?: default
|
||||
val syncUrl = envValue("SYNC_URL", "https://planer.marha.de")
|
||||
val syncToken = envValue("SYNC_TOKEN", "")
|
||||
|
||||
android {
|
||||
namespace = "de.marha.planer"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "de.marha.planer"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
buildConfigField("String", "SYNC_URL", "\"$syncUrl\"")
|
||||
buildConfigField("String", "SYNC_TOKEN", "\"$syncToken\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.09.02")
|
||||
implementation(composeBom)
|
||||
implementation("androidx.activity:activity-compose:1.9.2")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.foundation:foundation")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.json:json:20240303")
|
||||
}
|
||||
19
android/app/src/main/AndroidManifest.xml
Normal file
19
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<application
|
||||
android:label="Planer"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@android:style/Theme.DeviceDefault.NoActionBar">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
93
android/app/src/main/kotlin/de/marha/planer/MainActivity.kt
Normal file
93
android/app/src/main/kotlin/de/marha/planer/MainActivity.kt
Normal file
@@ -0,0 +1,93 @@
|
||||
package de.marha.planer
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Checklist
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import de.marha.planer.data.Store
|
||||
import de.marha.planer.data.Sync
|
||||
import de.marha.planer.ui.CalendarScreen
|
||||
import de.marha.planer.ui.NotesScreen
|
||||
import de.marha.planer.ui.PlanerTheme
|
||||
import de.marha.planer.ui.SettingsScreen
|
||||
import de.marha.planer.ui.TodosScreen
|
||||
import java.io.File
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private lateinit var store: Store
|
||||
private lateinit var sync: Sync
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val dir = File(filesDir, "data")
|
||||
store = Store(dir)
|
||||
sync = Sync(store, getSharedPreferences("sync", Context.MODE_PRIVATE), dir, lifecycleScope)
|
||||
setContent {
|
||||
PlanerTheme { App(store, sync) }
|
||||
}
|
||||
}
|
||||
|
||||
// Abgleich läuft, solange die App sichtbar ist
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
sync.start()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
sync.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private val tabs = listOf(
|
||||
Triple("Kalender", Icons.Filled.CalendarMonth, 0),
|
||||
Triple("Todos", Icons.Filled.Checklist, 1),
|
||||
Triple("Notizen", Icons.Filled.Description, 2),
|
||||
Triple("Server", Icons.Filled.Settings, 3),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun App(store: Store, sync: Sync) {
|
||||
// Ohne Server-Zugang zuerst die Einstellungen zeigen
|
||||
var tab by rememberSaveable { mutableIntStateOf(if (sync.active) 0 else 3) }
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
for ((label, icon, index) in tabs) {
|
||||
NavigationBarItem(
|
||||
selected = tab == index,
|
||||
onClick = { tab = index },
|
||||
icon = { Icon(icon, contentDescription = label) },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
val modifier = Modifier.padding(padding)
|
||||
when (tab) {
|
||||
0 -> CalendarScreen(store, modifier)
|
||||
1 -> TodosScreen(store, modifier)
|
||||
2 -> NotesScreen(store, modifier)
|
||||
else -> SettingsScreen(sync, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
408
android/app/src/main/kotlin/de/marha/planer/ui/CalendarScreen.kt
Normal file
408
android/app/src/main/kotlin/de/marha/planer/ui/CalendarScreen.kt
Normal file
@@ -0,0 +1,408 @@
|
||||
package de.marha.planer.ui
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import de.marha.planer.data.Dates
|
||||
import de.marha.planer.data.Event
|
||||
import de.marha.planer.data.Store
|
||||
import de.marha.planer.data.Todo
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
|
||||
private val HOUR_HEIGHT = 48.dp
|
||||
private val HOUR_LABEL_WIDTH = 40.dp
|
||||
private val ALLDAY_LANE = 20.dp
|
||||
|
||||
private fun pad(n: Int) = "%02d".format(n)
|
||||
|
||||
fun endTimeOf(event: Event): String = event.end ?: Dates.minutesToTime(minOf(Dates.timeToMinutes(event.start!!) + 60, 23 * 60 + 59))
|
||||
|
||||
// 'ganztags', '09:00–10:30', bei mehrtägigen Terminen mit Enddatum.
|
||||
fun timeRange(event: Event): String {
|
||||
val multiDay = !event.singleDay
|
||||
if (event.start == null) return if (multiDay) "ganztags bis ${Dates.formatDayMonth(event.endDateOrDate)}" else "ganztags"
|
||||
if (multiDay) return "${event.start} – ${Dates.formatDayMonth(event.endDateOrDate)} ${endTimeOf(event)}"
|
||||
return "${event.start}–${endTimeOf(event)}"
|
||||
}
|
||||
|
||||
// Anteil eines Termins an einem Tag in Minuten: [von, bis)
|
||||
private fun minuteSpan(event: Event, day: String): Pair<Int, Int> {
|
||||
val from = if (day == event.date) Dates.timeToMinutes(event.start!!) else 0
|
||||
val to = if (day == event.endDateOrDate) Dates.timeToMinutes(endTimeOf(event)) else 24 * 60
|
||||
return from to maxOf(to, from + 30)
|
||||
}
|
||||
|
||||
private data class Block(val event: Event, val dayIndex: Int, val from: Int, val to: Int, var col: Int = 0, var cols: Int = 1)
|
||||
|
||||
// Überlappende Termine bekommen Spalten nebeneinander.
|
||||
private fun assignColumns(items: List<Block>) {
|
||||
val sorted = items.sortedWith(compareBy({ it.from }, { -it.to }))
|
||||
fun overlaps(a: Block, b: Block) = a.from < b.to && b.from < a.to
|
||||
for (item in sorted) {
|
||||
val used = sorted.filter { it !== item && it.col >= 0 && overlaps(it, item) && sorted.indexOf(it) < sorted.indexOf(item) }.map { it.col }.toSet()
|
||||
var col = 0
|
||||
while (used.contains(col)) col++
|
||||
item.col = col
|
||||
}
|
||||
for (item in sorted) item.cols = (sorted.filter { overlaps(it, item) }.maxOfOrNull { it.col } ?: 0) + 1
|
||||
}
|
||||
|
||||
private data class Lane(val event: Event, val from: Int, val to: Int, var lane: Int = 0)
|
||||
|
||||
// Ganztägige Termine bekommen Zeilen untereinander, wenn sich ihre Tage überschneiden.
|
||||
private fun assignLanes(items: List<Lane>): Int {
|
||||
val laneEnds = mutableListOf<Int>()
|
||||
for (item in items.sortedWith(compareBy({ it.from }, { -it.to }))) {
|
||||
var lane = laneEnds.indexOfFirst { it < item.from }
|
||||
if (lane == -1) {
|
||||
laneEnds.add(item.to)
|
||||
lane = laneEnds.size - 1
|
||||
} else laneEnds[lane] = item.to
|
||||
item.lane = lane
|
||||
}
|
||||
return laneEnds.size
|
||||
}
|
||||
|
||||
// Kalender: Monat, Woche, Tag. Tipp bearbeitet oder legt an, langes Drücken öffnet das Menü.
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CalendarScreen(store: Store, modifier: Modifier = Modifier) {
|
||||
store.version.intValue
|
||||
var view by rememberSaveable { mutableStateOf("month") }
|
||||
var cursor by rememberSaveable { mutableStateOf(Dates.today()) }
|
||||
var eventDialog by remember { mutableStateOf<Event?>(null) }
|
||||
var todoDialog by remember { mutableStateOf<Todo?>(null) }
|
||||
// Menü „Termin/Todo erstellen“: Tag, Uhrzeit und Position auf dem Bildschirm
|
||||
var createMenu by remember { mutableStateOf<Triple<String, String?, DpOffset>?>(null) }
|
||||
var rootOrigin by remember { mutableStateOf(Offset.Zero) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
fun newEvent(day: String, time: String?) {
|
||||
eventDialog = Event(id = "", title = "", date = day, start = time, end = time?.let { Dates.minutesToTime(minOf(Dates.timeToMinutes(it) + 60, 23 * 60 + 59)) }, created = "", updated = "")
|
||||
}
|
||||
fun newTodo(day: String, time: String?) {
|
||||
todoDialog = Todo(id = "", title = "", date = day, time = time, created = "", updated = "")
|
||||
}
|
||||
fun openMenu(day: String, time: String?, positionInRoot: Offset) {
|
||||
val local = positionInRoot - rootOrigin
|
||||
createMenu = Triple(day, time, with(density) { DpOffset(local.x.toDp(), local.y.toDp()) })
|
||||
}
|
||||
|
||||
fun shift(n: Int) {
|
||||
cursor = when (view) {
|
||||
"month" -> LocalDate.parse(cursor).withDayOfMonth(1).plusMonths(n.toLong()).toString()
|
||||
"week" -> Dates.addDays(cursor, 7L * n)
|
||||
else -> Dates.addDays(cursor, n.toLong())
|
||||
}
|
||||
}
|
||||
|
||||
val date = LocalDate.parse(cursor)
|
||||
val title = when (view) {
|
||||
"month" -> Dates.formatMonth(date.year, date.monthValue)
|
||||
"week" -> Dates.weekDays(cursor).let { "${Dates.formatDayMonth(it[0])} – ${Dates.formatDateInput(it[6])}" }
|
||||
else -> Dates.formatDate(cursor)
|
||||
}
|
||||
|
||||
Box(modifier.fillMaxSize().onGloballyPositioned { rootOrigin = it.positionInRoot() }) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = { shift(-1) }) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Zurück") }
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
IconButton(onClick = { shift(1) }) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Weiter") }
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(onClick = { cursor = Dates.today() }) { Text("Heute") }
|
||||
}
|
||||
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(horizontal = 12.dp)) {
|
||||
listOf("month" to "Monat", "week" to "Woche", "day" to "Tag").forEachIndexed { index, (key, label) ->
|
||||
SegmentedButton(selected = view == key, onClick = { view = key }, shape = SegmentedButtonDefaults.itemShape(index, 3)) { Text(label) }
|
||||
}
|
||||
}
|
||||
when (view) {
|
||||
"month" -> MonthView(store, cursor, onDay = { cursor = it; view = "day" }, onEvent = { eventDialog = it }, onTodo = { todoDialog = it }, onNewEvent = { newEvent(it, null) }, onNewTodo = { newTodo(it, null) })
|
||||
"week" -> WeekView(store, cursor, onEvent = { eventDialog = it }, onTodo = { todoDialog = it }, onTap = { day, time -> newEvent(day, time) }, onLongPress = { day, time, pos -> openMenu(day, time, pos) })
|
||||
else -> DayView(store, cursor, onEvent = { eventDialog = it }, onTodo = { todoDialog = it }, onNewEvent = { newEvent(cursor, null) }, onNewTodo = { newTodo(cursor, null) })
|
||||
}
|
||||
}
|
||||
createMenu?.let { (day, time, offset) ->
|
||||
DropdownMenu(expanded = true, onDismissRequest = { createMenu = null }, offset = offset) {
|
||||
DropdownMenuItem(text = { Text("Termin erstellen") }, onClick = { newEvent(day, time); createMenu = null })
|
||||
DropdownMenuItem(text = { Text("Todo erstellen") }, onClick = { newTodo(day, time); createMenu = null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventDialog?.let { EventDialog(store, it, onDismiss = { eventDialog = null }) }
|
||||
todoDialog?.let { TodoDialog(store, it, onDismiss = { todoDialog = null }) }
|
||||
}
|
||||
|
||||
// ---- Monat ----
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun MonthView(
|
||||
store: Store, cursor: String,
|
||||
onDay: (String) -> Unit, onEvent: (Event) -> Unit, onTodo: (Todo) -> Unit,
|
||||
onNewEvent: (String) -> Unit, onNewTodo: (String) -> Unit,
|
||||
) {
|
||||
store.version.intValue // neu zeichnen, sobald sich Daten ändern (Strong Skipping überspringt sonst)
|
||||
val date = LocalDate.parse(cursor)
|
||||
val days = Dates.monthGrid(date.year, date.monthValue)
|
||||
val today = Dates.today()
|
||||
var menuDay by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 4.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
for (name in Dates.WEEKDAYS) Text(name, Modifier.weight(1f), color = Muted, fontSize = 11.sp, textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||
}
|
||||
for (week in 0 until 6) {
|
||||
Row(Modifier.fillMaxWidth().weight(1f)) {
|
||||
for (day in days.subList(week * 7, week * 7 + 7)) {
|
||||
val otherMonth = LocalDate.parse(day).monthValue != date.monthValue
|
||||
val events = store.eventsOn(day)
|
||||
val todos = store.todosOn(day)
|
||||
Box(
|
||||
Modifier.weight(1f).fillMaxSize().padding(1.dp)
|
||||
.background(Color(0x0AFFFFFF), RoundedCornerShape(6.dp))
|
||||
.then(if (day == today) Modifier.border(1.dp, Orange, RoundedCornerShape(6.dp)) else Modifier)
|
||||
.combinedClickable(onClick = { onDay(day) }, onLongClick = { menuDay = day })
|
||||
.padding(3.dp),
|
||||
) {
|
||||
Column {
|
||||
Text(day.substring(8).trimStart('0'), fontWeight = FontWeight.Bold, fontSize = 12.sp, color = if (otherMonth) Color(0xFF777777) else MaterialTheme.colorScheme.onBackground)
|
||||
val entries = events.map { e -> (if (e.start != null && e.singleDay) "${e.start} ${e.title}" else e.title) to { onEvent(e) } } +
|
||||
todos.map { t -> ((if (t.done) "☑ " else "☐ ") + (t.time?.let { "$it " } ?: "") + t.title) to { onTodo(t) } }
|
||||
entries.take(2).forEachIndexed { index, (text, click) ->
|
||||
val isTodo = index >= events.size
|
||||
Text(
|
||||
text, fontSize = 9.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White,
|
||||
modifier = Modifier.padding(top = 2.dp).fillMaxWidth()
|
||||
.background(if (isTodo) TodoBlue.copy(alpha = 0.35f) else Orange.copy(alpha = 0.5f), RoundedCornerShape(3.dp))
|
||||
.clickable { click() }.padding(horizontal = 3.dp),
|
||||
)
|
||||
}
|
||||
if (entries.size > 2) Text("+${entries.size - 2}", fontSize = 9.sp, color = Muted)
|
||||
}
|
||||
DropdownMenu(expanded = menuDay == day, onDismissRequest = { menuDay = null }) {
|
||||
DropdownMenuItem(text = { Text("Termin erstellen") }, onClick = { onNewEvent(day); menuDay = null })
|
||||
DropdownMenuItem(text = { Text("Todo erstellen") }, onClick = { onNewTodo(day); menuDay = null })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Woche ----
|
||||
|
||||
@Composable
|
||||
private fun WeekView(
|
||||
store: Store, cursor: String,
|
||||
onEvent: (Event) -> Unit, onTodo: (Todo) -> Unit,
|
||||
onTap: (String, String?) -> Unit, onLongPress: (String, String?, Offset) -> Unit,
|
||||
) {
|
||||
store.version.intValue // neu zeichnen, sobald sich Daten ändern (Strong Skipping überspringt sonst)
|
||||
val days = Dates.weekDays(cursor)
|
||||
val today = Dates.today()
|
||||
val todayIndex = days.indexOf(today)
|
||||
val density = LocalDensity.current
|
||||
val scroll = rememberScrollState()
|
||||
|
||||
// Ganztägige Termine als Blöcke über ihre Tage
|
||||
val allDay = days.flatMap { store.eventsOn(it) }.filter { it.start == null }.distinctBy { it.id }
|
||||
.map { e -> Lane(e, days.indexOf(e.date).let { if (it < 0) 0 else it }, days.indexOf(e.endDateOrDate).let { if (it < 0) 6 else it }) }
|
||||
val lanes = assignLanes(allDay)
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(Modifier.fillMaxWidth().padding(top = 4.dp)) {
|
||||
Text("KW ${Dates.isoWeek(days[0])}", Modifier.width(HOUR_LABEL_WIDTH), color = Muted, fontSize = 9.sp)
|
||||
for (day in days) {
|
||||
val d = LocalDate.parse(day)
|
||||
Text("${Dates.WEEKDAYS[d.dayOfWeek.value - 1]} ${d.dayOfMonth}.", Modifier.weight(1f), fontSize = 11.sp,
|
||||
color = if (day == today) Orange else Color(0xFFCCCCCC), fontWeight = if (day == today) FontWeight.Bold else null,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||
}
|
||||
}
|
||||
// Ganztags-Zeile: Blöcke, darunter Todos ohne Uhrzeit. Tipp legt einen ganztägigen Termin an.
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
Text("ganz", Modifier.width(HOUR_LABEL_WIDTH), color = Muted, fontSize = 9.sp)
|
||||
BoxWithConstraints(Modifier.weight(1f).height(maxOf(22.dp, ALLDAY_LANE * lanes + 4.dp))) {
|
||||
val colWidth = maxWidth / 7
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
for (day in days) Box(Modifier.weight(1f).fillMaxSize().padding(1.dp).background(Color(0x0AFFFFFF), RoundedCornerShape(3.dp)).pointerInput(day) {
|
||||
detectTapGestures(onTap = { onTap(day, null) }, onLongPress = { onLongPress(day, null, it) })
|
||||
})
|
||||
}
|
||||
for (lane in allDay) {
|
||||
Box(
|
||||
Modifier.offset(x = colWidth * lane.from, y = ALLDAY_LANE * lane.lane)
|
||||
.width(colWidth * (lane.to - lane.from + 1)).height(ALLDAY_LANE - 2.dp).padding(horizontal = 1.dp)
|
||||
.background(Orange.copy(alpha = 0.8f), RoundedCornerShape(3.dp)).clickable { onEvent(lane.event) }.padding(horizontal = 4.dp),
|
||||
) { Text(lane.event.title, fontSize = 10.sp, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stundenraster mit Terminblöcken, roter Linie und Todos mit Uhrzeit
|
||||
Box(Modifier.weight(1f).verticalScroll(scroll)) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().height(HOUR_HEIGHT * 24)) {
|
||||
val colWidth = (maxWidth - HOUR_LABEL_WIDTH) / 7
|
||||
var gridOrigin by remember { mutableStateOf(Offset.Zero) }
|
||||
val hourPx = with(density) { HOUR_HEIGHT.toPx() }
|
||||
val colPx = with(density) { colWidth.toPx() }
|
||||
val labelPx = with(density) { HOUR_LABEL_WIDTH.toPx() }
|
||||
fun cellAt(offset: Offset): Pair<String, String> {
|
||||
val dayIndex = ((offset.x - labelPx) / colPx).toInt().coerceIn(0, 6)
|
||||
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
|
||||
return days[dayIndex] to "${pad(hour)}:00"
|
||||
}
|
||||
Column(Modifier.fillMaxSize().onGloballyPositioned { gridOrigin = it.positionInRoot() }.pointerInput(days) {
|
||||
detectTapGestures(
|
||||
onTap = { cellAt(it).let { (day, time) -> onTap(day, time) } },
|
||||
onLongPress = { cellAt(it).let { (day, time) -> onLongPress(day, time, gridOrigin + it) } },
|
||||
)
|
||||
}) {
|
||||
for (hour in 0 until 24) {
|
||||
Row(Modifier.fillMaxWidth().height(HOUR_HEIGHT)) {
|
||||
Text("${pad(hour)}:00", Modifier.width(HOUR_LABEL_WIDTH).padding(top = 2.dp), color = Muted, fontSize = 9.sp)
|
||||
for (i in 0 until 7) Box(Modifier.weight(1f).fillMaxSize().padding(1.dp).background(Color(0x0AFFFFFF), RoundedCornerShape(3.dp)))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Termine als Blöcke, überlappende nebeneinander
|
||||
days.forEachIndexed { dayIndex, day ->
|
||||
val blocks = store.eventsOn(day).filter { it.start != null }.map { e -> minuteSpan(e, day).let { (f, t) -> Block(e, dayIndex, f, t) } }
|
||||
assignColumns(blocks)
|
||||
for (block in blocks) {
|
||||
val width = colWidth / block.cols
|
||||
Box(
|
||||
Modifier.offset(x = HOUR_LABEL_WIDTH + colWidth * dayIndex + width * block.col, y = HOUR_HEIGHT * block.from / 60f)
|
||||
.width(width).height(HOUR_HEIGHT * (block.to - block.from) / 60f - 2.dp).padding(horizontal = 1.dp)
|
||||
.background(Orange.copy(alpha = 0.8f), RoundedCornerShape(4.dp))
|
||||
.border(1.dp, Color(0x40FFFFFF), RoundedCornerShape(4.dp))
|
||||
.clickable { onEvent(block.event) }.padding(horizontal = 3.dp, vertical = 1.dp),
|
||||
) {
|
||||
Column {
|
||||
Text(if (day == block.event.date) block.event.start!! else "…", fontSize = 8.sp, color = Color(0xDDFFFFFF))
|
||||
Text(block.event.title, fontSize = 10.sp, color = Color.White, fontWeight = FontWeight.Bold, overflow = TextOverflow.Clip)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (todo in store.todosOn(day).filter { it.time != null }) {
|
||||
Box(
|
||||
Modifier.offset(x = HOUR_LABEL_WIDTH + colWidth * dayIndex, y = HOUR_HEIGHT * Dates.timeToMinutes(todo.time!!) / 60f)
|
||||
.width(colWidth).height(16.dp).padding(horizontal = 1.dp)
|
||||
.background(TodoBlue.copy(alpha = 0.5f), RoundedCornerShape(3.dp)).clickable { onTodo(todo) }.padding(horizontal = 2.dp),
|
||||
) { Text((if (todo.done) "☑ " else "☐ ") + todo.title, fontSize = 9.sp, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
}
|
||||
}
|
||||
// Rote Linie an der aktuellen Uhrzeit in der Spalte von heute
|
||||
if (todayIndex >= 0) {
|
||||
val now = LocalTime.now()
|
||||
Box(Modifier.offset(x = HOUR_LABEL_WIDTH + colWidth * todayIndex, y = HOUR_HEIGHT * (now.hour * 60 + now.minute) / 60f - 1.dp).width(colWidth).height(2.dp).background(NowRed))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Beim Öffnen die aktuelle Uhrzeit in die Mitte
|
||||
LaunchedEffect(scroll.maxValue) {
|
||||
if (scroll.maxValue > 0 && scroll.value == 0) {
|
||||
val now = LocalTime.now()
|
||||
val nowPx = with(density) { HOUR_HEIGHT.toPx() } * (now.hour * 60 + now.minute) / 60f
|
||||
val viewport = with(density) { HOUR_HEIGHT.toPx() } * 24 - scroll.maxValue
|
||||
scroll.scrollTo((nowPx - viewport / 2).toInt().coerceIn(0, scroll.maxValue))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tag ----
|
||||
|
||||
@Composable
|
||||
private fun DayView(store: Store, day: String, onEvent: (Event) -> Unit, onTodo: (Todo) -> Unit, onNewEvent: () -> Unit, onNewTodo: () -> Unit) {
|
||||
store.version.intValue // neu zeichnen, sobald sich Daten ändern (Strong Skipping überspringt sonst)
|
||||
val events = store.eventsOn(day)
|
||||
val todos = store.todosOn(day)
|
||||
LazyColumn(Modifier.fillMaxSize().padding(horizontal = 8.dp)) {
|
||||
item { SectionHeader("Termine", "+ Termin", onNewEvent) }
|
||||
if (events.isEmpty()) item { Text("Keine Termine", color = Muted, modifier = Modifier.padding(8.dp)) }
|
||||
items(events, key = { it.id }) { event ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 2.dp).background(Color(0x0FFFFFFF), RoundedCornerShape(6.dp)).clickable { onEvent(event) }.padding(10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(timeRange(event), color = Orange, modifier = Modifier.width(110.dp))
|
||||
Text(event.title, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
item { SectionHeader("Todos", "+ Todo", onNewTodo) }
|
||||
if (todos.isEmpty()) item { Text("Keine Todos", color = Muted, modifier = Modifier.padding(8.dp)) }
|
||||
items(todos, key = { it.id }) { todo -> TodoRow(todo, onToggle = { store.toggleTodo(todo.id) }, onEdit = { onTodo(todo) }) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String, buttonLabel: String, onClick: () -> Unit) {
|
||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(title, color = Muted, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onClick) { Text(buttonLabel) }
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun Dp.px(density: androidx.compose.ui.unit.Density): Float = with(density) { toPx() }
|
||||
264
android/app/src/main/kotlin/de/marha/planer/ui/Dialogs.kt
Normal file
264
android/app/src/main/kotlin/de/marha/planer/ui/Dialogs.kt
Normal file
@@ -0,0 +1,264 @@
|
||||
package de.marha.planer.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DatePicker
|
||||
import androidx.compose.material3.DatePickerDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import de.marha.planer.data.Dates
|
||||
import de.marha.planer.data.Event
|
||||
import de.marha.planer.data.Store
|
||||
import de.marha.planer.data.Todo
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
|
||||
// Dialoge für Termin, Todo und Namen. Enter/Speichern übernimmt, Abbrechen verwirft.
|
||||
|
||||
// Termin: Titel sowie Von und Bis (Datum und Uhrzeit). Ohne Ende gilt eine Stunde, ohne Uhrzeit ganztägig.
|
||||
@Composable
|
||||
fun EventDialog(store: Store, event: Event, onDismiss: () -> Unit) {
|
||||
val isNew = event.id.isEmpty()
|
||||
var title by remember { mutableStateOf(event.title) }
|
||||
var date by remember { mutableStateOf(event.date) }
|
||||
var start by remember { mutableStateOf(event.start) }
|
||||
var endDate by remember { mutableStateOf(event.endDate) }
|
||||
var end by remember { mutableStateOf(event.end) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun save() {
|
||||
val text = title.trim()
|
||||
if (text.isEmpty()) {
|
||||
error = "Titel fehlt"
|
||||
return
|
||||
}
|
||||
var saved = event.copy(title = text, date = date, start = null, end = null, endDate = null)
|
||||
val endDay = endDate
|
||||
if (endDay != null && endDay != date) {
|
||||
if (endDay < date) {
|
||||
error = "Ende liegt vor dem Anfang"
|
||||
return
|
||||
}
|
||||
saved = saved.copy(endDate = endDay)
|
||||
}
|
||||
val startTime = start
|
||||
if (startTime != null) {
|
||||
val endTime = end ?: Dates.minutesToTime(minOf(Dates.timeToMinutes(startTime) + 60, 23 * 60 + 59))
|
||||
if (saved.endDate == null && Dates.timeToMinutes(endTime) <= Dates.timeToMinutes(startTime)) {
|
||||
error = "Ende liegt vor dem Anfang"
|
||||
return
|
||||
}
|
||||
saved = saved.copy(start = startTime, end = endTime)
|
||||
}
|
||||
store.saveEvent(saved)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (isNew) "Neuer Termin" else "Termin bearbeiten") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextInput("Titel", title) { title = it }
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Von", modifier = Modifier.width(28.dp), color = Muted)
|
||||
DateField(date, allowNone = false) { date = it ?: date }
|
||||
TimeField(start) { start = it }
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Bis", modifier = Modifier.width(28.dp), color = Muted)
|
||||
DateField(endDate ?: date, allowNone = false) { endDate = it }
|
||||
TimeField(end) { end = it }
|
||||
}
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { save() }) { Text("Speichern") } },
|
||||
dismissButton = {
|
||||
Row {
|
||||
if (!isNew) TextButton(onClick = { store.removeEvent(event.id); onDismiss() }) { Text("Löschen") }
|
||||
TextButton(onClick = onDismiss) { Text("Abbrechen") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Todo: Text sowie Datum und Uhrzeit. Eine Uhrzeit ohne Datum meint heute.
|
||||
@Composable
|
||||
fun TodoDialog(store: Store, todo: Todo, onDismiss: () -> Unit) {
|
||||
val isNew = todo.id.isEmpty()
|
||||
var title by remember { mutableStateOf(todo.title) }
|
||||
var date by remember { mutableStateOf(todo.date) }
|
||||
var time by remember { mutableStateOf(todo.time) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun save() {
|
||||
val text = title.trim()
|
||||
if (text.isEmpty()) {
|
||||
error = "Text fehlt"
|
||||
return
|
||||
}
|
||||
store.saveTodo(todo.copy(title = text, date = date, time = time))
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (isNew) "Neues Todo" else "Todo bearbeiten") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextInput("Was ist zu tun?", title) { title = it }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
DateField(date, allowNone = true) {
|
||||
date = it
|
||||
if (it == null) time = null
|
||||
}
|
||||
TimeField(time) {
|
||||
time = it
|
||||
if (it != null && date == null) date = Dates.today()
|
||||
}
|
||||
}
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { save() }) { Text("Speichern") } },
|
||||
dismissButton = {
|
||||
Row {
|
||||
if (!isNew) TextButton(onClick = { store.removeTodo(todo.id); onDismiss() }) { Text("Löschen") }
|
||||
TextButton(onClick = onDismiss) { Text("Abbrechen") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Einzeiliges Textfeld, das beim Öffnen den Fokus bekommt. Cursor am Ende, bei selectAll ist der Text markiert.
|
||||
@Composable
|
||||
private fun TextInput(label: String, initial: String, selectAll: Boolean = false, onChange: (String) -> Unit) {
|
||||
var value by remember {
|
||||
mutableStateOf(TextFieldValue(initial, selection = if (selectAll) TextRange(0, initial.length) else TextRange(initial.length)))
|
||||
}
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||
OutlinedTextField(
|
||||
value = value, onValueChange = { value = it; onChange(it.text) }, label = { Text(label) }, singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focus),
|
||||
)
|
||||
}
|
||||
|
||||
// Namensdialog für Notizen und Ordner. onConfirm wirft bei Problemen, die Meldung bleibt im Dialog.
|
||||
@Composable
|
||||
fun NameDialog(title: String, initial: String, confirmLabel: String = "Speichern", onDismiss: () -> Unit, onConfirm: (String) -> Unit) {
|
||||
var name by remember { mutableStateOf(initial) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextInput("Name", name, selectAll = true) { name = it }
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
try {
|
||||
onConfirm(name.trim())
|
||||
onDismiss()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
error = e.message
|
||||
}
|
||||
}) { Text(confirmLabel) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Abbrechen") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfirmDialog(title: String, confirmLabel: String, onDismiss: () -> Unit, onConfirm: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
confirmButton = { TextButton(onClick = { onConfirm(); onDismiss() }) { Text(confirmLabel) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Abbrechen") } },
|
||||
)
|
||||
}
|
||||
|
||||
// Datumsauswahl als Button, öffnet den Kalender. allowNone erlaubt „Kein Datum“.
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DateField(value: String?, allowNone: Boolean, onPick: (String?) -> Unit) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
OutlinedButton(onClick = { open = true }) { Text(value?.let { Dates.formatDate(it) } ?: "Kein Datum") }
|
||||
if (open) {
|
||||
val initial = (value ?: Dates.today()).let { LocalDate.parse(it).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() }
|
||||
val state = rememberDatePickerState(initialSelectedDateMillis = initial)
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { open = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
state.selectedDateMillis?.let { onPick(Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate().toString()) }
|
||||
open = false
|
||||
}) { Text("OK") }
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
if (allowNone) TextButton(onClick = { onPick(null); open = false }) { Text("Kein Datum") }
|
||||
TextButton(onClick = { open = false }) { Text("Abbrechen") }
|
||||
}
|
||||
},
|
||||
) { DatePicker(state = state) }
|
||||
}
|
||||
}
|
||||
|
||||
// Uhrzeitauswahl als Button, öffnet die Uhr. „Keine Uhrzeit“ setzt zurück.
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TimeField(value: String?, onPick: (String?) -> Unit) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
OutlinedButton(onClick = { open = true }) { Text(value ?: "Keine Uhrzeit") }
|
||||
if (open) {
|
||||
val minutes = value?.let { Dates.timeToMinutes(it) } ?: (8 * 60)
|
||||
val state = rememberTimePickerState(initialHour = minutes / 60, initialMinute = minutes % 60, is24Hour = true)
|
||||
AlertDialog(
|
||||
onDismissRequest = { open = false },
|
||||
text = { TimePicker(state = state) },
|
||||
confirmButton = { TextButton(onClick = { onPick(Dates.minutesToTime(state.hour * 60 + state.minute)); open = false }) { Text("OK") } },
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton(onClick = { onPick(null); open = false }) { Text("Keine Uhrzeit") }
|
||||
TextButton(onClick = { open = false }) { Text("Abbrechen") }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Spacer8() = Spacer(Modifier.padding(4.dp))
|
||||
217
android/app/src/main/kotlin/de/marha/planer/ui/NotesScreen.kt
Normal file
217
android/app/src/main/kotlin/de/marha/planer/ui/NotesScreen.kt
Normal file
@@ -0,0 +1,217 @@
|
||||
package de.marha.planer.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.marha.planer.data.Markdown
|
||||
import de.marha.planer.data.Store
|
||||
import de.marha.planer.data.TreeNode
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
private const val AUTOSAVE_MS = 5000L
|
||||
|
||||
// Eine Zeile im Baum: Ordner oder Notiz mit Einrückung
|
||||
private data class Row(val path: String, val name: String, val depth: Int, val isFolder: Boolean, val open: Boolean)
|
||||
|
||||
// Notizen: Baum mit Ordnern, Tipp öffnet die Notiz. Langes Drücken: Erstellen, Umbenennen, Löschen.
|
||||
@Composable
|
||||
fun NotesScreen(store: Store, modifier: Modifier = Modifier) {
|
||||
store.version.intValue
|
||||
var open by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var expanded by rememberSaveable { mutableStateOf(setOf<String>()) }
|
||||
var menuFor by remember { mutableStateOf<Row?>(null) }
|
||||
var fabMenu by remember { mutableStateOf(false) }
|
||||
// Offener Dialog: Triple(Art, Ordner oder Pfad, Name)
|
||||
var dialog by remember { mutableStateOf<Triple<String, String, String>?>(null) }
|
||||
var confirmDelete by remember { mutableStateOf<Row?>(null) }
|
||||
|
||||
val opened = open?.let { store.note(it) }
|
||||
if (opened != null) {
|
||||
NoteView(store, opened.id, onClose = { open = null })
|
||||
return
|
||||
}
|
||||
|
||||
val rows = mutableListOf<Row>()
|
||||
fun collect(node: TreeNode, depth: Int) {
|
||||
for (sub in node.folders) {
|
||||
val isOpen = expanded.contains(sub.path)
|
||||
rows.add(Row(sub.path, sub.name, depth, true, isOpen))
|
||||
if (isOpen) collect(sub, depth + 1)
|
||||
}
|
||||
for (note in node.notes) rows.add(Row(note.id, note.name, depth, false, false))
|
||||
}
|
||||
collect(store.tree(), 0)
|
||||
|
||||
Box(modifier.fillMaxSize()) {
|
||||
if (rows.isEmpty()) Text("Keine Notizen", color = Muted, modifier = Modifier.padding(16.dp))
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(rows, key = { (if (it.isFolder) "f:" else "n:") + it.path }) { row ->
|
||||
Box {
|
||||
TreeRow(
|
||||
row,
|
||||
onClick = {
|
||||
if (row.isFolder) expanded = if (row.open) expanded - row.path else expanded + row.path
|
||||
else open = row.path
|
||||
},
|
||||
onLongClick = { menuFor = row },
|
||||
)
|
||||
DropdownMenu(expanded = menuFor == row, onDismissRequest = { menuFor = null }) {
|
||||
if (row.isFolder) {
|
||||
DropdownMenuItem(text = { Text("Notiz erstellen") }, onClick = { dialog = Triple("note", row.path, ""); menuFor = null })
|
||||
DropdownMenuItem(text = { Text("Ordner erstellen") }, onClick = { dialog = Triple("folder", row.path, ""); menuFor = null })
|
||||
}
|
||||
DropdownMenuItem(text = { Text("Umbenennen") }, onClick = { dialog = Triple(if (row.isFolder) "renameFolder" else "renameNote", row.path, row.name); menuFor = null })
|
||||
val deletable = !row.isFolder || store.folderEmpty(row.path)
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (deletable) "Löschen" else "Löschen (Ordner ist nicht leer)") },
|
||||
enabled = deletable,
|
||||
onClick = { confirmDelete = row; menuFor = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.align(Alignment.BottomEnd).padding(16.dp)) {
|
||||
FloatingActionButton(onClick = { fabMenu = true }) { Icon(Icons.Filled.Add, contentDescription = "Erstellen") }
|
||||
DropdownMenu(expanded = fabMenu, onDismissRequest = { fabMenu = false }) {
|
||||
DropdownMenuItem(text = { Text("Notiz erstellen") }, onClick = { dialog = Triple("note", "", ""); fabMenu = false })
|
||||
DropdownMenuItem(text = { Text("Ordner erstellen") }, onClick = { dialog = Triple("folder", "", ""); fabMenu = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialog?.let { (kind, target, name) ->
|
||||
when (kind) {
|
||||
"note" -> NameDialog("Neue Notiz", "", "Anlegen", onDismiss = { dialog = null }) {
|
||||
val id = store.createNote(target, it)
|
||||
if (target.isNotEmpty()) expanded = expanded + target
|
||||
open = id
|
||||
}
|
||||
"folder" -> NameDialog("Neuer Ordner", "", "Anlegen", onDismiss = { dialog = null }) {
|
||||
val id = store.createFolder(target, it)
|
||||
expanded = expanded + target + id
|
||||
}
|
||||
"renameNote" -> NameDialog("Umbenennen", name, "Umbenennen", onDismiss = { dialog = null }) { store.renameNote(target, it) }
|
||||
else -> NameDialog("Umbenennen", name, "Umbenennen", onDismiss = { dialog = null }) {
|
||||
val id = store.renameFolder(target, it)
|
||||
if (expanded.contains(target)) expanded = expanded - target + id
|
||||
}
|
||||
}
|
||||
}
|
||||
confirmDelete?.let { row ->
|
||||
ConfirmDialog("„${row.name}“ löschen?", "Löschen", onDismiss = { confirmDelete = null }) {
|
||||
if (row.isFolder) store.deleteFolder(row.path) else store.deleteNote(row.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun TreeRow(row: Row, onClick: () -> Unit, onLongClick: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.padding(start = (12 + row.depth * 20).dp, end = 12.dp, top = 10.dp, bottom = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val icon = if (!row.isFolder) Icons.Filled.Description else if (row.open) Icons.Filled.FolderOpen else Icons.Filled.Folder
|
||||
Icon(icon, contentDescription = null, tint = Muted, modifier = Modifier.padding(end = 10.dp))
|
||||
Text(row.name, style = if (row.isFolder) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
|
||||
// Eine Notiz: formatiert anzeigen, Tipp in den Text bearbeitet. Gespeichert wird alle 5 Sekunden und beim Verlassen.
|
||||
@Composable
|
||||
fun NoteView(store: Store, id: String, onClose: () -> Unit) {
|
||||
store.version.intValue
|
||||
val note = store.note(id)
|
||||
var editing by rememberSaveable { mutableStateOf(false) }
|
||||
var text by rememberSaveable { mutableStateOf(note?.content ?: "") }
|
||||
val focus = remember { FocusRequester() }
|
||||
|
||||
fun flush() {
|
||||
if (editing) store.writeNote(id, text)
|
||||
}
|
||||
fun stopEdit() {
|
||||
flush()
|
||||
editing = false
|
||||
store.lockedNote = null
|
||||
}
|
||||
|
||||
BackHandler { if (editing) stopEdit() else onClose() }
|
||||
DisposableEffect(Unit) { onDispose { flush(); store.lockedNote = null } }
|
||||
LaunchedEffect(editing) {
|
||||
if (editing) {
|
||||
store.lockedNote = id
|
||||
text = store.note(id)?.content ?: ""
|
||||
focus.requestFocus()
|
||||
while (true) {
|
||||
delay(AUTOSAVE_MS)
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(Modifier.fillMaxWidth().padding(4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = { if (editing) stopEdit() else onClose() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
|
||||
Text(note?.name ?: "", style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f))
|
||||
if (editing) Text("Fertig", color = Orange, modifier = Modifier.clickable { stopEdit() }.padding(12.dp))
|
||||
}
|
||||
if (editing) {
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
textStyle = TextStyle(color = MaterialTheme.colorScheme.onBackground, fontFamily = FontFamily.Monospace, fontSize = MaterialTheme.typography.bodyMedium.fontSize),
|
||||
cursorBrush = SolidColor(Orange),
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp).focusRequester(focus),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
Markdown.render(note?.content ?: ""),
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).clickable { editing = true }.padding(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.marha.planer.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.marha.planer.data.Sync
|
||||
|
||||
// Server-Zugang: URL und Token, wie sync.json auf dem Desktop.
|
||||
@Composable
|
||||
fun SettingsScreen(sync: Sync, modifier: Modifier = Modifier) {
|
||||
var url by remember { mutableStateOf(sync.url) }
|
||||
var token by remember { mutableStateOf(sync.token) }
|
||||
var saved by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Server", style = MaterialTheme.typography.titleLarge)
|
||||
OutlinedTextField(value = url, onValueChange = { url = it; saved = false }, label = { Text("URL") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(
|
||||
value = token, onValueChange = { token = it; saved = false }, label = { Text("Token") }, singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(onClick = { sync.configure(url, token); saved = true }) { Text("Speichern") }
|
||||
if (saved) Text("Gespeichert. Abgleich läuft.", color = Muted)
|
||||
sync.status?.let { Text("Letzter Fehler: $it", color = MaterialTheme.colorScheme.error) }
|
||||
Text(
|
||||
"Ohne Zugang läuft die App rein lokal. Der Token steht auf dem Server in der .env und auf dem Desktop in sync.json.",
|
||||
color = Muted, style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
31
android/app/src/main/kotlin/de/marha/planer/ui/Theme.kt
Normal file
31
android/app/src/main/kotlin/de/marha/planer/ui/Theme.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package de.marha.planer.ui
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Dunkel mit Orange, wie die Desktop-Extension
|
||||
val Orange = Color(0xFFE9672E)
|
||||
val Panel = Color(0xFF1C1C1C)
|
||||
val Muted = Color(0xFF999999)
|
||||
val NowRed = Color(0xFFFF3B30)
|
||||
val TodoBlue = Color(0xFF8AB4F8)
|
||||
|
||||
private val scheme = darkColorScheme(
|
||||
primary = Orange,
|
||||
onPrimary = Color.White,
|
||||
secondary = TodoBlue,
|
||||
background = Color(0xFF121212),
|
||||
onBackground = Color(0xFFE6E6E6),
|
||||
surface = Panel,
|
||||
onSurface = Color(0xFFE6E6E6),
|
||||
surfaceVariant = Color(0xFF2A2A2A),
|
||||
onSurfaceVariant = Color(0xFFBBBBBB),
|
||||
outline = Color(0xFF444444),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PlanerTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = scheme, content = content)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package de.marha.planer.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.marha.planer.data.Dates
|
||||
import de.marha.planer.data.Store
|
||||
import de.marha.planer.data.Todo
|
||||
|
||||
// Todo-Liste: Häkchen hakt ab, Tipp auf den Text bearbeitet, Plus legt neu an.
|
||||
@Composable
|
||||
fun TodosScreen(store: Store, modifier: Modifier = Modifier) {
|
||||
store.version.intValue
|
||||
var editing by remember { mutableStateOf<Todo?>(null) }
|
||||
val todos = store.sortedTodos()
|
||||
|
||||
Box(modifier.fillMaxSize()) {
|
||||
if (todos.isEmpty()) {
|
||||
Text("Keine Todos", color = Muted, modifier = Modifier.padding(16.dp))
|
||||
}
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(todos, key = { it.id }) { todo ->
|
||||
TodoRow(todo, onToggle = { store.toggleTodo(todo.id) }, onEdit = { editing = todo })
|
||||
}
|
||||
}
|
||||
FloatingActionButton(
|
||||
onClick = { editing = Todo(id = "", title = "", created = "", updated = "") },
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
|
||||
) { Icon(Icons.Filled.Add, contentDescription = "Neues Todo") }
|
||||
}
|
||||
|
||||
editing?.let { todo -> TodoDialog(store, todo, onDismiss = { editing = null }) }
|
||||
}
|
||||
|
||||
// '17.09. 14:00', '17.09.' oder '' bei allgemeinen Todos.
|
||||
fun todoWhen(todo: Todo): String {
|
||||
val date = todo.date ?: return ""
|
||||
return if (todo.time != null) "${Dates.formatDayMonth(date)} ${todo.time}" else Dates.formatDayMonth(date)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TodoRow(todo: Todo, onToggle: () -> Unit, onEdit: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clickable(onClick = onEdit).padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(checked = todo.done, onCheckedChange = { onToggle() })
|
||||
Text(
|
||||
todo.title.lineSequence().first(),
|
||||
modifier = Modifier.weight(1f),
|
||||
color = if (todo.done) Color(0xFF888888) else MaterialTheme.colorScheme.onBackground,
|
||||
textDecoration = if (todo.done) TextDecoration.LineThrough else null,
|
||||
)
|
||||
val when_ = todoWhen(todo)
|
||||
if (when_.isNotEmpty()) {
|
||||
val overdue = !todo.done && (todo.date ?: "") < Dates.today()
|
||||
Text(when_, color = if (overdue) Color(0xFFFF6B6B) else Muted, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(end = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
10
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
10
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp" android:height="108dp"
|
||||
android:viewportWidth="108" android:viewportHeight="108">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M30,34 h48 a4,4 0 0 1 4,4 v40 a4,4 0 0 1 -4,4 h-48 a4,4 0 0 1 -4,-4 v-40 a4,4 0 0 1 4,-4 z" />
|
||||
<path android:fillColor="#E9672E"
|
||||
android:pathData="M26,38 a4,4 0 0 1 4,-4 h48 a4,4 0 0 1 4,4 v10 h-56 z" />
|
||||
<path android:fillColor="#E9672E"
|
||||
android:pathData="M36,56 h10 v10 h-10 z M50,56 h10 v10 h-10 z M64,56 h10 v10 h-10 z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
3
android/app/src/main/res/values/colors.xml
Normal file
3
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1A1A1A</color>
|
||||
</resources>
|
||||
3
android/app/src/main/res/values/strings.xml
Normal file
3
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Planer</string>
|
||||
</resources>
|
||||
40
android/app/src/test/kotlin/de/marha/planer/DatesTest.kt
Normal file
40
android/app/src/test/kotlin/de/marha/planer/DatesTest.kt
Normal file
@@ -0,0 +1,40 @@
|
||||
package de.marha.planer
|
||||
|
||||
import de.marha.planer.data.Dates
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DatesTest {
|
||||
@Test fun monthGridStartsMondayBefore() {
|
||||
val grid = Dates.monthGrid(2026, 9)
|
||||
assertEquals(42, grid.size)
|
||||
assertEquals("2026-08-31", grid[0])
|
||||
assertEquals("2026-06-01", Dates.monthGrid(2026, 6)[0])
|
||||
}
|
||||
|
||||
@Test fun weekStartsMonday() {
|
||||
assertEquals("2026-09-14", Dates.startOfWeek("2026-09-17"))
|
||||
assertEquals("2026-09-14", Dates.startOfWeek("2026-09-20"))
|
||||
assertEquals("2026-09-14", Dates.startOfWeek("2026-09-14"))
|
||||
assertEquals(7, Dates.weekDays("2026-09-17").size)
|
||||
}
|
||||
|
||||
@Test fun isoWeek() {
|
||||
assertEquals(38, Dates.isoWeek("2026-09-17"))
|
||||
assertEquals(53, Dates.isoWeek("2027-01-01"))
|
||||
}
|
||||
|
||||
@Test fun formats() {
|
||||
assertEquals("Do, 17.09.2026", Dates.formatDate("2026-09-17"))
|
||||
assertEquals("17.09.", Dates.formatDayMonth("2026-09-17"))
|
||||
assertEquals("September 2026", Dates.formatMonth(2026, 9))
|
||||
assertEquals(90, Dates.timeToMinutes("01:30"))
|
||||
assertEquals("01:30", Dates.minutesToTime(90))
|
||||
}
|
||||
|
||||
// Gleiches Format wie die Extension, damit sich Zeitstempel als Strings vergleichen lassen
|
||||
@Test fun nowIsoHasMillisAndZ() {
|
||||
assertTrue(Regex("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$").matches(Dates.nowIso()))
|
||||
}
|
||||
}
|
||||
45
android/app/src/test/kotlin/de/marha/planer/MarkdownTest.kt
Normal file
45
android/app/src/test/kotlin/de/marha/planer/MarkdownTest.kt
Normal file
@@ -0,0 +1,45 @@
|
||||
package de.marha.planer
|
||||
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import de.marha.planer.data.Markdown
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MarkdownTest {
|
||||
@Test fun headingIsBoldAndStripped() {
|
||||
val out = Markdown.render("# Titel")
|
||||
assertEquals("Titel", out.text)
|
||||
assertTrue(out.spanStyles.any { it.item.fontWeight == FontWeight.Bold && it.start == 0 && it.end == 5 })
|
||||
}
|
||||
|
||||
@Test fun inlineStyles() {
|
||||
assertEquals("A b c", Markdown.render("A **b** c").text)
|
||||
assertEquals("a kursiv b", Markdown.render("a *kursiv* b").text)
|
||||
assertEquals("snake_case_name", Markdown.render("snake_case_name").text)
|
||||
assertEquals("2 * 3 * 4", Markdown.render("2 * 3 * 4").text)
|
||||
assertEquals("Link", Markdown.render("[Link](http://x)").text)
|
||||
assertEquals("Wiki", Markdown.render("[[Wiki]]").text)
|
||||
assertEquals("[Bild: Bild]", Markdown.render("").text)
|
||||
assertEquals("weg", Markdown.render("~~weg~~").text)
|
||||
}
|
||||
|
||||
@Test fun codeKeepsStars() {
|
||||
val out = Markdown.render("`**nicht fett**`")
|
||||
assertEquals("**nicht fett**", out.text)
|
||||
assertTrue(out.spanStyles.none { it.item.fontWeight == FontWeight.Bold })
|
||||
}
|
||||
|
||||
@Test fun lists() {
|
||||
assertEquals(" • eins\n • zwei", Markdown.render("- eins\n - zwei").text)
|
||||
assertEquals(" 1. a\n 2. b", Markdown.render("1. a\n2. b").text)
|
||||
assertEquals(" ☐ offen\n ☑ fertig", Markdown.render("- [ ] offen\n- [x] fertig").text)
|
||||
}
|
||||
|
||||
@Test fun blocks() {
|
||||
assertEquals("let a = 1;", Markdown.render("```\nlet a = 1;\n```").text.trim())
|
||||
assertEquals("▎ zitat", Markdown.render("> zitat").text)
|
||||
assertTrue(Markdown.render("---").text.startsWith("────"))
|
||||
assertEquals("a\n\nb", Markdown.render("a\n\nb").text)
|
||||
}
|
||||
}
|
||||
103
android/app/src/test/kotlin/de/marha/planer/StoreTest.kt
Normal file
103
android/app/src/test/kotlin/de/marha/planer/StoreTest.kt
Normal file
@@ -0,0 +1,103 @@
|
||||
package de.marha.planer
|
||||
|
||||
import de.marha.planer.data.Event
|
||||
import de.marha.planer.data.Store
|
||||
import de.marha.planer.data.Todo
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
class StoreTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
|
||||
private fun store() = Store(tmp.root)
|
||||
private fun event(title: String, date: String, start: String? = null, end: String? = null, endDate: String? = null) =
|
||||
Event(id = "", title = title, date = date, start = start, end = end, endDate = endDate, created = "", updated = "")
|
||||
|
||||
@Test fun eventsGetIdAndTombstone() {
|
||||
val s = store()
|
||||
s.saveEvent(event("Zahnarzt", "2026-09-17", "09:30", "10:30"))
|
||||
val saved = s.events.single()
|
||||
assertTrue(saved.id.isNotEmpty() && saved.updated.endsWith("Z"))
|
||||
s.removeEvent(saved.id)
|
||||
assertTrue(s.events.single().deleted)
|
||||
assertEquals(0, s.eventsOn("2026-09-17").size)
|
||||
// Neu laden: Tombstone bleibt in der Datei
|
||||
assertTrue(store().events.single().deleted)
|
||||
}
|
||||
|
||||
@Test fun multiDayEventTouchesEveryDay() {
|
||||
val s = store()
|
||||
s.saveEvent(event("Messe", "2026-09-15", "14:00", "11:00", "2026-09-16"))
|
||||
assertEquals(1, s.eventsOn("2026-09-15").size)
|
||||
assertEquals(1, s.eventsOn("2026-09-16").size)
|
||||
assertEquals(0, s.eventsOn("2026-09-17").size)
|
||||
}
|
||||
|
||||
@Test fun todosSorted() {
|
||||
val s = store()
|
||||
s.saveTodo(Todo(id = "", title = "allgemein", created = "", updated = ""))
|
||||
s.saveTodo(Todo(id = "", title = "morgen", date = "2026-09-18", created = "", updated = ""))
|
||||
s.saveTodo(Todo(id = "", title = "heute", date = "2026-09-17", time = "11:00", created = "", updated = ""))
|
||||
s.toggleTodo(s.todos.first { it.title == "morgen" }.id)
|
||||
assertEquals(listOf("heute", "allgemein", "morgen"), s.sortedTodos().map { it.title })
|
||||
s.removeTodo(s.todos.first { it.title == "heute" }.id)
|
||||
assertEquals(listOf("allgemein", "morgen"), s.sortedTodos().map { it.title })
|
||||
}
|
||||
|
||||
@Test fun notesAndFolders() {
|
||||
val s = store()
|
||||
val folder = s.createFolder("", "Projekte")
|
||||
val sub = s.createFolder(folder, "App")
|
||||
val note = s.createNote(sub, "Plan")
|
||||
assertEquals("Projekte/App/Plan.md", note)
|
||||
s.writeNote(note, "# Plan")
|
||||
assertEquals("# Plan", s.note(note)!!.content)
|
||||
assertEquals(listOf("Projekte"), s.tree().folders.map { it.name })
|
||||
assertEquals(listOf("Plan"), s.tree().folders[0].folders[0].notes.map { it.name })
|
||||
|
||||
val renamed = s.renameNote(note, "Neu")
|
||||
assertEquals("Projekte/App/Neu.md", renamed)
|
||||
assertNull(s.note(note))
|
||||
assertEquals("# Plan", s.note(renamed)!!.content)
|
||||
|
||||
val moved = s.renameFolder(folder, "Arbeit")
|
||||
assertEquals("Arbeit", moved)
|
||||
assertNotNull(s.note("Arbeit/App/Neu.md"))
|
||||
assertTrue(s.folders.any { it.id == "Arbeit/App" && !it.deleted })
|
||||
assertTrue(s.folders.any { it.id == "Projekte" && it.deleted })
|
||||
|
||||
try {
|
||||
s.deleteFolder("Arbeit")
|
||||
throw AssertionError("voller Ordner darf nicht löschbar sein")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
assertEquals("Der Ordner ist nicht leer", e.message)
|
||||
}
|
||||
s.deleteNote("Arbeit/App/Neu.md")
|
||||
s.deleteFolder("Arbeit/App")
|
||||
s.deleteFolder("Arbeit")
|
||||
assertTrue(s.tree().folders.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun remoteNewerWins() {
|
||||
val s = store()
|
||||
s.saveTodo(Todo(id = "", title = "lokal", created = "", updated = ""))
|
||||
val id = s.todos.single().id
|
||||
val older = JSONObject().put("id", id).put("title", "alt").put("updated", "2000-01-01T00:00:00.000Z")
|
||||
assertEquals(false, s.applyRemote("todos", listOf(older)))
|
||||
assertEquals("lokal", s.todos.single().title)
|
||||
val newer = JSONObject().put("id", id).put("title", "neu").put("updated", "2099-01-01T00:00:00.000Z").put("done", true)
|
||||
assertEquals(true, s.applyRemote("todos", listOf(newer)))
|
||||
assertEquals("neu", s.todos.single().title)
|
||||
assertTrue(s.todos.single().done)
|
||||
// Notiz vom Server samt Ordner
|
||||
s.applyRemote("folders", listOf(JSONObject().put("id", "Remote").put("updated", "2026-01-01T00:00:00.000Z")))
|
||||
s.applyRemote("notes", listOf(JSONObject().put("id", "Remote/Idee.md").put("content", "hi").put("updated", "2026-01-01T00:00:00.000Z")))
|
||||
assertEquals("hi", s.tree().folders.single().notes.single().content)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user