From aff6cef4930a4d22c9f1996d0e60f41c58ed7da6 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 12:22:56 +0200 Subject: [PATCH] feat: full Compose UI (auth, calendar, events, accounts, settings, profile) - Root navigation (server setup -> login+2FA -> calendar) with settings-driven theme - CalendarViewModel mirroring iOS CalendarStore: range-based caching, background prefetch, hidden/banished calendar filters, event CRUD - Five calendar views: Month, Week, Day, Quarter, Agenda (shared time grid) - Event detail + editor sheets (platform date/time pickers, color override, writable-calendar picker) across local/caldav/google/homeassistant - Calendar filter sheet (per-calendar show/hide) - Accounts screen: add/delete CalDAV, local, iCal, Home Assistant; list Google - Settings: view/week-start/language/colors/contrast/hour-height/cache range, synced to server - Profile: email, password change, 2FA enable/disable - gradlew build (lint + tests + assemble) passes Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 2 +- .../com/scarriffle/calendarr/MainActivity.kt | 17 +- .../scarriffle/calendarr/ui/CalendarrRoot.kt | 45 +++ .../scarriffle/calendarr/ui/LocalProviders.kt | 22 ++ .../scarriffle/calendarr/ui/MainViewModel.kt | 79 ++++ .../calendarr/ui/accounts/AccountsScreen.kt | 241 +++++++++++++ .../ui/accounts/AccountsViewModel.kt | 88 +++++ .../calendarr/ui/auth/AuthViewModel.kt | 97 +++++ .../calendarr/ui/auth/LoginScreen.kt | 124 +++++++ .../calendarr/ui/auth/ServerSetupScreen.kt | 87 +++++ .../calendarr/ui/calendar/AgendaView.kt | 109 ++++++ .../ui/calendar/CalendarFilterSheet.kt | 83 +++++ .../ui/calendar/CalendarFormatting.kt | 87 +++++ .../calendarr/ui/calendar/CalendarScreen.kt | 261 ++++++++++++++ .../ui/calendar/CalendarViewModel.kt | 340 ++++++++++++++++++ .../calendarr/ui/calendar/MonthView.kt | 166 +++++++++ .../calendarr/ui/calendar/QuarterView.kt | 104 ++++++ .../calendarr/ui/calendar/TimeGridView.kt | 219 +++++++++++ .../calendarr/ui/event/EventDetailSheet.kt | 123 +++++++ .../calendarr/ui/event/EventEditorSheet.kt | 275 ++++++++++++++ .../scarriffle/calendarr/ui/menu/MenuSheet.kt | 74 ++++ .../calendarr/ui/profile/ProfileScreen.kt | 163 +++++++++ .../calendarr/ui/profile/ProfileViewModel.kt | 91 +++++ .../calendarr/ui/settings/SettingsScreen.kt | 190 ++++++++++ .../ui/settings/SettingsViewModel.kt | 30 ++ .../scarriffle/calendarr/ui/theme/Theme.kt | 47 ++- 26 files changed, 3129 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/LocalProviders.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/auth/AuthViewModel.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/auth/LoginScreen.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/auth/ServerSetupScreen.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/calendar/QuarterView.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileScreen.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileViewModel.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d5956c3..f4833dc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -87,7 +87,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.6") + androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01")) androidTestImplementation("androidx.compose.ui:ui-test-junit4") diff --git a/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt b/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt index 029eafb..db5e8e7 100644 --- a/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt +++ b/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt @@ -3,14 +3,7 @@ package com.scarriffle.calendarr import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import com.scarriffle.calendarr.ui.theme.CalendarrTheme +import com.scarriffle.calendarr.ui.CalendarrRoot import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint @@ -18,13 +11,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - CalendarrTheme { - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { - Text(text = "Calendarr Android", style = MaterialTheme.typography.headlineMedium) - } - } - } + CalendarrRoot() } } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt new file mode 100644 index 0000000..b61e566 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt @@ -0,0 +1,45 @@ +package com.scarriffle.calendarr.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.ui.auth.LoginScreen +import com.scarriffle.calendarr.ui.auth.ServerSetupScreen +import com.scarriffle.calendarr.ui.calendar.CalendarScreen +import com.scarriffle.calendarr.ui.theme.CalendarrTheme + +@Composable +fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) { + val route by vm.route.collectAsState() + val settings by vm.settings.collectAsState() + + CalendarrTheme(settings) { + CompositionLocalProvider( + LocalLang provides L10n.resolved(settings.language), + LocalAppSettings provides settings, + ) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + when (route) { + AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured) + AppRoute.LOGIN -> LoginScreen( + serverUrl = vm.serverUrl, + onLoggedIn = vm::onLoggedIn, + onBack = vm::switchServer, + ) + AppRoute.MAIN -> CalendarScreen( + onLogout = vm::logout, + onSwitchServer = vm::switchServer, + onSettingsChanged = vm::applyLocalSettings, + onSettingsSynced = vm::refreshSettings, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/LocalProviders.kt b/app/src/main/java/com/scarriffle/calendarr/ui/LocalProviders.kt new file mode 100644 index 0000000..e11c9ff --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/LocalProviders.kt @@ -0,0 +1,22 @@ +package com.scarriffle.calendarr.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf +import com.scarriffle.calendarr.domain.model.AppSettings + +/** Resolved UI language ("de" / "en"), provided at the root. */ +val LocalLang = compositionLocalOf { "de" } + +/** Current appearance settings, provided at the root. */ +val LocalAppSettings = staticCompositionLocalOf { AppSettings() } + +/** Convenience translation lookup that reads the ambient language. */ +@Composable +@ReadOnlyComposable +fun tr(key: String): String = L10n.t(key, LocalLang.current) + +@Composable +@ReadOnlyComposable +fun tr(key: String, vararg args: Any): String = L10n.t(key, LocalLang.current, *args) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt new file mode 100644 index 0000000..1714702 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt @@ -0,0 +1,79 @@ +package com.scarriffle.calendarr.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.scarriffle.calendarr.data.CalendarRepository +import com.scarriffle.calendarr.data.CredentialStore +import com.scarriffle.calendarr.data.SettingsStore +import com.scarriffle.calendarr.data.remote.ApiProvider +import com.scarriffle.calendarr.domain.model.AppSettings +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +enum class AppRoute { SETUP, LOGIN, MAIN } + +@HiltViewModel +class MainViewModel @Inject constructor( + private val credentialStore: CredentialStore, + private val settingsStore: SettingsStore, + private val repository: CalendarRepository, + private val apiProvider: ApiProvider, +) : ViewModel() { + + private val _route = MutableStateFlow(computeRoute()) + val route: StateFlow = _route.asStateFlow() + + private val _settings = MutableStateFlow(settingsStore.loadSettings()) + val settings: StateFlow = _settings.asStateFlow() + + val serverUrl: String get() = credentialStore.serverUrl ?: "" + val username: String get() = credentialStore.username ?: "" + val isAdmin: Boolean get() = credentialStore.isAdmin + + init { + if (_route.value == AppRoute.MAIN) refreshSettings() + } + + private fun computeRoute(): AppRoute = when { + !credentialStore.isConfigured -> AppRoute.SETUP + !credentialStore.isLoggedIn -> AppRoute.LOGIN + else -> AppRoute.MAIN + } + + fun onServerConfigured() { _route.value = computeRoute() } + + fun onLoggedIn() { + _route.value = AppRoute.MAIN + refreshSettings() + } + + fun logout() { + credentialStore.clearToken() + _route.value = computeRoute() + } + + fun switchServer() { + credentialStore.clearAll() + apiProvider.invalidate() + _route.value = computeRoute() + } + + /** Pull appearance settings from the server, caching them locally. */ + fun refreshSettings() { + viewModelScope.launch { + runCatching { repository.getSettings() }.onSuccess { s -> + settingsStore.saveSettings(s) + _settings.value = s + } + } + } + + fun applyLocalSettings(s: AppSettings) { + settingsStore.saveSettings(s) + _settings.value = s + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt new file mode 100644 index 0000000..9888702 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt @@ -0,0 +1,241 @@ +package com.scarriffle.calendarr.ui.accounts + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex + +private enum class AddType { LOCAL, CALDAV, ICAL, HA } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountsScreen( + onClose: () -> Unit, + onChanged: () -> Unit, + vm: AccountsViewModel = hiltViewModel(), +) { + var addDialog by remember { mutableStateOf(null) } + + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Scaffold( + topBar = { + androidx.compose.material3.TopAppBar( + title = { Text(tr("accounts.title")) }, + navigationIcon = { + IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) } + }, + ) + }, + ) { padding -> + if (vm.loading) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + return@Scaffold + } + LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { + // Local + item { SectionHeader(tr("accounts.local.header"), tr("accounts.local.add")) { addDialog = AddType.LOCAL } } + if (vm.local.isEmpty()) item { EmptyRow(tr("accounts.local.empty")) } + items(vm.local, key = { "l${it.id}" }) { cal -> + AccountRow(cal.name, cal.color) { vm.deleteLocal(cal.id, onChanged) } + } + + // CalDAV + item { SectionHeader(tr("accounts.caldav.header"), tr("accounts.caldav.add")) { addDialog = AddType.CALDAV } } + if (vm.caldav.isEmpty()) item { EmptyRow(tr("accounts.caldav.empty")) } + items(vm.caldav, key = { "c${it.id}" }) { acc -> + AccountRow("${acc.name} (${acc.username})", acc.color) { vm.deleteCalDAV(acc.id, onChanged) } + } + + // iCal + item { SectionHeader(tr("accounts.ical.header"), tr("accounts.ical.add")) { addDialog = AddType.ICAL } } + if (vm.ical.isEmpty()) item { EmptyRow(tr("accounts.ical.empty")) } + items(vm.ical, key = { "i${it.id}" }) { sub -> + AccountRow(sub.name, sub.color) { vm.deleteICal(sub.id, onChanged) } + } + + // Google + item { SectionHeaderNoAdd(tr("accounts.google.header")) } + if (vm.google.isEmpty()) item { EmptyRow(tr("accounts.google.hint")) } + items(vm.google, key = { "g${it.id}" }) { acc -> + AccountRow(acc.email, "#4285f4") { vm.deleteGoogle(acc.id, onChanged) } + } + + // Home Assistant + item { SectionHeader(tr("accounts.ha.header"), tr("accounts.ha.add")) { addDialog = AddType.HA } } + if (vm.homeAssistant.isEmpty()) item { EmptyRow(tr("accounts.ha.empty")) } + items(vm.homeAssistant, key = { "h${it.id}" }) { acc -> + AccountRow(acc.name, "#46bdc6") { vm.deleteHA(acc.id, onChanged) } + } + + vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } } + item { Spacer(Modifier.size(40.dp)) } + } + } + } + + when (addDialog) { + AddType.LOCAL -> LocalDialog(onDismiss = { addDialog = null }) { name, color -> + vm.addLocal(name, color, onChanged); addDialog = null + } + AddType.CALDAV -> CalDAVDialog(onDismiss = { addDialog = null }) { n, u, us, p, c -> + vm.addCalDAV(n, u, us, p, c, onChanged); addDialog = null + } + AddType.ICAL -> ICalDialog(onDismiss = { addDialog = null }) { n, u, c, r -> + vm.addICal(n, u, c, r, onChanged); addDialog = null + } + AddType.HA -> HADialog(onDismiss = { addDialog = null }) { n, u, t -> + vm.addHA(n, u, t, onChanged); addDialog = null + } + null -> Unit + } +} + +@Composable +private fun SectionHeader(title: String, addLabel: String, onAdd: () -> Unit) { + Row( + Modifier.fillMaxWidth().padding(top = 20.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + TextButton(onClick = onAdd) { + Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(4.dp)) + Text(addLabel) + } + } +} + +@Composable +private fun SectionHeaderNoAdd(title: String) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 20.dp, bottom = 6.dp)) +} + +@Composable +private fun EmptyRow(text: String) { + Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 6.dp)) +} + +@Composable +private fun AccountRow(name: String, color: String, onDelete: () -> Unit) { + Row( + Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(color))) + Text(name, modifier = Modifier.weight(1f).padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge) + IconButton(onClick = onDelete) { + Icon(Icons.Filled.Delete, contentDescription = tr("common.delete"), tint = MaterialTheme.colorScheme.error) + } + } +} + +// ---- Add dialogs ---- + +@Composable +private fun LocalDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) { + var name by remember { mutableStateOf("") } + val color = "#34a853" + FormDialog(tr("accounts.local.add"), onDismiss, confirmEnabled = name.isNotBlank(), onConfirm = { onConfirm(name.trim(), color) }) { + OutlinedTextField(name, { name = it }, label = { Text(tr("local.name")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun CalDAVDialog(onDismiss: () -> Unit, onConfirm: (String, String, String, String, String) -> Unit) { + var name by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + var user by remember { mutableStateOf("") } + var pass by remember { mutableStateOf("") } + val color = "#4285f4" + FormDialog(tr("accounts.caldav.add"), onDismiss, confirmEnabled = url.isNotBlank() && user.isNotBlank(), onConfirm = { onConfirm(name.trim(), url.trim(), user.trim(), pass, color) }) { + OutlinedTextField(name, { name = it }, label = { Text(tr("caldav.display_name")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(url, { url = it }, label = { Text(tr("caldav.url")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(user, { user = it }, label = { Text(tr("caldav.username")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(pass, { pass = it }, label = { Text(tr("caldav.password")) }, singleLine = true, visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun ICalDialog(onDismiss: () -> Unit, onConfirm: (String, String, String, Int) -> Unit) { + var name by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + val color = "#46bdc6" + FormDialog(tr("accounts.ical.add"), onDismiss, confirmEnabled = url.isNotBlank(), onConfirm = { onConfirm(name.trim(), url.trim(), color, 60) }) { + OutlinedTextField(name, { name = it }, label = { Text(tr("ical.name")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(url, { url = it }, label = { Text(tr("ical.url")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun HADialog(onDismiss: () -> Unit, onConfirm: (String, String, String) -> Unit) { + var name by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + var token by remember { mutableStateOf("") } + FormDialog(tr("accounts.ha.add"), onDismiss, confirmEnabled = url.isNotBlank() && token.isNotBlank(), onConfirm = { onConfirm(name.trim(), url.trim(), token.trim()) }) { + OutlinedTextField(name, { name = it }, label = { Text(tr("ha.display_name")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(url, { url = it }, label = { Text(tr("ha.url_placeholder")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(token, { token = it }, label = { Text(tr("ha.token")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun FormDialog( + title: String, + onDismiss: () -> Unit, + confirmEnabled: Boolean, + onConfirm: () -> Unit, + content: @Composable () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Column { content() } }, + confirmButton = { TextButton(onClick = onConfirm, enabled = confirmEnabled) { Text(tr("caldav.connect")) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(tr("common.cancel")) } }, + ) +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt new file mode 100644 index 0000000..3ced76d --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt @@ -0,0 +1,88 @@ +package com.scarriffle.calendarr.ui.accounts + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.scarriffle.calendarr.data.CalendarRepository +import com.scarriffle.calendarr.domain.model.CalDAVAccount +import com.scarriffle.calendarr.domain.model.GoogleAccount +import com.scarriffle.calendarr.domain.model.HomeAssistantAccount +import com.scarriffle.calendarr.domain.model.ICalSubscription +import com.scarriffle.calendarr.domain.model.LocalCalendar +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AccountsViewModel @Inject constructor( + private val repository: CalendarRepository, +) : ViewModel() { + + var loading by mutableStateOf(true) + private set + var error by mutableStateOf(null) + private set + + var caldav by mutableStateOf>(emptyList()) + private set + var local by mutableStateOf>(emptyList()) + private set + var ical by mutableStateOf>(emptyList()) + private set + var google by mutableStateOf>(emptyList()) + private set + var homeAssistant by mutableStateOf>(emptyList()) + private set + + init { load() } + + fun load() { + viewModelScope.launch { + loading = true + error = null + caldav = runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList()) + local = runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList()) + ical = runCatching { repository.getICalSubscriptions() }.getOrDefault(emptyList()) + google = runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList()) + homeAssistant = runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList()) + loading = false + } + } + + private fun mutate(onChanged: () -> Unit, block: suspend () -> Unit) { + viewModelScope.launch { + runCatching { block() } + .onSuccess { load(); onChanged() } + .onFailure { error = it.message } + } + } + + fun addLocal(name: String, color: String, onChanged: () -> Unit) = + mutate(onChanged) { repository.addLocalCalendar(name, color) } + + fun deleteLocal(id: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.deleteLocalCalendar(id) } + + fun addCalDAV(name: String, url: String, user: String, pass: String, color: String, onChanged: () -> Unit) = + mutate(onChanged) { repository.addCalDAVAccount(name, url, user, pass, color) } + + fun deleteCalDAV(id: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.deleteCalDAVAccount(id) } + + fun addICal(name: String, url: String, color: String, refreshMinutes: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.addICalSubscription(name, url, color, refreshMinutes) } + + fun deleteICal(id: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.deleteICalSubscription(id) } + + fun addHA(name: String, url: String, token: String, onChanged: () -> Unit) = + mutate(onChanged) { repository.addHomeAssistantAccount(name, url, token) } + + fun deleteHA(id: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.deleteHomeAssistantAccount(id) } + + fun deleteGoogle(id: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.deleteGoogleAccount(id) } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/auth/AuthViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/auth/AuthViewModel.kt new file mode 100644 index 0000000..034151f --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/auth/AuthViewModel.kt @@ -0,0 +1,97 @@ +package com.scarriffle.calendarr.ui.auth + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.scarriffle.calendarr.data.CalendarRepository +import com.scarriffle.calendarr.data.CredentialStore +import com.scarriffle.calendarr.data.remote.ApiProvider +import com.scarriffle.calendarr.data.remote.TwoFactorRequiredException +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AuthViewModel @Inject constructor( + private val repository: CalendarRepository, + private val credentialStore: CredentialStore, +) : ViewModel() { + + // Server setup + var serverUrl by mutableStateOf(credentialStore.serverUrl ?: "") + private set + var checking by mutableStateOf(false) + private set + var setupError by mutableStateOf(null) + private set + + // Login + var username by mutableStateOf("") + private set + var password by mutableStateOf("") + private set + var totpCode by mutableStateOf("") + private set + var showTotp by mutableStateOf(false) + private set + var rememberMe by mutableStateOf(true) + private set + var loggingIn by mutableStateOf(false) + private set + var loginError by mutableStateOf(null) + private set + + fun onServerUrlChange(v: String) { serverUrl = v; setupError = null } + fun onUsernameChange(v: String) { username = v; loginError = null } + fun onPasswordChange(v: String) { password = v; loginError = null } + fun onTotpChange(v: String) { totpCode = v; loginError = null } + fun onRememberChange(v: Boolean) { rememberMe = v } + + fun checkServer(onConfigured: () -> Unit) { + val url = serverUrl.trim() + if (url.isEmpty()) { setupError = "URL erforderlich"; return } + viewModelScope.launch { + checking = true + setupError = null + runCatching { repository.setupRequired(url) } + .onSuccess { + credentialStore.serverUrl = ApiProvider.normalize(url) + onConfigured() + } + .onFailure { setupError = it.message ?: "Verbindung fehlgeschlagen" } + checking = false + } + } + + fun login(onSuccess: () -> Unit) { + val base = credentialStore.serverUrl ?: return + if (username.isBlank() || password.isBlank()) { + loginError = "Benutzername und Passwort erforderlich" + return + } + viewModelScope.launch { + loggingIn = true + loginError = null + runCatching { + repository.login( + baseUrl = base, + username = username.trim(), + password = password, + totpCode = totpCode.trim().takeIf { it.isNotEmpty() }, + rememberMe = rememberMe, + ) + }.onSuccess { onSuccess() } + .onFailure { e -> + if (e is TwoFactorRequiredException) { + showTotp = true + loginError = null + } else { + loginError = e.message ?: "Anmeldung fehlgeschlagen" + } + } + loggingIn = false + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/auth/LoginScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/auth/LoginScreen.kt new file mode 100644 index 0000000..d730125 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/auth/LoginScreen.kt @@ -0,0 +1,124 @@ +package com.scarriffle.calendarr.ui.auth + +import androidx.compose.animation.AnimatedVisibility +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.data.CredentialStore +import com.scarriffle.calendarr.ui.tr + +@Composable +fun LoginScreen( + serverUrl: String, + onLoggedIn: () -> Unit, + onBack: () -> Unit, + vm: AuthViewModel = hiltViewModel(), +) { + Column(modifier = Modifier.fillMaxSize().padding(horizontal = 28.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 12.dp)) { + IconButton(onClick = onBack) { + Icon(Icons.Filled.ArrowBack, contentDescription = tr("auth.back")) + } + Spacer(Modifier.height(0.dp)) + Text(serverUrl, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(tr("auth.login_title"), style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(24.dp)) + OutlinedTextField( + value = vm.username, + onValueChange = vm::onUsernameChange, + label = { Text(tr("auth.username")) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = vm.password, + onValueChange = vm::onPasswordChange, + label = { Text(tr("auth.password")) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + modifier = Modifier.fillMaxWidth(), + ) + AnimatedVisibility(vm.showTotp) { + Column { + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = vm.totpCode, + onValueChange = vm::onTotpChange, + label = { Text(tr("auth.totp")) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + modifier = Modifier.fillMaxWidth(), + ) + } + } + Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(tr("auth.remember"), style = MaterialTheme.typography.bodyMedium) + Switch(checked = vm.rememberMe, onCheckedChange = vm::onRememberChange) + } + vm.loginError?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(20.dp)) + Button( + onClick = { vm.login(onLoggedIn) }, + enabled = !vm.loggingIn, + modifier = Modifier.fillMaxWidth(), + ) { + if (vm.loggingIn) { + CircularProgressIndicator(modifier = Modifier.height(20.dp), strokeWidth = 2.dp) + } else { + Text(tr("auth.login")) + } + } + TextButton(onClick = onBack) { Text(tr("server.switch")) } + } + } +} + +/** Read the configured server URL for display. */ +@Composable +fun rememberServerUrl(credentialStore: CredentialStore): String = + credentialStore.serverUrl ?: "" diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/auth/ServerSetupScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/auth/ServerSetupScreen.kt new file mode 100644 index 0000000..413e278 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/auth/ServerSetupScreen.kt @@ -0,0 +1,87 @@ +package com.scarriffle.calendarr.ui.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.ui.tr + +@Composable +fun ServerSetupScreen( + onConfigured: () -> Unit, + vm: AuthViewModel = hiltViewModel(), +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 28.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Filled.CalendarMonth, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.height(64.dp), + ) + Spacer(Modifier.height(16.dp)) + Text("Calendarr", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(8.dp)) + Text( + tr("auth.server_hint"), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(28.dp)) + OutlinedTextField( + value = vm.serverUrl, + onValueChange = vm::onServerUrlChange, + label = { Text(tr("auth.server_url")) }, + placeholder = { Text("https://cal.example.com") }, + singleLine = true, + isError = vm.setupError != null, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + ), + modifier = Modifier.fillMaxWidth(), + ) + vm.setupError?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(20.dp)) + Button( + onClick = { vm.checkServer(onConfigured) }, + enabled = !vm.checking && vm.serverUrl.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + if (vm.checking) { + CircularProgressIndicator(modifier = Modifier.height(20.dp), strokeWidth = 2.dp) + } else { + Text(tr("auth.continue")) + } + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt new file mode 100644 index 0000000..c2c2562 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt @@ -0,0 +1,109 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +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.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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@Composable +fun AgendaView( + state: CalendarUiState, + vm: CalendarViewModel, + onEventClick: (CalEvent) -> Unit, +) { + val lang = LocalLang.current + val today = LocalDate.now() + val days = (0 until 90).map { today.plusDays(it.toLong()) } + .map { it to vm.eventsOn(it, state.events) } + .filter { it.second.isNotEmpty() } + + if (days.isEmpty()) { + Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(tr("cal.no_events_title"), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(6.dp)) + Text( + tr("cal.no_events_body"), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + return + } + + val headerFmt = DateTimeFormatter.ofPattern("EEEE, d. MMMM", com.scarriffle.calendarr.ui.L10n.locale(lang)) + + LazyColumn(Modifier.fillMaxSize().padding(horizontal = 12.dp)) { + days.forEach { (date, events) -> + item(key = "h-$date") { + Text( + headerFmt.format(date).replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = if (date == today) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(top = 16.dp, bottom = 6.dp), + ) + } + items(events, key = { "${date}-${it.id}" }) { ev -> + AgendaRow(ev, lang, onClick = { onEventClick(ev) }) + } + } + item { Spacer(Modifier.height(80.dp)) } + } +} + +@Composable +private fun AgendaRow(event: CalEvent, lang: String, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surface) + .clickable(onClick = onClick) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(10.dp).clip(CircleShape).background(colorFromHex(event.effectiveColor))) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(event.title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + if (event.location.isNotBlank()) { + Text(event.location, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Spacer(Modifier.width(8.dp)) + Text( + eventTimeRange(event, lang), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt new file mode 100644 index 0000000..b84a831 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt @@ -0,0 +1,83 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex + +data class CalendarFilterEntry(val key: String, val name: String, val color: String) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarFilterSheet( + events: List, + vm: CalendarViewModel, + onDismiss: () -> Unit, +) { + val state by vm.state.collectAsState() + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(tr("filter.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Row { + TextButton(onClick = { vm.setHiddenCalendars(emptySet()) }) { Text(tr("filter.show_all")) } + TextButton(onClick = { + vm.setHiddenCalendars(events.map { it.key }.toSet()) + }) { Text(tr("filter.hide_all")) } + } + } + if (events.isEmpty()) { + Text( + tr("filter.empty"), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 24.dp), + ) + } + events.forEach { entry -> + val visible = entry.key !in state.hiddenKeys + Row( + Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(entry.color))) + Text( + entry.name, + modifier = Modifier.weight(1f).padding(start = 12.dp), + style = MaterialTheme.typography.bodyLarge, + ) + Switch( + checked = visible, + onCheckedChange = { vm.setCalendarHidden(entry.key, hidden = !it) }, + ) + } + } + Box(Modifier.padding(bottom = 24.dp)) + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt new file mode 100644 index 0000000..01ee814 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt @@ -0,0 +1,87 @@ +package com.scarriffle.calendarr.ui.calendar + +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.domain.model.CalViewType +import com.scarriffle.calendarr.ui.L10n +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle + +private val zone: ZoneId = ZoneId.systemDefault() + +fun titleForView(viewType: CalViewType, date: LocalDate, lang: String): String { + val loc = L10n.locale(lang) + return when (viewType) { + CalViewType.MONTH -> + DateTimeFormatter.ofPattern("LLLL yyyy", loc).format(date) + .replaceFirstChar { it.uppercase(loc) } + CalViewType.QUARTER -> { + val fmt = DateTimeFormatter.ofPattern("LLL yyyy", loc) + "${fmt.format(date)} – ${fmt.format(date.plusMonths(2))}" + } + CalViewType.WEEK -> { + val start = date + val fmt = DateTimeFormatter.ofPattern("d. MMM", loc) + val endFmt = DateTimeFormatter.ofPattern("d. MMM yyyy", loc) + "${fmt.format(start)} – ${endFmt.format(start.plusDays(6))}" + } + CalViewType.DAY -> + DateTimeFormatter.ofPattern("EEEE, d. MMMM yyyy", loc).format(date) + CalViewType.AGENDA -> L10n.t("view.agenda", lang) + } +} + +fun weekdayLabels(mondayFirst: Boolean, lang: String): List { + val loc = L10n.locale(lang) + val order = if (mondayFirst) { + listOf(1, 2, 3, 4, 5, 6, 7) + } else { + listOf(7, 1, 2, 3, 4, 5, 6) + } + return order.map { + java.time.DayOfWeek.of(it).getDisplayName(TextStyle.SHORT, loc).take(2) + } +} + +fun timeLabel(instant: Instant, lang: String): String = + DateTimeFormatter.ofPattern("HH:mm", L10n.locale(lang)).withZone(zone).format(instant) + +fun localTime(instant: Instant): LocalTime = LocalTime.ofInstant(instant, zone) + +fun localDate(instant: Instant): LocalDate = LocalDate.ofInstant(instant, zone) + +/** Minutes-from-midnight of the instant in the local zone. */ +fun minutesOfDay(instant: Instant): Int { + val t = localTime(instant) + return t.hour * 60 + t.minute +} + +fun eventTimeRange(event: CalEvent, lang: String): String { + if (event.isAllDay) return L10n.t("cal.allday", lang) + return "${timeLabel(event.startDate, lang)} – ${timeLabel(event.endDate, lang)}" +} + +/** Full human-readable date (+time) range for the detail sheet. */ +fun eventDateRange(event: CalEvent, lang: String): String { + val loc = L10n.locale(lang) + val dateFmt = DateTimeFormatter.ofPattern("EEE, d. MMM yyyy", loc) + val startDate = localDate(event.startDate) + val endDate = localDate(event.endDate) + if (event.isAllDay) { + // All-day end is exclusive on the server; show the inclusive last day. + val lastDay = endDate.minusDays(1) + return if (lastDay.isAfter(startDate)) { + "${dateFmt.format(startDate)} – ${dateFmt.format(lastDay)}" + } else { + "${dateFmt.format(startDate)} · ${L10n.t("cal.allday", lang)}" + } + } + return if (startDate == endDate) { + "${dateFmt.format(startDate)}\n${timeLabel(event.startDate, lang)} – ${timeLabel(event.endDate, lang)}" + } else { + "${dateFmt.format(startDate)} ${timeLabel(event.startDate, lang)}\n– ${dateFmt.format(endDate)} ${timeLabel(event.endDate, lang)}" + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt new file mode 100644 index 0000000..f9025be --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -0,0 +1,261 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.layout.Box +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.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Today +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.domain.model.AppSettings +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.domain.model.CalViewType +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.accounts.AccountsScreen +import com.scarriffle.calendarr.ui.event.EventDetailSheet +import com.scarriffle.calendarr.ui.event.EventEditorSheet +import com.scarriffle.calendarr.ui.menu.MenuSheet +import com.scarriffle.calendarr.ui.profile.ProfileScreen +import com.scarriffle.calendarr.ui.settings.SettingsScreen +import com.scarriffle.calendarr.ui.tr +import java.time.LocalDate + +private enum class Overlay { NONE, PROFILE, SETTINGS, ACCOUNTS } + +data class EditorRequest(val existing: CalEvent?, val date: LocalDate) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarScreen( + onLogout: () -> Unit, + onSwitchServer: () -> Unit, + onSettingsChanged: (AppSettings) -> Unit, + onSettingsSynced: () -> Unit, + vm: CalendarViewModel = hiltViewModel(), +) { + val state by vm.state.collectAsState() + val lang = LocalLang.current + + var viewMenuOpen by remember { mutableStateOf(false) } + var showMenu by remember { mutableStateOf(false) } + var showFilter by remember { mutableStateOf(false) } + var detailEvent by remember { mutableStateOf(null) } + var editor by remember { mutableStateOf(null) } + var overlay by remember { mutableStateOf(Overlay.NONE) } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + titleForView(state.viewType, state.currentDate, lang), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = { showMenu = true }) { + Icon(Icons.Filled.Menu, contentDescription = tr("nav.menu")) + } + }, + actions = { + IconButton(onClick = vm::navigatePrev) { + Icon(Icons.Filled.ChevronLeft, contentDescription = null) + } + IconButton(onClick = vm::moveToToday) { + Icon(Icons.Filled.Today, contentDescription = tr("nav.today")) + } + IconButton(onClick = vm::navigateNext) { + Icon(Icons.Filled.ChevronRight, contentDescription = null) + } + IconButton(onClick = { showFilter = true }) { + Icon(Icons.Filled.FilterList, contentDescription = tr("filter.button")) + } + Box { + IconButton(onClick = { viewMenuOpen = true }) { + Icon(state.viewType.icon, contentDescription = tr("view.change")) + } + DropdownMenu(expanded = viewMenuOpen, onDismissRequest = { viewMenuOpen = false }) { + CalViewType.entries.forEach { type -> + DropdownMenuItem( + text = { Text(tr("view.${type.key}")) }, + leadingIcon = { Icon(type.icon, contentDescription = null) }, + onClick = { + viewMenuOpen = false + vm.setViewType(type) + }, + ) + } + } + } + }, + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { editor = EditorRequest(null, state.currentDate) }) { + Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event")) + } + }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + if (state.isLoading || state.isBackgroundCaching) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + } + state.error?.let { err -> + ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError) + } + Box(Modifier.fillMaxSize()) { + CalendarBody( + state = state, + vm = vm, + onEventClick = { detailEvent = it }, + onDayClick = { date -> vm.goToDate(date, CalViewType.DAY) }, + onEmptySlotClick = { date -> editor = EditorRequest(null, date) }, + ) + } + } + } + + // ---- Sheets ---- + + if (showMenu) { + MenuSheet( + isAdmin = false, + onDismiss = { showMenu = false }, + onProfile = { showMenu = false; overlay = Overlay.PROFILE }, + onAppearance = { showMenu = false; overlay = Overlay.SETTINGS }, + onAccounts = { showMenu = false; overlay = Overlay.ACCOUNTS }, + onSync = { showMenu = false; vm.syncWithServer() }, + onLogout = { showMenu = false; onLogout() }, + onSwitchServer = { showMenu = false; onSwitchServer() }, + ) + } + + if (showFilter) { + CalendarFilterSheet( + events = remember(state.events, state.hiddenKeys) { allKnownCalendars(vm) }, + vm = vm, + onDismiss = { showFilter = false }, + ) + } + + detailEvent?.let { ev -> + EventDetailSheet( + event = ev, + onDismiss = { detailEvent = null }, + onEdit = { + detailEvent = null + editor = EditorRequest(ev, localDate(ev.startDate)) + }, + onDelete = { + vm.deleteEvent(ev) {} + detailEvent = null + }, + ) + } + + editor?.let { req -> + EventEditorSheet( + request = req, + writableCalendars = state.writableCalendars, + onDismiss = { editor = null }, + onSave = { cal, title, start, end, allDay, location, desc, color -> + vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color) { error -> + if (error == null) editor = null + } + }, + ) + } + + when (overlay) { + Overlay.PROFILE -> ProfileScreen(onClose = { overlay = Overlay.NONE }) + Overlay.SETTINGS -> SettingsScreen( + onClose = { overlay = Overlay.NONE }, + onSettingsChanged = onSettingsChanged, + onSettingsSynced = onSettingsSynced, + ) + Overlay.ACCOUNTS -> AccountsScreen( + onClose = { overlay = Overlay.NONE }, + onChanged = { vm.loadWritableCalendars(); vm.syncWithServer() }, + ) + Overlay.NONE -> Unit + } +} + +@Composable +private fun CalendarBody( + state: CalendarUiState, + vm: CalendarViewModel, + onEventClick: (CalEvent) -> Unit, + onDayClick: (LocalDate) -> Unit, + onEmptySlotClick: (LocalDate) -> Unit, +) { + when (state.viewType) { + CalViewType.MONTH -> MonthView(state, vm, onDayClick, onEventClick) + CalViewType.WEEK -> WeekView(state, vm, onEventClick) + CalViewType.DAY -> DayView(state, vm, onEventClick) + CalViewType.QUARTER -> QuarterView(state, onDayClick) + CalViewType.AGENDA -> AgendaView(state, vm, onEventClick) + } +} + +@Composable +private fun ErrorBanner(message: String, onRetry: () -> Unit, onDismiss: () -> Unit) { + androidx.compose.material3.Surface( + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(12.dp)) { + Text(message, color = MaterialTheme.colorScheme.onErrorContainer, style = MaterialTheme.typography.bodySmall) + androidx.compose.foundation.layout.Row { + TextButton(onClick = onRetry) { Text(tr("common.retry")) } + TextButton(onClick = onDismiss) { Text(tr("common.close")) } + } + } + } +} + +@Composable +private fun loadingPlaceholder() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } +} + +/** Distinct calendars currently present in the cache, for the filter sheet. */ +private fun allKnownCalendars(vm: CalendarViewModel): List { + val st = vm.state.value + return st.events + .map { CalendarFilterEntry(calendarKey(it.source, it.calendarId), it.calendarName.ifBlank { it.source }, it.effectiveColor) } + .distinctBy { it.key } + .sortedBy { it.name.lowercase() } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt new file mode 100644 index 0000000..362ea93 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -0,0 +1,340 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.scarriffle.calendarr.data.CalendarRepository +import com.scarriffle.calendarr.data.SettingsStore +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.domain.model.CalViewType +import com.scarriffle.calendarr.domain.model.WritableCalendar +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import javax.inject.Inject + +/** Build the "source:id" visibility key, stripping any "-" prefix. */ +fun calendarKey(source: String, calendarId: String): String { + val prefix = "$source-" + val id = if (calendarId.startsWith(prefix)) calendarId.removePrefix(prefix) else calendarId + return "$source:$id" +} + +data class CalendarUiState( + val viewType: CalViewType = CalViewType.MONTH, + val currentDate: LocalDate = LocalDate.now(), + val events: List = emptyList(), + val isLoading: Boolean = false, + val isBackgroundCaching: Boolean = false, + val error: String? = null, + val weekStartsOnMonday: Boolean = true, + val writableCalendars: List = emptyList(), + val hiddenKeys: Set = emptySet(), + val banishedKeys: Set = emptySet(), +) + +@HiltViewModel +class CalendarViewModel @Inject constructor( + private val repository: CalendarRepository, + private val settingsStore: SettingsStore, +) : ViewModel() { + + private val zone: ZoneId = ZoneId.systemDefault() + + private val _state = MutableStateFlow(initialState()) + val state: StateFlow = _state.asStateFlow() + + // Cache bookkeeping + private var cachedStart: Instant? = null + private var cachedEnd: Instant? = null + private var allCachedEvents: List = emptyList() + + init { + loadVisible() + loadWritableCalendars() + prefetchBackground() + } + + private fun initialState(): CalendarUiState { + val s = settingsStore.loadSettings() + return CalendarUiState( + viewType = CalViewType.fromKey(s.defaultView), + weekStartsOnMonday = s.weekStartsOnMonday, + hiddenKeys = settingsStore.hiddenCalendarKeys, + banishedKeys = settingsStore.banishedCalendarKeys, + ) + } + + // ---- Navigation ---- + + fun setViewType(type: CalViewType) { + _state.update { it.copy(viewType = type) } + loadVisible() + } + + fun moveToToday() { + _state.update { it.copy(currentDate = LocalDate.now()) } + loadVisible() + } + + fun navigatePrev() = navigate(-1) + fun navigateNext() = navigate(+1) + + private fun navigate(direction: Int) { + val st = _state.value + val date = st.currentDate + val newDate = when (st.viewType) { + CalViewType.WEEK -> date.plusWeeks(direction.toLong()) + CalViewType.DAY -> date.plusDays(direction.toLong()) + CalViewType.QUARTER -> date.plusMonths((3 * direction).toLong()) + else -> date.plusMonths(direction.toLong()) + } + _state.update { it.copy(currentDate = newDate) } + loadVisible() + } + + fun goToDate(date: LocalDate, viewType: CalViewType? = null) { + _state.update { it.copy(currentDate = date, viewType = viewType ?: it.viewType) } + loadVisible() + } + + // ---- Range ---- + + fun rangeForCurrentView(): Pair { + val st = _state.value + val date = st.currentDate + return when (st.viewType) { + CalViewType.MONTH -> { + val start = date.withDayOfMonth(1) + instant(start.minusMonths(1)) to instant(start.plusMonths(2)) + } + CalViewType.QUARTER -> { + val start = date.withDayOfMonth(1) + instant(start) to instant(start.plusMonths(4)) + } + CalViewType.WEEK -> { + val weekStart = startOfWeek(date, st.weekStartsOnMonday) + instant(weekStart) to instant(weekStart.plusDays(8)) + } + CalViewType.DAY -> instant(date) to instant(date.plusDays(1)) + CalViewType.AGENDA -> { + val today = LocalDate.now() + instant(today) to instant(today.plusDays(90)) + } + } + } + + private fun instant(date: LocalDate): Instant = date.atStartOfDay(zone).toInstant() + + private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate { + val dow = date.dayOfWeek.value // Mon=1 .. Sun=7 + val offset = if (mondayFirst) dow - 1 else dow % 7 + return date.minusDays(offset.toLong()) + } + + // ---- Loading ---- + + fun loadVisible(force: Boolean = false) { + val (start, end) = rangeForCurrentView() + if (!force && isCached(start, end)) { + refreshFromCache() + return + } + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + runCatching { repository.fetchEvents(start, end) } + .onSuccess { fetched -> + mergeIntoCache(fetched, start, end) + refreshFromCache() + _state.update { it.copy(isLoading = false) } + } + .onFailure { e -> + _state.update { it.copy(isLoading = false, error = e.message) } + } + } + } + + private fun prefetchBackground() { + val months = settingsStore.cacheMonths + val today = LocalDate.now().withDayOfMonth(1) + val start = instant(today.minusMonths(months.toLong())) + val end = instant(today.plusMonths((months + 1).toLong())) + if (isCached(start, end)) return + viewModelScope.launch { + _state.update { it.copy(isBackgroundCaching = true) } + runCatching { repository.fetchEvents(start, end) } + .onSuccess { fetched -> + mergeIntoCache(fetched, start, end) + refreshFromCache() + } + _state.update { it.copy(isBackgroundCaching = false) } + } + } + + private fun isCached(start: Instant, end: Instant): Boolean { + val cs = cachedStart ?: return false + val ce = cachedEnd ?: return false + return !cs.isAfter(start) && !ce.isBefore(end) + } + + private fun mergeIntoCache(newEvents: List, rangeStart: Instant, rangeEnd: Instant) { + val retained = allCachedEvents.filter { + !it.startDate.isBefore(rangeEnd) || !it.endDate.isAfter(rangeStart) + } + allCachedEvents = retained + newEvents + cachedStart = cachedStart?.let { minOf(it, rangeStart) } ?: rangeStart + cachedEnd = cachedEnd?.let { maxOf(it, rangeEnd) } ?: rangeEnd + } + + private fun refreshFromCache() { + val hidden = _state.value.hiddenKeys + val banished = _state.value.banishedKeys + val visible = allCachedEvents.filter { ev -> + val key = calendarKey(ev.source, ev.calendarId) + key !in hidden && key !in banished + } + _state.update { it.copy(events = visible) } + } + + private fun invalidateCache() { + cachedStart = null + cachedEnd = null + allCachedEvents = emptyList() + } + + fun syncWithServer() { + invalidateCache() + loadVisible(force = true) + prefetchBackground() + } + + fun clearError() = _state.update { it.copy(error = null) } + + // ---- Visibility filters ---- + + fun setCalendarHidden(key: String, hidden: Boolean) { + val next = _state.value.hiddenKeys.toMutableSet().apply { + if (hidden) add(key) else remove(key) + } + settingsStore.hiddenCalendarKeys = next + _state.update { it.copy(hiddenKeys = next) } + refreshFromCache() + } + + fun setHiddenCalendars(keys: Set) { + settingsStore.hiddenCalendarKeys = keys + _state.update { it.copy(hiddenKeys = keys) } + refreshFromCache() + } + + fun setCalendarBanished(key: String, banished: Boolean) { + val nextBanished = _state.value.banishedKeys.toMutableSet().apply { + if (banished) add(key) else remove(key) + } + val nextHidden = _state.value.hiddenKeys.toMutableSet().apply { + if (banished) remove(key) + } + settingsStore.banishedCalendarKeys = nextBanished + settingsStore.hiddenCalendarKeys = nextHidden + _state.update { it.copy(banishedKeys = nextBanished, hiddenKeys = nextHidden) } + refreshFromCache() + } + + // ---- Writable calendars ---- + + fun loadWritableCalendars() { + viewModelScope.launch { + runCatching { repository.getWritableCalendars() } + .onSuccess { cals -> _state.update { it.copy(writableCalendars = cals) } } + } + } + + // ---- Event mutations ---- + + private fun afterMutation() { + invalidateCache() + loadVisible(force = true) + prefetchBackground() + } + + fun saveEvent( + calendar: WritableCalendar, + existing: CalEvent?, + title: String, + start: Instant, + end: Instant, + isAllDay: Boolean, + location: String, + description: String, + color: String?, + onResult: (String?) -> Unit, + ) { + viewModelScope.launch { + val result = runCatching { + if (existing != null && existing.source == calendar.source) { + when (existing.source) { + "local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color) + "caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color) + else -> createForSource(calendar, title, start, end, isAllDay, location, description, color) + } + } else { + createForSource(calendar, title, start, end, isAllDay, location, description, color) + } + } + result.onSuccess { afterMutation(); onResult(null) } + .onFailure { onResult(it.message ?: "Fehler") } + } + } + + private suspend fun createForSource( + calendar: WritableCalendar, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, color: String?, + ) { + when (calendar.source) { + "local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color) + "caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color) + "google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description) + "homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description) + } + } + + fun deleteEvent(event: CalEvent, onResult: (String?) -> Unit) { + viewModelScope.launch { + val result = runCatching { + when (event.source) { + "local" -> repository.deleteLocalEvent(event.id) + "caldav" -> repository.deleteCalDAVEvent(event.id, event.url, calendarNumericId(event)) + "homeassistant" -> repository.deleteHAEvent(calendarNumericId(event) ?: 0, event.id) + else -> Unit + } + } + result.onSuccess { + removeCachedEvent(event.id) + onResult(null) + }.onFailure { onResult(it.message ?: "Fehler") } + } + } + + private fun calendarNumericId(event: CalEvent): Int? { + val key = calendarKey(event.source, event.calendarId) + return key.substringAfter(":").toIntOrNull() + } + + private fun removeCachedEvent(id: String) { + allCachedEvents = allCachedEvents.filterNot { it.id == id } + refreshFromCache() + } + + /** Events overlapping a single day, sorted by start. */ + fun eventsOn(date: LocalDate, events: List): List { + val dayStart = instant(date) + val dayEnd = instant(date.plusDays(1)) + return events.filter { it.startDate.isBefore(dayEnd) && it.endDate.isAfter(dayStart) } + .sortedBy { it.startDate } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt new file mode 100644 index 0000000..2690fa7 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt @@ -0,0 +1,166 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +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.sp +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.ui.LocalAppSettings +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.contrastingTextColor +import java.time.LocalDate +import java.time.temporal.IsoFields + +@Composable +fun MonthView( + state: CalendarUiState, + vm: CalendarViewModel, + onDayClick: (LocalDate) -> Unit, + onEventClick: (CalEvent) -> Unit, +) { + val lang = LocalLang.current + val mondayFirst = state.weekStartsOnMonday + val today = LocalDate.now() + val firstOfMonth = state.currentDate.withDayOfMonth(1) + val firstVisible = startOfWeekFor(firstOfMonth, mondayFirst) + val weeks = (0 until 6).map { w -> (0 until 7).map { d -> firstVisible.plusDays((w * 7 + d).toLong()) } } + + Column(Modifier.fillMaxSize()) { + // Header: CW + weekdays + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Box(Modifier.width(28.dp)) + weekdayLabels(mondayFirst, lang).forEach { label -> + Text( + label, + modifier = Modifier.weight(1f), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + weeks.forEach { week -> + Row(Modifier.fillMaxWidth().weight(1f)) { + val cw = week.first().get(IsoFields.WEEK_OF_WEEK_BASED_YEAR) + Box(Modifier.width(28.dp).fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Text( + "$cw", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + fontSize = 9.sp, + modifier = Modifier.padding(top = 4.dp), + ) + } + week.forEach { day -> + DayCell( + day = day, + inMonth = day.month == firstOfMonth.month, + isToday = day == today, + events = vm.eventsOn(day, state.events), + onClick = { onDayClick(day) }, + onEventClick = onEventClick, + modifier = Modifier.weight(1f).fillMaxSize(), + ) + } + } + } + } +} + +@Composable +private fun DayCell( + day: LocalDate, + inMonth: Boolean, + isToday: Boolean, + events: List, + onClick: () -> Unit, + onEventClick: (CalEvent) -> Unit, + modifier: Modifier = Modifier, +) { + val todayColor = colorFromHex(LocalAppSettings.current.todayColor) + Column( + modifier = modifier + .padding(1.dp) + .clickable(onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(top = 2.dp)) { + if (isToday) { + Box( + Modifier + .height(22.dp).width(22.dp) + .clip(RoundedCornerShape(11.dp)) + .background(todayColor), + ) + } + Text( + "${day.dayOfMonth}", + style = MaterialTheme.typography.labelMedium, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, + color = when { + isToday -> todayColor.contrastingTextColor() + inMonth -> MaterialTheme.colorScheme.onBackground + else -> MaterialTheme.colorScheme.outline + }, + ) + } + events.take(3).forEach { ev -> + EventChip(ev, onClick = { onEventClick(ev) }) + } + if (events.size > 3) { + Text( + "+${events.size - 3}", + style = MaterialTheme.typography.labelSmall, + fontSize = 8.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun EventChip(event: CalEvent, onClick: () -> Unit) { + val color = colorFromHex(event.effectiveColor) + Box( + Modifier + .fillMaxWidth() + .padding(horizontal = 1.dp, vertical = 1.dp) + .clip(RoundedCornerShape(3.dp)) + .background(color) + .clickable(onClick = onClick) + .padding(horizontal = 3.dp, vertical = 1.dp), + ) { + Text( + event.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 9.sp, + color = color.contrastingTextColor(), + ) + } +} + +private fun startOfWeekFor(date: LocalDate, mondayFirst: Boolean): LocalDate { + val dow = date.dayOfWeek.value + val offset = if (mondayFirst) dow - 1 else dow % 7 + return date.minusDays(offset.toLong()) +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/QuarterView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/QuarterView.kt new file mode 100644 index 0000000..e6387b3 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/QuarterView.kt @@ -0,0 +1,104 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.scarriffle.calendarr.ui.LocalAppSettings +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.contrastingTextColor +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@Composable +fun QuarterView(state: CalendarUiState, onDayClick: (LocalDate) -> Unit) { + val lang = LocalLang.current + val months = (0 until 3).map { state.currentDate.withDayOfMonth(1).plusMonths(it.toLong()) } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp)) { + months.forEach { month -> + MiniMonth(month, state, lang, onDayClick) + } + } +} + +@Composable +private fun MiniMonth(month: LocalDate, state: CalendarUiState, lang: String, onDayClick: (LocalDate) -> Unit) { + val mondayFirst = state.weekStartsOnMonday + val today = LocalDate.now() + val titleFmt = DateTimeFormatter.ofPattern("LLLL yyyy", com.scarriffle.calendarr.ui.L10n.locale(lang)) + val dow = month.dayOfWeek.value + val offset = if (mondayFirst) dow - 1 else dow % 7 + val firstVisible = month.minusDays(offset.toLong()) + val cells = (0 until 42).map { firstVisible.plusDays(it.toLong()) } + + Column(Modifier.padding(bottom = 16.dp)) { + Text( + titleFmt.format(month).replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 4.dp), + ) + Row(Modifier.fillMaxWidth()) { + weekdayLabels(mondayFirst, lang).forEach { + Text(it, Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 9.sp, color = MaterialTheme.colorScheme.outline) + } + } + cells.chunked(7).forEach { week -> + Row(Modifier.fillMaxWidth()) { + week.forEach { day -> + val hasEvents = state.events.any { + localDate(it.startDate) <= day && localDate(it.endDate) >= day + } + val firstColor = state.events.firstOrNull { + localDate(it.startDate) <= day && localDate(it.endDate) >= day + }?.effectiveColor + Box( + Modifier.weight(1f).aspectRatio(1f).clickable { onDayClick(day) }, + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + val isToday = day == today + Box(contentAlignment = Alignment.Center) { + if (isToday) { + Box( + Modifier.clip(CircleShape) + .background(colorFromHex(LocalAppSettings.current.todayColor)) + .padding(horizontal = 5.dp, vertical = 2.dp), + ) { Text("${day.dayOfMonth}", fontSize = 10.sp, color = colorFromHex(LocalAppSettings.current.todayColor).contrastingTextColor()) } + } else { + Text( + "${day.dayOfMonth}", + fontSize = 10.sp, + color = if (day.month == month.month) MaterialTheme.colorScheme.onBackground else MaterialTheme.colorScheme.outline, + ) + } + } + if (hasEvents) { + Box(Modifier.padding(top = 1.dp).clip(CircleShape).background(colorFromHex(firstColor)).padding(2.dp)) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt new file mode 100644 index 0000000..3ea85bb --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt @@ -0,0 +1,219 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.layout +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.ui.LocalAppSettings +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.contrastingTextColor +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +private val GUTTER = 48.dp + +@Composable +fun TimeGridView( + days: List, + state: CalendarUiState, + vm: CalendarViewModel, + onEventClick: (CalEvent) -> Unit, +) { + val hourHeight = LocalAppSettings.current.hourHeight.coerceIn(28, 100).dp + val lang = LocalLang.current + val today = LocalDate.now() + + Column(Modifier.fillMaxSize()) { + // Day headers (only for multi-day / week view) + if (days.size > 1) { + Row(Modifier.fillMaxWidth()) { + Box(Modifier.width(GUTTER)) + val dayFmt = DateTimeFormatter.ofPattern("EEE d", com.scarriffle.calendarr.ui.L10n.locale(lang)) + days.forEach { day -> + Text( + dayFmt.format(day), + modifier = Modifier.weight(1f).padding(vertical = 4.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelSmall, + fontWeight = if (day == today) FontWeight.Bold else FontWeight.Normal, + color = if (day == today) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // All-day row + val allDay = days.map { d -> d to vm.eventsOn(d, state.events).filter { it.isAllDay } } + if (allDay.any { it.second.isNotEmpty() }) { + Row(Modifier.fillMaxWidth().padding(bottom = 2.dp)) { + Box(Modifier.width(GUTTER), contentAlignment = Alignment.Center) { + Text(com.scarriffle.calendarr.ui.tr("cal.allday"), fontSize = 8.sp, color = MaterialTheme.colorScheme.outline) + } + allDay.forEach { (_, evs) -> + Column(Modifier.weight(1f).padding(horizontal = 1.dp)) { + evs.forEach { ev -> AllDayChip(ev, onClick = { onEventClick(ev) }) } + } + } + } + Divider() + } + + // Scrollable hour grid + Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + Box(Modifier.fillMaxWidth().height(hourHeight * 24)) { + // Hour lines + labels + for (h in 0..24) { + val y = hourHeight * h.toFloat() + Divider( + modifier = Modifier + .fillMaxWidth() + .padding(start = GUTTER) + .offset(y = y), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + ) + if (h < 24) { + Text( + "%02d:00".format(h), + modifier = Modifier.width(GUTTER).offset(y = y + 2.dp).padding(end = 4.dp), + textAlign = TextAlign.End, + fontSize = 9.sp, + color = MaterialTheme.colorScheme.outline, + ) + } + } + + // Timed events per day column + days.forEachIndexed { index, day -> + val timed = vm.eventsOn(day, state.events).filter { !it.isAllDay } + timed.forEach { ev -> + TimedEvent( + event = ev, + day = day, + index = index, + dayCount = days.size, + hourHeight = hourHeight, + onClick = { onEventClick(ev) }, + ) + } + } + } + } + } +} + +@Composable +private fun TimedEvent( + event: CalEvent, + day: LocalDate, + index: Int, + dayCount: Int, + hourHeight: androidx.compose.ui.unit.Dp, + onClick: () -> Unit, +) { + val startMin = if (localDate(event.startDate).isBefore(day)) 0 else minutesOfDay(event.startDate) + val endMin = if (localDate(event.endDate).isAfter(day)) 24 * 60 else minutesOfDay(event.endDate) + val duration = (endMin - startMin).coerceAtLeast(30) + val top = hourHeight * (startMin / 60f) + val height = (hourHeight * (duration / 60f)) + val color = colorFromHex(event.effectiveColor) + val lang = LocalLang.current + + Box( + modifier = Modifier + .offset(y = top) + .columnSlot(index, dayCount) + .height(height) + .padding(horizontal = 1.dp, vertical = 0.5.dp) + .clip(RoundedCornerShape(4.dp)) + .background(color) + .clickable(onClick = onClick) + .padding(horizontal = 4.dp, vertical = 2.dp), + ) { + Column { + Text( + event.title, + fontSize = 10.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = color.contrastingTextColor(), + fontWeight = FontWeight.Medium, + ) + if (height > 36.dp) { + Text( + eventTimeRange(event, lang), + fontSize = 8.sp, + color = color.contrastingTextColor().copy(alpha = 0.85f), + ) + } + } + } +} + +/** Position a child in column [index] of [dayCount] columns, after the gutter. */ +private fun Modifier.columnSlot(index: Int, dayCount: Int): Modifier = layout { measurable, constraints -> + val gutterPx = GUTTER.roundToPx() + val available = (constraints.maxWidth - gutterPx).coerceAtLeast(0) + val colWidth = available / dayCount + val placeable = measurable.measure( + constraints.copy(minWidth = colWidth, maxWidth = colWidth) + ) + layout(constraints.maxWidth, placeable.height) { + placeable.place(gutterPx + colWidth * index, 0) + } +} + +@Composable +private fun AllDayChip(event: CalEvent, onClick: () -> Unit) { + val color = colorFromHex(event.effectiveColor) + Box( + Modifier + .fillMaxWidth() + .padding(vertical = 1.dp) + .clip(RoundedCornerShape(3.dp)) + .background(color) + .clickable(onClick = onClick) + .padding(horizontal = 3.dp, vertical = 1.dp), + ) { + Text(event.title, fontSize = 9.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, color = color.contrastingTextColor()) + } +} + +@Composable +fun DayView(state: CalendarUiState, vm: CalendarViewModel, onEventClick: (CalEvent) -> Unit) { + TimeGridView(listOf(state.currentDate), state, vm, onEventClick) +} + +@Composable +fun WeekView(state: CalendarUiState, vm: CalendarViewModel, onEventClick: (CalEvent) -> Unit) { + val mondayFirst = state.weekStartsOnMonday + val dow = state.currentDate.dayOfWeek.value + val offset = if (mondayFirst) dow - 1 else dow % 7 + val weekStart = state.currentDate.minusDays(offset.toLong()) + val days = (0 until 7).map { weekStart.plusDays(it.toLong()) } + TimeGridView(days, state, vm, onEventClick) +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt new file mode 100644 index 0000000..0d9532f --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt @@ -0,0 +1,123 @@ +package com.scarriffle.calendarr.ui.event + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Notes +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.calendar.eventDateRange +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventDetailSheet( + event: CalEvent, + onDismiss: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + val lang = LocalLang.current + var confirmDelete by remember { mutableStateOf(false) } + val canEdit = event.source == "local" || event.source == "caldav" + val canDelete = event.source == "local" || event.source == "caldav" || event.source == "homeassistant" + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(16.dp).clip(CircleShape).background(colorFromHex(event.effectiveColor))) + Spacer(Modifier.size(10.dp)) + Text( + event.title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + } + Spacer(Modifier.size(16.dp)) + DetailRow(Icons.Filled.Schedule, eventDateRange(event, lang)) + if (event.calendarName.isNotBlank()) { + DetailRow(Icons.Filled.Notes, event.calendarName) + } + if (event.location.isNotBlank()) { + DetailRow(Icons.Filled.LocationOn, event.location) + } + if (event.notes.isNotBlank()) { + DetailRow(Icons.Filled.Notes, event.notes) + } + Spacer(Modifier.size(20.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (canEdit) { + OutlinedButton(onClick = onEdit) { + Icon(Icons.Filled.Edit, contentDescription = null) + Spacer(Modifier.size(6.dp)) + Text(tr("event.edit_title")) + } + } + if (canDelete) { + OutlinedButton(onClick = { confirmDelete = true }) { + Icon(Icons.Filled.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) + Spacer(Modifier.size(6.dp)) + Text(tr("common.delete"), color = MaterialTheme.colorScheme.error) + } + } + } + } + } + + if (confirmDelete) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + title = { Text(tr("common.delete")) }, + text = { Text(tr("event.delete_confirm")) }, + confirmButton = { + TextButton(onClick = { confirmDelete = false; onDelete() }) { + Text(tr("common.delete"), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { confirmDelete = false }) { Text(tr("common.cancel")) } + }, + ) + } +} + +@Composable +private fun DetailRow(icon: ImageVector, text: String) { + Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.Top) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp)) + Spacer(Modifier.size(12.dp)) + Text(text, style = MaterialTheme.typography.bodyLarge) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt new file mode 100644 index 0000000..dee00e3 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt @@ -0,0 +1,275 @@ +package com.scarriffle.calendarr.ui.event + +import android.app.DatePickerDialog +import android.app.TimePickerDialog +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.domain.model.WritableCalendar +import com.scarriffle.calendarr.ui.L10n +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.calendar.EditorRequest +import com.scarriffle.calendarr.ui.calendar.calendarKey +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +private val PRESET_COLORS = listOf( + "#4285f4", "#ea4335", "#34a853", "#fbbc05", + "#46bdc6", "#9c27b0", "#ff7043", "#7090c0", +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventEditorSheet( + request: EditorRequest, + writableCalendars: List, + onDismiss: () -> Unit, + onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?) -> Unit, +) { + val zone = ZoneId.systemDefault() + val context = LocalContext.current + val lang = LocalLang.current + val existing = request.existing + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var title by remember { mutableStateOf(existing?.title ?: "") } + var allDay by remember { mutableStateOf(existing?.isAllDay ?: false) } + var location by remember { mutableStateOf(existing?.location ?: "") } + var description by remember { mutableStateOf(existing?.notes ?: "") } + var color by remember { mutableStateOf(existing?.color) } + + val initialStart = existing?.startDate + val initialEnd = existing?.endDate + var startDate by remember { + mutableStateOf(initialStart?.let { LocalDate.ofInstant(it, zone) } ?: request.date) + } + var startTime by remember { + mutableStateOf(initialStart?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: LocalTime.of(9, 0)) + } + var endDate by remember { + mutableStateOf( + initialEnd?.let { + val d = LocalDate.ofInstant(it, zone) + if (existing?.isAllDay == true) d.minusDays(1) else d + } ?: request.date + ) + } + var endTime by remember { + mutableStateOf(initialEnd?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: LocalTime.of(10, 0)) + } + + val preselected = existing?.let { ev -> + val id = calendarKey(ev.source, ev.calendarId).substringAfter(":").toIntOrNull() + writableCalendars.firstOrNull { it.source == ev.source && it.numericId == id } + } + var calendar by remember { mutableStateOf(preselected ?: writableCalendars.firstOrNull()) } + var calMenuOpen by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + val dateFmt = DateTimeFormatter.ofPattern("EEE, d. MMM yyyy") + val timeFmt = DateTimeFormatter.ofPattern("HH:mm") + + fun pickDate(current: LocalDate, onPicked: (LocalDate) -> Unit) { + DatePickerDialog( + context, + { _, y, m, d -> onPicked(LocalDate.of(y, m + 1, d)) }, + current.year, current.monthValue - 1, current.dayOfMonth, + ).show() + } + + fun pickTime(current: LocalTime, onPicked: (LocalTime) -> Unit) { + TimePickerDialog( + context, + { _, h, min -> onPicked(LocalTime.of(h, min)) }, + current.hour, current.minute, true, + ).show() + } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .padding(bottom = 32.dp), + ) { + Text( + if (existing == null) tr("event.new_title") else tr("event.edit_title"), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(vertical = 8.dp), + ) + + OutlinedTextField( + value = title, + onValueChange = { title = it; error = null }, + label = { Text(tr("event.title_placeholder")) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(12.dp)) + + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(tr("event.allday"), style = MaterialTheme.typography.bodyLarge) + Switch(checked = allDay, onCheckedChange = { allDay = it }) + } + Spacer(Modifier.size(8.dp)) + + // Start + Text(tr("event.start"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { pickDate(startDate) { startDate = it; if (endDate.isBefore(it)) endDate = it } }) { + Text(dateFmt.format(startDate)) + } + if (!allDay) { + OutlinedButton(onClick = { pickTime(startTime) { startTime = it } }) { Text(timeFmt.format(startTime)) } + } + } + Spacer(Modifier.size(8.dp)) + + // End + Text(tr("event.end"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { pickDate(endDate) { endDate = it } }) { Text(dateFmt.format(endDate)) } + if (!allDay) { + OutlinedButton(onClick = { pickTime(endTime) { endTime = it } }) { Text(timeFmt.format(endTime)) } + } + } + Spacer(Modifier.size(12.dp)) + + OutlinedTextField( + value = location, + onValueChange = { location = it }, + label = { Text(tr("event.location")) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(12.dp)) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text(tr("event.description")) }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(12.dp)) + + // Calendar picker + Text(tr("event.calendar_section"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (writableCalendars.isEmpty()) { + Text(tr("event.no_writable"), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } else { + Box { + OutlinedButton(onClick = { calMenuOpen = true }, modifier = Modifier.fillMaxWidth()) { + Box(Modifier.size(12.dp).clip(CircleShape).background(colorFromHex(calendar?.color))) + Spacer(Modifier.size(8.dp)) + Text(calendar?.name ?: tr("event.calendar_picker"), modifier = Modifier.weight(1f)) + Icon(Icons.Filled.ArrowDropDown, contentDescription = null) + } + DropdownMenu(expanded = calMenuOpen, onDismissRequest = { calMenuOpen = false }) { + writableCalendars.forEach { c -> + DropdownMenuItem( + text = { Text(c.name) }, + leadingIcon = { Box(Modifier.size(12.dp).clip(CircleShape).background(colorFromHex(c.color))) }, + onClick = { calendar = c; calMenuOpen = false }, + ) + } + } + } + } + Spacer(Modifier.size(12.dp)) + + // Color + Text(tr("event.color_section"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(Modifier.padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PRESET_COLORS.forEach { hex -> + val selected = color?.equals(hex, ignoreCase = true) == true + Box( + Modifier + .size(28.dp) + .clip(CircleShape) + .background(colorFromHex(hex)) + .then(if (selected) Modifier.border(2.dp, Color.White, CircleShape) else Modifier) + .clickable { color = hex }, + contentAlignment = Alignment.Center, + ) { + if (selected) Icon(Icons.Filled.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp)) + } + } + } + if (color != null) { + androidx.compose.material3.TextButton(onClick = { color = null }) { Text(tr("event.reset_color")) } + } + + error?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 8.dp)) + } + Spacer(Modifier.size(16.dp)) + Button( + onClick = { + val cal = calendar + if (title.isBlank()) { error = L10n.t("event.title_placeholder", lang); return@Button } + if (cal == null) { error = L10n.t("event.no_writable", lang); return@Button } + val start: Instant + val end: Instant + if (allDay) { + start = startDate.atStartOfDay(zone).toInstant() + end = endDate.plusDays(1).atStartOfDay(zone).toInstant() + } else { + start = startDate.atTime(startTime).atZone(zone).toInstant() + end = endDate.atTime(endTime).atZone(zone).toInstant() + } + onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color) + }, + enabled = writableCalendars.isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (existing == null) tr("event.add") else tr("event.save")) + } + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt new file mode 100644 index 0000000..2f05481 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt @@ -0,0 +1,74 @@ +package com.scarriffle.calendarr.ui.menu + +import androidx.compose.foundation.clickable +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.ui.tr + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MenuSheet( + isAdmin: Boolean, + onDismiss: () -> Unit, + onProfile: () -> Unit, + onAppearance: () -> Unit, + onAccounts: () -> Unit, + onSync: () -> Unit, + onLogout: () -> Unit, + onSwitchServer: () -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(Modifier.fillMaxWidth().padding(bottom = 24.dp)) { + Text( + "Calendarr", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + MenuRow(Icons.Filled.AccountCircle, tr("menu.profile"), onProfile) + MenuRow(Icons.Filled.Palette, tr("menu.appearance"), onAppearance) + MenuRow(Icons.Filled.CalendarMonth, tr("menu.accounts"), onAccounts) + Divider(Modifier.padding(vertical = 4.dp)) + MenuRow(Icons.Filled.Sync, tr("menu.sync"), onSync) + Divider(Modifier.padding(vertical = 4.dp)) + MenuRow(Icons.Filled.Logout, tr("menu.logout"), onLogout) + MenuRow(Icons.Filled.Dns, tr("server.switch"), onSwitchServer) + } + } +} + +@Composable +private fun MenuRow(icon: ImageVector, label: String, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(18.dp)) + Text(label, style = MaterialTheme.typography.bodyLarge) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileScreen.kt new file mode 100644 index 0000000..80d8061 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileScreen.kt @@ -0,0 +1,163 @@ +package com.scarriffle.calendarr.ui.profile + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.ui.tr + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileScreen(onClose: () -> Unit, vm: ProfileViewModel = hiltViewModel()) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(tr("profile.title")) }, + navigationIcon = { + IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) } + }, + ) + }, + ) { padding -> + if (vm.loading) { + Column(Modifier.fillMaxSize().padding(padding), verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + } + return@Scaffold + } + Column( + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(20.dp), + ) { + val p = vm.profile + SectionTitle(tr("profile.account")) + InfoRow(tr("profile.username"), p?.username ?: "—") + InfoRow(tr("profile.role"), if (p?.isAdmin == true) tr("profile.role.admin") else tr("profile.role.user")) + Spacer(Modifier.size(20.dp)) + + SectionTitle(tr("profile.email")) + OutlinedTextField( + value = vm.email, + onValueChange = vm::onEmailChange, + label = { Text(tr("profile.email")) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(8.dp)) + Button(onClick = vm::saveEmail) { Text(tr("profile.save_email")) } + + Spacer(Modifier.size(24.dp)) + Divider() + Spacer(Modifier.size(16.dp)) + PasswordSection(vm) + + Spacer(Modifier.size(24.dp)) + Divider() + Spacer(Modifier.size(16.dp)) + TwoFactorSection(vm) + + vm.message?.let { + Spacer(Modifier.size(16.dp)) + Text(it, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall) + } + } + } + } +} + +@Composable +private fun PasswordSection(vm: ProfileViewModel) { + val mismatchMsg = tr("profile.password_mismatch") + var current by remember { mutableStateOf("") } + var new1 by remember { mutableStateOf("") } + var new2 by remember { mutableStateOf("") } + var localError by remember { mutableStateOf(null) } + + SectionTitle(tr("profile.change_password")) + OutlinedTextField(value = current, onValueChange = { current = it }, label = { Text(tr("profile.current_password")) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(value = new1, onValueChange = { new1 = it }, label = { Text(tr("profile.new_password")) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(value = new2, onValueChange = { new2 = it }, label = { Text(tr("profile.new_password_repeat")) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + localError?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + Spacer(Modifier.size(8.dp)) + Button(onClick = { + if (new1 != new2) { localError = mismatchMsg; return@Button } + localError = null + vm.changePassword(current, new1) { ok -> if (ok) { current = ""; new1 = ""; new2 = "" } } + }) { Text(tr("profile.change_password")) } +} + +@Composable +private fun TwoFactorSection(vm: ProfileViewModel) { + val enabled = vm.profile?.totpEnabled == true + var disablePw by remember { mutableStateOf("") } + SectionTitle(tr("profile.twofa")) + Text(if (enabled) tr("profile.twofa.active") else tr("profile.twofa.inactive"), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.size(8.dp)) + if (enabled) { + OutlinedTextField(value = disablePw, onValueChange = { disablePw = it }, label = { Text(tr("twofa.password_placeholder")) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedButton(onClick = { vm.disable2fa(disablePw); disablePw = "" }) { Text(tr("profile.twofa.disable")) } + } else { + val setup = vm.totpSetup + if (setup == null) { + Button(onClick = vm::begin2fa) { Text(tr("profile.twofa.enable")) } + } else { + Text(tr("twofa.scan_hint"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.size(8.dp)) + Text(setup.secret, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(value = vm.totpCode, onValueChange = vm::onTotpChange, label = { Text(tr("twofa.code_placeholder")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) { + Button(onClick = vm::confirm2fa) { Text(tr("twofa.activate")) } + OutlinedButton(onClick = vm::cancel2fa) { Text(tr("common.cancel")) } + } + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text(text, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp)) +} + +@Composable +private fun InfoRow(label: String, value: String) { + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text(label, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, fontWeight = FontWeight.Medium) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileViewModel.kt new file mode 100644 index 0000000..4e3e084 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileViewModel.kt @@ -0,0 +1,91 @@ +package com.scarriffle.calendarr.ui.profile + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.scarriffle.calendarr.data.CalendarRepository +import com.scarriffle.calendarr.data.TotpSetup +import com.scarriffle.calendarr.domain.model.UserProfile +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val repository: CalendarRepository, +) : ViewModel() { + + var profile by mutableStateOf(null) + private set + var email by mutableStateOf("") + private set + var loading by mutableStateOf(true) + private set + var message by mutableStateOf(null) + private set + + // 2FA enrolment + var totpSetup by mutableStateOf(null) + private set + var totpCode by mutableStateOf("") + private set + + init { load() } + + fun load() { + viewModelScope.launch { + loading = true + runCatching { repository.getProfile() } + .onSuccess { p -> profile = p; email = p.email ?: "" } + .onFailure { message = it.message } + loading = false + } + } + + fun onEmailChange(v: String) { email = v } + fun onTotpChange(v: String) { totpCode = v } + + fun saveEmail() { + viewModelScope.launch { + runCatching { repository.updateEmail(email.trim()) } + .onSuccess { message = "✓"; load() } + .onFailure { message = it.message } + } + } + + fun changePassword(current: String, new: String, onDone: (Boolean) -> Unit) { + viewModelScope.launch { + runCatching { repository.changePassword(current, new) } + .onSuccess { message = "✓"; onDone(true) } + .onFailure { message = it.message; onDone(false) } + } + } + + fun begin2fa() { + viewModelScope.launch { + runCatching { repository.setup2fa() } + .onSuccess { totpSetup = it } + .onFailure { message = it.message } + } + } + + fun confirm2fa() { + viewModelScope.launch { + runCatching { repository.enable2fa(totpCode.trim()) } + .onSuccess { totpSetup = null; totpCode = ""; load() } + .onFailure { message = it.message } + } + } + + fun disable2fa(password: String) { + viewModelScope.launch { + runCatching { repository.disable2fa(password) } + .onSuccess { load() } + .onFailure { message = it.message } + } + } + + fun cancel2fa() { totpSetup = null; totpCode = "" } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..6478841 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt @@ -0,0 +1,190 @@ +package com.scarriffle.calendarr.ui.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.domain.model.AppSettings +import com.scarriffle.calendarr.domain.model.CalViewType +import com.scarriffle.calendarr.ui.LocalAppSettings +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex + +private val PALETTE = listOf( + "#4285f4", "#ea4335", "#34a853", "#fbbc05", "#46bdc6", "#9c27b0", "#ff7043", "#7090c0", +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onClose: () -> Unit, + onSettingsChanged: (AppSettings) -> Unit, + onSettingsSynced: () -> Unit, + vm: SettingsViewModel = hiltViewModel(), +) { + val initialSettings = LocalAppSettings.current + var settings by remember { mutableStateOf(initialSettings) } + var cacheMonths by remember { mutableStateOf(vm.cacheMonths) } + + fun update(newSettings: AppSettings) { + settings = newSettings + onSettingsChanged(newSettings) + vm.apply(newSettings, onSettingsSynced) + } + + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(tr("settings.title")) }, + navigationIcon = { + IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) } + }, + ) + }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) { + + Section(tr("settings.calview")) + ChipRow( + options = CalViewType.entries.map { it.key to tr("view.${it.key}") }, + selected = settings.defaultView, + onSelect = { update(settings.copy(defaultView = it)) }, + ) + Spacer(Modifier.size(16.dp)) + + Section(tr("settings.firstweekday")) + ChipRow( + options = listOf("monday" to tr("settings.monday"), "sunday" to tr("settings.sunday")), + selected = settings.weekStartDay, + onSelect = { update(settings.copy(weekStartDay = it)) }, + ) + Spacer(Modifier.size(16.dp)) + + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(tr("settings.dimpast"), style = MaterialTheme.typography.bodyLarge) + Switch(checked = settings.dimPastEvents, onCheckedChange = { update(settings.copy(dimPastEvents = it)) }) + } + Divider(Modifier.padding(vertical = 16.dp)) + + Section(tr("settings.language")) + ChipRow( + options = listOf("system" to tr("lang.system"), "de" to tr("lang.german"), "en" to tr("lang.english")), + selected = settings.language, + onSelect = { update(settings.copy(language = it)) }, + ) + Divider(Modifier.padding(vertical = 16.dp)) + + Section(tr("settings.colors")) + ColorChooser(tr("settings.color.primary"), settings.primaryColor) { update(settings.copy(primaryColor = it)) } + ColorChooser(tr("settings.color.accent"), settings.accentColor) { update(settings.copy(accentColor = it)) } + ColorChooser(tr("settings.color.today"), settings.todayColor) { update(settings.copy(todayColor = it)) } + Divider(Modifier.padding(vertical = 16.dp)) + + Section(tr("settings.hourheight")) + Text("${settings.hourHeight} dp", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Slider( + value = settings.hourHeight.toFloat(), + onValueChange = { settings = settings.copy(hourHeight = it.toInt()) }, + onValueChangeFinished = { update(settings) }, + valueRange = 28f..100f, + ) + Spacer(Modifier.size(8.dp)) + + Section(tr("settings.textcontrast")) + ContrastStepper(settings.textContrast) { update(settings.copy(textContrast = it)) } + Section(tr("settings.linecontrast")) + ContrastStepper(settings.lineContrast) { update(settings.copy(lineContrast = it)) } + Divider(Modifier.padding(vertical = 16.dp)) + + Section(tr("settings.cache.title")) + ChipRow( + options = listOf("1" to tr("settings.cache.1m"), "3" to tr("settings.cache.3m"), "6" to tr("settings.cache.6m"), "12" to tr("settings.cache.1y")), + selected = cacheMonths.toString(), + onSelect = { cacheMonths = it.toInt(); vm.cacheMonths = it.toInt() }, + ) + Spacer(Modifier.size(40.dp)) + } + } + } +} + +@Composable +private fun Section(title: String) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp)) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChipRow(options: List>, selected: String, onSelect: (String) -> Unit) { + Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + options.forEach { (key, label) -> + FilterChip(selected = key == selected, onClick = { onSelect(key) }, label = { Text(label) }) + } + } +} + +@Composable +private fun ColorChooser(label: String, current: String, onPick: (String) -> Unit) { + Column(Modifier.padding(vertical = 6.dp)) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PALETTE.forEach { hex -> + val selected = hex.equals(current, ignoreCase = true) + Box( + Modifier.size(28.dp).clip(CircleShape).background(colorFromHex(hex)) + .then(if (selected) Modifier.border(2.dp, Color.White, CircleShape) else Modifier) + .clickable { onPick(hex) }, + ) + } + } + } +} + +@Composable +private fun ContrastStepper(value: Int, onChange: (Int) -> Unit) { + Slider( + value = value.toFloat(), + onValueChange = { onChange(it.toInt().coerceIn(1, 4)) }, + valueRange = 1f..4f, + steps = 2, + ) +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..18f507d --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt @@ -0,0 +1,30 @@ +package com.scarriffle.calendarr.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.scarriffle.calendarr.data.CalendarRepository +import com.scarriffle.calendarr.data.SettingsStore +import com.scarriffle.calendarr.domain.model.AppSettings +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val repository: CalendarRepository, + private val settingsStore: SettingsStore, +) : ViewModel() { + + /** Persist locally immediately, then sync to the server in the background. */ + fun apply(settings: AppSettings, onSynced: () -> Unit) { + settingsStore.saveSettings(settings) + viewModelScope.launch { + runCatching { repository.updateSettings(settings) } + onSynced() + } + } + + var cacheMonths: Int + get() = settingsStore.cacheMonths + set(value) { settingsStore.cacheMonths = value } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt b/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt index 6cc1e7b..7a5057e 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt @@ -1,35 +1,44 @@ package com.scarriffle.calendarr.ui.theme -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.scarriffle.calendarr.domain.model.AppSettings +import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.contrastingTextColor -private val LightColors = lightColorScheme( - primary = md_theme_light_primary, - onPrimary = md_theme_light_onPrimary, - background = md_theme_light_background, - onBackground = md_theme_light_onBackground, -) - -private val DarkColors = darkColorScheme( - primary = md_theme_dark_primary, - onPrimary = md_theme_dark_onPrimary, - background = md_theme_dark_background, - onBackground = md_theme_dark_onBackground, -) - +/** + * The Calendarr UI is a dark theme whose accent colours follow the user's + * synced [AppSettings] (mirrors the iOS appearance settings). + */ @Composable fun CalendarrTheme( - darkTheme: Boolean = isSystemInDarkTheme(), + settings: AppSettings = AppSettings(), content: @Composable () -> Unit, ) { - val colors = if (darkTheme) DarkColors else LightColors + val primary = colorFromHex(settings.primaryColor, Color(0xFF4285F4)) + val accent = colorFromHex(settings.accentColor, Color(0xFFEA4335)) + + val colors = darkColorScheme( + primary = primary, + onPrimary = primary.contrastingTextColor(), + secondary = accent, + onSecondary = accent.contrastingTextColor(), + tertiary = accent, + background = Color(0xFF000000), + onBackground = Color(0xFFF2F2F7), + surface = Color(0xFF1C1C1E), + onSurface = Color(0xFFF2F2F7), + surfaceVariant = Color(0xFF2C2C2E), + onSurfaceVariant = Color(0xFFBEBEC4), + outline = Color(0xFF3A3A3C), + ) MaterialTheme( colorScheme = colors, - typography = androidx.compose.material3.Typography(), + typography = Typography(), content = content, ) }