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")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.6")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
|
||||
@@ -3,14 +3,7 @@ package com.scarriffle.calendarr
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.scarriffle.calendarr.ui.theme.CalendarrTheme
|
||||
import com.scarriffle.calendarr.ui.CalendarrRoot
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -18,13 +11,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
CalendarrTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
||||
Text(text = "Calendarr Android", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
CalendarrRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.scarriffle.calendarr.domain.model.AppSettings
|
||||
import com.scarriffle.calendarr.util.colorFromHex
|
||||
import com.scarriffle.calendarr.util.contrastingTextColor
|
||||
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = md_theme_light_primary,
|
||||
onPrimary = md_theme_light_onPrimary,
|
||||
background = md_theme_light_background,
|
||||
onBackground = md_theme_light_onBackground,
|
||||
)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = md_theme_dark_primary,
|
||||
onPrimary = md_theme_dark_onPrimary,
|
||||
background = md_theme_dark_background,
|
||||
onBackground = md_theme_dark_onBackground,
|
||||
)
|
||||
|
||||
/**
|
||||
* The Calendarr UI is a dark theme whose accent colours follow the user's
|
||||
* synced [AppSettings] (mirrors the iOS appearance settings).
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarrTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
settings: AppSettings = AppSettings(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colors = if (darkTheme) DarkColors else LightColors
|
||||
val primary = colorFromHex(settings.primaryColor, Color(0xFF4285F4))
|
||||
val accent = colorFromHex(settings.accentColor, Color(0xFFEA4335))
|
||||
|
||||
val colors = darkColorScheme(
|
||||
primary = primary,
|
||||
onPrimary = primary.contrastingTextColor(),
|
||||
secondary = accent,
|
||||
onSecondary = accent.contrastingTextColor(),
|
||||
tertiary = accent,
|
||||
background = Color(0xFF000000),
|
||||
onBackground = Color(0xFFF2F2F7),
|
||||
surface = Color(0xFF1C1C1E),
|
||||
onSurface = Color(0xFFF2F2F7),
|
||||
surfaceVariant = Color(0xFF2C2C2E),
|
||||
onSurfaceVariant = Color(0xFFBEBEC4),
|
||||
outline = Color(0xFF3A3A3C),
|
||||
)
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colors,
|
||||
typography = androidx.compose.material3.Typography(),
|
||||
typography = Typography(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user