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 <noreply@anthropic.com>
This commit is contained in:
@@ -87,7 +87,7 @@ dependencies {
|
|||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
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("androidx.test.espresso:espresso-core:3.5.1")
|
||||||
androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
|
androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
|
||||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||||
|
|||||||
@@ -3,14 +3,7 @@ package com.scarriffle.calendarr
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.foundation.layout.Box
|
import com.scarriffle.calendarr.ui.CalendarrRoot
|
||||||
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 dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -18,13 +11,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
CalendarrTheme {
|
CalendarrRoot()
|
||||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
|
||||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
|
||||||
Text(text = "Calendarr Android", style = MaterialTheme.typography.headlineMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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<AppRoute> = _route.asStateFlow()
|
||||||
|
|
||||||
|
private val _settings = MutableStateFlow(settingsStore.loadSettings())
|
||||||
|
val settings: StateFlow<AppSettings> = _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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AddType?>(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")) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<String?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var caldav by mutableStateOf<List<CalDAVAccount>>(emptyList())
|
||||||
|
private set
|
||||||
|
var local by mutableStateOf<List<LocalCalendar>>(emptyList())
|
||||||
|
private set
|
||||||
|
var ical by mutableStateOf<List<ICalSubscription>>(emptyList())
|
||||||
|
private set
|
||||||
|
var google by mutableStateOf<List<GoogleAccount>>(emptyList())
|
||||||
|
private set
|
||||||
|
var homeAssistant by mutableStateOf<List<HomeAssistantAccount>>(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) }
|
||||||
|
}
|
||||||
@@ -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<String?>(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<String?>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ?: ""
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CalendarFilterEntry>,
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> {
|
||||||
|
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)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CalEvent?>(null) }
|
||||||
|
var editor by remember { mutableStateOf<EditorRequest?>(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<CalendarFilterEntry> {
|
||||||
|
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() }
|
||||||
|
}
|
||||||
@@ -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 "<source>-" 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<CalEvent> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isBackgroundCaching: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val weekStartsOnMonday: Boolean = true,
|
||||||
|
val writableCalendars: List<WritableCalendar> = emptyList(),
|
||||||
|
val hiddenKeys: Set<String> = emptySet(),
|
||||||
|
val banishedKeys: Set<String> = 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<CalendarUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
// Cache bookkeeping
|
||||||
|
private var cachedStart: Instant? = null
|
||||||
|
private var cachedEnd: Instant? = null
|
||||||
|
private var allCachedEvents: List<CalEvent> = 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<Instant, Instant> {
|
||||||
|
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<CalEvent>, 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<String>) {
|
||||||
|
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<CalEvent>): List<CalEvent> {
|
||||||
|
val dayStart = instant(date)
|
||||||
|
val dayEnd = instant(date.plusDays(1))
|
||||||
|
return events.filter { it.startDate.isBefore(dayEnd) && it.endDate.isAfter(dayStart) }
|
||||||
|
.sortedBy { it.startDate }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CalEvent>,
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LocalDate>,
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WritableCalendar>,
|
||||||
|
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<String?>(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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String?>(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<UserProfile?>(null)
|
||||||
|
private set
|
||||||
|
var email by mutableStateOf("")
|
||||||
|
private set
|
||||||
|
var loading by mutableStateOf(true)
|
||||||
|
private set
|
||||||
|
var message by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
// 2FA enrolment
|
||||||
|
var totpSetup by mutableStateOf<TotpSetup?>(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 = "" }
|
||||||
|
}
|
||||||
@@ -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<Pair<String, String>>, 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -1,35 +1,44 @@
|
|||||||
package com.scarriffle.calendarr.ui.theme
|
package com.scarriffle.calendarr.ui.theme
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
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,
|
* The Calendarr UI is a dark theme whose accent colours follow the user's
|
||||||
onPrimary = md_theme_light_onPrimary,
|
* synced [AppSettings] (mirrors the iOS appearance settings).
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarrTheme(
|
fun CalendarrTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
settings: AppSettings = AppSettings(),
|
||||||
content: @Composable () -> Unit,
|
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(
|
MaterialTheme(
|
||||||
colorScheme = colors,
|
colorScheme = colors,
|
||||||
typography = androidx.compose.material3.Typography(),
|
typography = Typography(),
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user