diff --git a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt index 2b3b18c..6aadd8b 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -79,7 +79,7 @@ class CalendarRepository @Inject constructor( JSONObject(resp.errorBody()?.string() ?: "").optString("detail") }.getOrNull() if (detail == "2fa_required") throw TwoFactorRequiredException() - throw UnauthorizedException() + throw UnauthorizedException(detail?.takeIf { it.isNotBlank() } ?: "Benutzername oder Passwort falsch") } if (!resp.isSuccessful) throw ApiException(errorDetail(resp.errorBody(), resp.code())) diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiException.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiException.kt index 526e52b..07babf5 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiException.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiException.kt @@ -7,8 +7,8 @@ import retrofit2.Response /** Generic server error carrying the `detail` message when available. */ open class ApiException(message: String) : Exception(message) -/** Credentials rejected (HTTP 401, not a 2FA prompt). */ -class UnauthorizedException : ApiException("Benutzername oder Passwort falsch") +/** Credentials rejected (HTTP 401, not a 2FA prompt). Carries the server detail. */ +class UnauthorizedException(message: String = "Benutzername oder Passwort falsch") : ApiException(message) /** Server requires a TOTP code to finish login (HTTP 401, detail "2fa_required"). */ class TwoFactorRequiredException : ApiException("2FA-Code erforderlich") diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt index 9888702..3ee3902 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt @@ -40,6 +40,7 @@ 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.components.PasswordField import com.scarriffle.calendarr.ui.tr import com.scarriffle.calendarr.util.colorFromHex @@ -193,7 +194,7 @@ private fun CalDAVDialog(onDismiss: () -> Unit, onConfirm: (String, String, Stri 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()) + PasswordField(pass, { pass = it }, label = tr("caldav.password"), modifier = Modifier.fillMaxWidth()) } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/auth/LoginScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/auth/LoginScreen.kt index d730125..bc0f85d 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/auth/LoginScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/auth/LoginScreen.kt @@ -26,10 +26,10 @@ 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.components.PasswordField import com.scarriffle.calendarr.ui.tr @Composable @@ -63,16 +63,10 @@ fun LoginScreen( modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(12.dp)) - OutlinedTextField( + PasswordField( value = vm.password, onValueChange = vm::onPasswordChange, - label = { Text(tr("auth.password")) }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - ), + label = tr("auth.password"), modifier = Modifier.fillMaxWidth(), ) AnimatedVisibility(vm.showTotp) { diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/components/PasswordField.kt b/app/src/main/java/com/scarriffle/calendarr/ui/components/PasswordField.kt new file mode 100644 index 0000000..dec4292 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/components/PasswordField.kt @@ -0,0 +1,88 @@ +package com.scarriffle.calendarr.ui.components + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import kotlinx.coroutines.delay + +/** + * Password input with two affordances the plain field lacks: + * - an eye toggle to reveal the full value, and + * - a brief reveal of the most recently typed character (~1.2s) so the user + * can tell whether a keystroke landed as intended. + */ +@Composable +fun PasswordField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + imeAction: ImeAction = ImeAction.Done, +) { + var fullyVisible by remember { mutableStateOf(false) } + var revealLast by remember { mutableStateOf(false) } + + // Reveal the last character briefly after each change. + LaunchedEffect(value) { + if (value.isNotEmpty() && !fullyVisible) { + revealLast = true + delay(1200) + revealLast = false + } else { + revealLast = false + } + } + + val transformation = when { + fullyVisible -> VisualTransformation.None + revealLast -> LastCharVisibleTransformation + else -> PasswordVisualTransformation() + } + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + singleLine = true, + visualTransformation = transformation, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction), + trailingIcon = { + IconButton(onClick = { fullyVisible = !fullyVisible }) { + Icon( + imageVector = if (fullyVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = null, + ) + } + }, + modifier = modifier, + ) +} + +/** Masks every character except the last with a bullet. 1:1 char mapping → identity offsets. */ +private val LastCharVisibleTransformation = object : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val masked = buildString { + for (i in text.indices) append(if (i == text.lastIndex) text[i] else '•') + } + return TransformedText(AnnotatedString(masked), OffsetMapping.Identity) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileScreen.kt index 80d8061..d81c160 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileScreen.kt @@ -32,9 +32,9 @@ 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.components.PasswordField import com.scarriffle.calendarr.ui.tr @OptIn(ExperimentalMaterial3Api::class) @@ -105,11 +105,11 @@ private fun PasswordSection(vm: ProfileViewModel) { var localError by remember { mutableStateOf(null) } SectionTitle(tr("profile.change_password")) - OutlinedTextField(value = current, onValueChange = { current = it }, label = { Text(tr("profile.current_password")) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + PasswordField(value = current, onValueChange = { current = it }, label = tr("profile.current_password"), 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()) + PasswordField(value = new1, onValueChange = { new1 = it }, label = tr("profile.new_password"), 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()) + PasswordField(value = new2, onValueChange = { new2 = it }, label = tr("profile.new_password_repeat"), modifier = Modifier.fillMaxWidth()) localError?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } Spacer(Modifier.size(8.dp)) Button(onClick = { @@ -127,7 +127,7 @@ private fun TwoFactorSection(vm: ProfileViewModel) { 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()) + PasswordField(value = disablePw, onValueChange = { disablePw = it }, label = tr("twofa.password_placeholder"), modifier = Modifier.fillMaxWidth()) Spacer(Modifier.size(8.dp)) OutlinedButton(onClick = { vm.disable2fa(disablePw); disablePw = "" }) { Text(tr("profile.twofa.disable")) } } else { diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt b/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt index 7a5057e..eb64f06 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt @@ -10,23 +10,27 @@ import com.scarriffle.calendarr.util.colorFromHex import com.scarriffle.calendarr.util.contrastingTextColor /** - * The Calendarr UI is a dark theme whose accent colours follow the user's - * synced [AppSettings] (mirrors the iOS appearance settings). + * The Calendarr brand accent — the green from the iOS `AccentColor` asset + * (#20A050). This drives the global control tint (buttons, FAB, switches, + * top bar) regardless of the server's per-calendar colours, matching iOS + * where the app tint is fixed and `primary_color` only styles calendar + * elements (e.g. the "today" highlight, read from [AppSettings]). */ +val BrandGreen = Color(0xFF20A050) + @Composable fun CalendarrTheme( settings: AppSettings = AppSettings(), content: @Composable () -> Unit, ) { - val primary = colorFromHex(settings.primaryColor, Color(0xFF4285F4)) - val accent = colorFromHex(settings.accentColor, Color(0xFFEA4335)) + val primary = BrandGreen val colors = darkColorScheme( primary = primary, onPrimary = primary.contrastingTextColor(), - secondary = accent, - onSecondary = accent.contrastingTextColor(), - tertiary = accent, + secondary = primary, + onSecondary = primary.contrastingTextColor(), + tertiary = primary, background = Color(0xFF000000), onBackground = Color(0xFFF2F2F7), surface = Color(0xFF1C1C1E), diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 8ed8511..0000000 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index c7bd273..0000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index c7bd273..0000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..59317c1 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..59317c1 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..c84e2e5 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..c84e2e5 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..7875186 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..7875186 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..a99ac08 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a99ac08 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..45aa7c7 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..45aa7c7 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/ic_launcher_colors.xml b/app/src/main/res/values/ic_launcher_colors.xml deleted file mode 100644 index 5101d8d..0000000 --- a/app/src/main/res/values/ic_launcher_colors.xml +++ /dev/null @@ -1,3 +0,0 @@ - - #0B1220 - diff --git a/claude.md b/claude.md index 727de27..3e5f040 100644 --- a/claude.md +++ b/claude.md @@ -12,7 +12,7 @@ Die folgenden Ordner dienen NUR zur Referenz. NIEMALS Dateien darin verändern, Alle Änderungen und neue Dateien kommen ausschliesslich in `../Calendarr Android/`. ## Server -- Basis-URL (Prod): https://cal.scarriffle.com +- Basis-URL (Prod): https://calendar.scarriffle.com - Auth: Erst Server-URL eingeben, dann Benutzername + Passwort - Credentials werden auf Android im EncryptedSharedPreferences gespeichert (Äquivalent zu Apple Keychain)