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>
@@ -79,7 +79,7 @@ class CalendarRepository @Inject constructor(
|
|||||||
JSONObject(resp.errorBody()?.string() ?: "").optString("detail")
|
JSONObject(resp.errorBody()?.string() ?: "").optString("detail")
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
if (detail == "2fa_required") throw TwoFactorRequiredException()
|
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()))
|
if (!resp.isSuccessful) throw ApiException(errorDetail(resp.errorBody(), resp.code()))
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import retrofit2.Response
|
|||||||
/** Generic server error carrying the `detail` message when available. */
|
/** Generic server error carrying the `detail` message when available. */
|
||||||
open class ApiException(message: String) : Exception(message)
|
open class ApiException(message: String) : Exception(message)
|
||||||
|
|
||||||
/** Credentials rejected (HTTP 401, not a 2FA prompt). */
|
/** Credentials rejected (HTTP 401, not a 2FA prompt). Carries the server detail. */
|
||||||
class UnauthorizedException : ApiException("Benutzername oder Passwort falsch")
|
class UnauthorizedException(message: String = "Benutzername oder Passwort falsch") : ApiException(message)
|
||||||
|
|
||||||
/** Server requires a TOTP code to finish login (HTTP 401, detail "2fa_required"). */
|
/** Server requires a TOTP code to finish login (HTTP 401, detail "2fa_required"). */
|
||||||
class TwoFactorRequiredException : ApiException("2FA-Code erforderlich")
|
class TwoFactorRequiredException : ApiException("2FA-Code erforderlich")
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.scarriffle.calendarr.ui.components.PasswordField
|
||||||
import com.scarriffle.calendarr.ui.tr
|
import com.scarriffle.calendarr.ui.tr
|
||||||
import com.scarriffle.calendarr.util.colorFromHex
|
import com.scarriffle.calendarr.util.colorFromHex
|
||||||
|
|
||||||
@@ -193,7 +194,7 @@ private fun CalDAVDialog(onDismiss: () -> Unit, onConfirm: (String, String, Stri
|
|||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
OutlinedTextField(user, { user = it }, label = { Text(tr("caldav.username")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(user, { user = it }, label = { Text(tr("caldav.username")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||||
Spacer(Modifier.size(8.dp))
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.scarriffle.calendarr.data.CredentialStore
|
import com.scarriffle.calendarr.data.CredentialStore
|
||||||
|
import com.scarriffle.calendarr.ui.components.PasswordField
|
||||||
import com.scarriffle.calendarr.ui.tr
|
import com.scarriffle.calendarr.ui.tr
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -63,16 +63,10 @@ fun LoginScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
OutlinedTextField(
|
PasswordField(
|
||||||
value = vm.password,
|
value = vm.password,
|
||||||
onValueChange = vm::onPasswordChange,
|
onValueChange = vm::onPasswordChange,
|
||||||
label = { Text(tr("auth.password")) },
|
label = tr("auth.password"),
|
||||||
singleLine = true,
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Password,
|
|
||||||
imeAction = ImeAction.Done,
|
|
||||||
),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
AnimatedVisibility(vm.showTotp) {
|
AnimatedVisibility(vm.showTotp) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,9 +32,9 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.scarriffle.calendarr.ui.components.PasswordField
|
||||||
import com.scarriffle.calendarr.ui.tr
|
import com.scarriffle.calendarr.ui.tr
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -105,11 +105,11 @@ private fun PasswordSection(vm: ProfileViewModel) {
|
|||||||
var localError by remember { mutableStateOf<String?>(null) }
|
var localError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
SectionTitle(tr("profile.change_password"))
|
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))
|
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))
|
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) }
|
localError?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
Button(onClick = {
|
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)
|
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))
|
Spacer(Modifier.size(8.dp))
|
||||||
if (enabled) {
|
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))
|
Spacer(Modifier.size(8.dp))
|
||||||
OutlinedButton(onClick = { vm.disable2fa(disablePw); disablePw = "" }) { Text(tr("profile.twofa.disable")) }
|
OutlinedButton(onClick = { vm.disable2fa(disablePw); disablePw = "" }) { Text(tr("profile.twofa.disable")) }
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,23 +10,27 @@ import com.scarriffle.calendarr.util.colorFromHex
|
|||||||
import com.scarriffle.calendarr.util.contrastingTextColor
|
import com.scarriffle.calendarr.util.contrastingTextColor
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Calendarr UI is a dark theme whose accent colours follow the user's
|
* The Calendarr brand accent — the green from the iOS `AccentColor` asset
|
||||||
* synced [AppSettings] (mirrors the iOS appearance settings).
|
* (#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
|
@Composable
|
||||||
fun CalendarrTheme(
|
fun CalendarrTheme(
|
||||||
settings: AppSettings = AppSettings(),
|
settings: AppSettings = AppSettings(),
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val primary = colorFromHex(settings.primaryColor, Color(0xFF4285F4))
|
val primary = BrandGreen
|
||||||
val accent = colorFromHex(settings.accentColor, Color(0xFFEA4335))
|
|
||||||
|
|
||||||
val colors = darkColorScheme(
|
val colors = darkColorScheme(
|
||||||
primary = primary,
|
primary = primary,
|
||||||
onPrimary = primary.contrastingTextColor(),
|
onPrimary = primary.contrastingTextColor(),
|
||||||
secondary = accent,
|
secondary = primary,
|
||||||
onSecondary = accent.contrastingTextColor(),
|
onSecondary = primary.contrastingTextColor(),
|
||||||
tertiary = accent,
|
tertiary = primary,
|
||||||
background = Color(0xFF000000),
|
background = Color(0xFF000000),
|
||||||
onBackground = Color(0xFFF2F2F7),
|
onBackground = Color(0xFFF2F2F7),
|
||||||
surface = Color(0xFF1C1C1E),
|
surface = Color(0xFF1C1C1E),
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
@@ -1,3 +0,0 @@
|
|||||||
<resources>
|
|
||||||
<color name="ic_launcher_background">#0B1220</color>
|
|
||||||
</resources>
|
|
||||||
@@ -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/`.
|
Alle Änderungen und neue Dateien kommen ausschliesslich in `../Calendarr Android/`.
|
||||||
|
|
||||||
## Server
|
## Server
|
||||||
- Basis-URL (Prod): https://cal.scarriffle.com
|
- Basis-URL (Prod): https://calendar.scarriffle.com
|
||||||
- Auth: Erst Server-URL eingeben, dann Benutzername + Passwort
|
- Auth: Erst Server-URL eingeben, dann Benutzername + Passwort
|
||||||
- Credentials werden auf Android im EncryptedSharedPreferences gespeichert (Äquivalent zu Apple Keychain)
|
- Credentials werden auf Android im EncryptedSharedPreferences gespeichert (Äquivalent zu Apple Keychain)
|
||||||
|
|
||||||
|
|||||||