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
co-authored by Claude Opus 4.8
parent 676b7ee2c6
commit aff6cef493
26 changed files with 3129 additions and 35 deletions
@@ -0,0 +1,163 @@
package com.scarriffle.calendarr.ui.profile
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.ui.tr
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen(onClose: () -> Unit, vm: ProfileViewModel = hiltViewModel()) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(tr("profile.title")) },
navigationIcon = {
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) }
},
)
},
) { padding ->
if (vm.loading) {
Column(Modifier.fillMaxSize().padding(padding), verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
}
return@Scaffold
}
Column(
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(20.dp),
) {
val p = vm.profile
SectionTitle(tr("profile.account"))
InfoRow(tr("profile.username"), p?.username ?: "—")
InfoRow(tr("profile.role"), if (p?.isAdmin == true) tr("profile.role.admin") else tr("profile.role.user"))
Spacer(Modifier.size(20.dp))
SectionTitle(tr("profile.email"))
OutlinedTextField(
value = vm.email,
onValueChange = vm::onEmailChange,
label = { Text(tr("profile.email")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(8.dp))
Button(onClick = vm::saveEmail) { Text(tr("profile.save_email")) }
Spacer(Modifier.size(24.dp))
Divider()
Spacer(Modifier.size(16.dp))
PasswordSection(vm)
Spacer(Modifier.size(24.dp))
Divider()
Spacer(Modifier.size(16.dp))
TwoFactorSection(vm)
vm.message?.let {
Spacer(Modifier.size(16.dp))
Text(it, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
@Composable
private fun PasswordSection(vm: ProfileViewModel) {
val mismatchMsg = tr("profile.password_mismatch")
var current by remember { mutableStateOf("") }
var new1 by remember { mutableStateOf("") }
var new2 by remember { mutableStateOf("") }
var localError by remember { mutableStateOf<String?>(null) }
SectionTitle(tr("profile.change_password"))
OutlinedTextField(value = current, onValueChange = { current = it }, label = { Text(tr("profile.current_password")) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(value = new1, onValueChange = { new1 = it }, label = { Text(tr("profile.new_password")) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(value = new2, onValueChange = { new2 = it }, label = { Text(tr("profile.new_password_repeat")) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth())
localError?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
Spacer(Modifier.size(8.dp))
Button(onClick = {
if (new1 != new2) { localError = mismatchMsg; return@Button }
localError = null
vm.changePassword(current, new1) { ok -> if (ok) { current = ""; new1 = ""; new2 = "" } }
}) { Text(tr("profile.change_password")) }
}
@Composable
private fun TwoFactorSection(vm: ProfileViewModel) {
val enabled = vm.profile?.totpEnabled == true
var disablePw by remember { mutableStateOf("") }
SectionTitle(tr("profile.twofa"))
Text(if (enabled) tr("profile.twofa.active") else tr("profile.twofa.inactive"), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.size(8.dp))
if (enabled) {
OutlinedTextField(value = disablePw, onValueChange = { disablePw = it }, label = { Text(tr("twofa.password_placeholder")) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedButton(onClick = { vm.disable2fa(disablePw); disablePw = "" }) { Text(tr("profile.twofa.disable")) }
} else {
val setup = vm.totpSetup
if (setup == null) {
Button(onClick = vm::begin2fa) { Text(tr("profile.twofa.enable")) }
} else {
Text(tr("twofa.scan_hint"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.size(8.dp))
Text(setup.secret, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(Modifier.size(8.dp))
OutlinedTextField(value = vm.totpCode, onValueChange = vm::onTotpChange, label = { Text(tr("twofa.code_placeholder")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
Button(onClick = vm::confirm2fa) { Text(tr("twofa.activate")) }
OutlinedButton(onClick = vm::cancel2fa) { Text(tr("common.cancel")) }
}
}
}
}
@Composable
private fun SectionTitle(text: String) {
Text(text, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp))
}
@Composable
private fun InfoRow(label: String, value: String) {
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(label, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, fontWeight = FontWeight.Medium)
}
}
@@ -0,0 +1,91 @@
package com.scarriffle.calendarr.ui.profile
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.data.TotpSetup
import com.scarriffle.calendarr.domain.model.UserProfile
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val repository: CalendarRepository,
) : ViewModel() {
var profile by mutableStateOf<UserProfile?>(null)
private set
var email by mutableStateOf("")
private set
var loading by mutableStateOf(true)
private set
var message by mutableStateOf<String?>(null)
private set
// 2FA enrolment
var totpSetup by mutableStateOf<TotpSetup?>(null)
private set
var totpCode by mutableStateOf("")
private set
init { load() }
fun load() {
viewModelScope.launch {
loading = true
runCatching { repository.getProfile() }
.onSuccess { p -> profile = p; email = p.email ?: "" }
.onFailure { message = it.message }
loading = false
}
}
fun onEmailChange(v: String) { email = v }
fun onTotpChange(v: String) { totpCode = v }
fun saveEmail() {
viewModelScope.launch {
runCatching { repository.updateEmail(email.trim()) }
.onSuccess { message = "✓"; load() }
.onFailure { message = it.message }
}
}
fun changePassword(current: String, new: String, onDone: (Boolean) -> Unit) {
viewModelScope.launch {
runCatching { repository.changePassword(current, new) }
.onSuccess { message = "✓"; onDone(true) }
.onFailure { message = it.message; onDone(false) }
}
}
fun begin2fa() {
viewModelScope.launch {
runCatching { repository.setup2fa() }
.onSuccess { totpSetup = it }
.onFailure { message = it.message }
}
}
fun confirm2fa() {
viewModelScope.launch {
runCatching { repository.enable2fa(totpCode.trim()) }
.onSuccess { totpSetup = null; totpCode = ""; load() }
.onFailure { message = it.message }
}
}
fun disable2fa(password: String) {
viewModelScope.launch {
runCatching { repository.disable2fa(password) }
.onSuccess { load() }
.onFailure { message = it.message }
}
}
fun cancel2fa() { totpSetup = null; totpCode = "" }
}