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:
Guido Schmit
2026-05-31 12:22:56 +02:00
parent 676b7ee2c6
commit aff6cef493
26 changed files with 3129 additions and 35 deletions

View File

@@ -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")

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")) } },
)
}

View File

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

View File

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

View File

@@ -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 ?: ""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = "" }
}

View File

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

View File

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

View File

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