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")
|
||||
}.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()))
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
## 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)
|
||||
|
||||
|
||||