fix: green brand theme, iOS launcher icon, password reveal, real login error

- Theme primary now the iOS brand green (#20A050), fixed app-wide (was tied
  to the server's blue primary_color)
- Launcher icon generated from the iOS AppIcon across all densities;
  dropped the placeholder adaptive icon
- PasswordField: eye toggle + brief last-character reveal; used on login,
  profile and CalDAV password inputs
- Login now surfaces the server's actual error detail (verified the JSON
  contract against the live server; a 401 is a genuine credential mismatch)
- CLAUDE.md: correct prod base URL to calendar.scarriffle.com

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-05-31 12:48:53 +02:00
parent aff6cef493
commit a1c36a8a03
22 changed files with 113 additions and 64 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,27 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M34,30h40a6,6 0 0 1 6,6v40a6,6 0 0 1 -6,6H34a6,6 0 0 1 -6,-6V36a6,6 0 0 1 6,-6z" />
<path
android:fillColor="#4285F4"
android:pathData="M34,30h40a6,6 0 0 1 6,6v8H28v-8a6,6 0 0 1 6,-6z" />
<path
android:fillColor="#4285F4"
android:pathData="M40,24h4v12h-4z M64,24h4v12h-4z" />
<path
android:fillColor="#EA4335"
android:pathData="M38,52h8v8h-8z" />
<path
android:fillColor="#34A853"
android:pathData="M50,52h8v8h-8z" />
<path
android:fillColor="#FBBC05"
android:pathData="M62,52h8v8h-8z" />
<path
android:fillColor="#7090C0"
android:pathData="M38,64h8v8h-8z M50,64h8v8h-8z" />
</vector>

View File

@@ -1,4 +0,0 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -1,4 +0,0 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,3 +0,0 @@
<resources>
<color name="ic_launcher_background">#0B1220</color>
</resources>

View File

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