From a015a4526528ab66018bcca22040323be788f960 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 11:44:48 +0200 Subject: [PATCH 01/59] chore: get Android project building (toolchain, deps, manifest, launcher icon) - Fix dependency versions (Retrofit 2.11, OkHttp 4.12, Compose BOM) - Add Material Components for the app theme - Add --add-exports/--add-opens for kapt on JDK 21 - Remove deprecated manifest package attr; add INTERNET permission - Add adaptive launcher icon Co-Authored-By: Claude Opus 4.8 --- .gitignore | 46 ++++ app/build.gradle.kts | 92 +++++++ app/proguard-rules.pro | 2 + app/src/main/AndroidManifest.xml | 24 ++ .../calendarr/CalendarrApplication.kt | 7 + .../com/scarriffle/calendarr/MainActivity.kt | 30 +++ .../scarriffle/calendarr/ui/theme/Color.kt | 13 + .../scarriffle/calendarr/ui/theme/Theme.kt | 35 +++ .../res/drawable/ic_launcher_foreground.xml | 27 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 4 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 4 + .../main/res/values/ic_launcher_colors.xml | 3 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 5 + build.gradle.kts | 1 + claude.md | 49 ++++ gradle.properties | 33 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 ++++++++++++++++++ gradlew.bat | 94 +++++++ settings.gradle.kts | 17 ++ 22 files changed, 747 insertions(+) create mode 100644 .gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/scarriffle/calendarr/CalendarrApplication.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/MainActivity.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/theme/Color.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/ic_launcher_colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 build.gradle.kts create mode 100644 claude.md create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6403640 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc.) +local.properties + +# Log files +*.log + +# Android Studio / IntelliJ +*.iml +.idea/ +*.ipr +*.iws +captures/ +.externalNativeBuild/ +.cxx/ + +# Keystore files +*.jks +*.keystore + +# macOS +.DS_Store + +# Kotlin +.kotlin/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..55f5bf2 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,92 @@ +plugins { + id("com.android.application") version "8.10.0" + id("org.jetbrains.kotlin.android") version "1.9.20" + id("org.jetbrains.kotlin.kapt") version "1.9.20" + id("com.google.dagger.hilt.android") version "2.49" +} + +android { + namespace = "com.scarriffle.calendarr" + compileSdk = 34 + + defaultConfig { + applicationId = "com.scarriffle.calendarr" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "0.1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { useSupportLibrary = true } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.5" + } + + packaging { + resources { + excludes += setOf("/META-INF/{AL2.0,LGPL2.1}") + } + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + implementation("androidx.activity:activity-compose:1.9.0") + + // Compose (managed by BOM) + implementation(platform("androidx.compose:compose-bom:2023.10.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material3:material3-window-size-class") + implementation("androidx.compose.material:material-icons-extended") + debugImplementation("androidx.compose.ui:ui-tooling") + + // DI + implementation("androidx.hilt:hilt-navigation-compose:1.2.0") + implementation("com.google.dagger:hilt-android:2.49") + kapt("com.google.dagger:hilt-compiler:2.49") + + // Networking + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.retrofit2:converter-moshi:2.11.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + implementation("com.squareup.moshi:moshi-kotlin:1.15.0") + + // Secure credential storage + implementation("androidx.security:security-crypto:1.1.0-alpha06") + + // Async + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.6") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..ddf4528 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# Add project-specific ProGuard rules here. +# Keep this file empty until release optimization rules are needed. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6494c5c --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/scarriffle/calendarr/CalendarrApplication.kt b/app/src/main/java/com/scarriffle/calendarr/CalendarrApplication.kt new file mode 100644 index 0000000..47024ac --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/CalendarrApplication.kt @@ -0,0 +1,7 @@ +package com.scarriffle.calendarr + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class CalendarrApplication : Application() diff --git a/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt b/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt new file mode 100644 index 0000000..029eafb --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt @@ -0,0 +1,30 @@ +package com.scarriffle.calendarr + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.scarriffle.calendarr.ui.theme.CalendarrTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + CalendarrTheme { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Text(text = "Calendarr Android", style = MaterialTheme.typography.headlineMedium) + } + } + } + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/theme/Color.kt b/app/src/main/java/com/scarriffle/calendarr/ui/theme/Color.kt new file mode 100644 index 0000000..8e369a7 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/theme/Color.kt @@ -0,0 +1,13 @@ +package com.scarriffle.calendarr.ui.theme + +import androidx.compose.ui.graphics.Color + +val md_theme_light_primary = Color(0xFF4285F4) +val md_theme_light_onPrimary = Color(0xFFFFFFFF) +val md_theme_light_background = Color(0xFFFFFFFF) +val md_theme_light_onBackground = Color(0xFF000000) + +val md_theme_dark_primary = Color(0xFF8AB4F8) +val md_theme_dark_onPrimary = Color(0xFF000000) +val md_theme_dark_background = Color(0xFF000000) +val md_theme_dark_onBackground = Color(0xFFFFFFFF) 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 new file mode 100644 index 0000000..6cc1e7b --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt @@ -0,0 +1,35 @@ +package com.scarriffle.calendarr.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +private val LightColors = lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, +) + +private val DarkColors = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, +) + +@Composable +fun CalendarrTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val colors = if (darkTheme) DarkColors else LightColors + + MaterialTheme( + colorScheme = colors, + typography = androidx.compose.material3.Typography(), + content = content, + ) +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..8ed8511 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c7bd273 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,4 @@ + + + + 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 new file mode 100644 index 0000000..c7bd273 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/values/ic_launcher_colors.xml b/app/src/main/res/values/ic_launcher_colors.xml new file mode 100644 index 0000000..5101d8d --- /dev/null +++ b/app/src/main/res/values/ic_launcher_colors.xml @@ -0,0 +1,3 @@ + + #0B1220 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..507019d --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Calendarr + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..a564b56 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..6df8e44 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1 @@ +// Gradle wrapper will be generated once settings and build files are present. diff --git a/claude.md b/claude.md new file mode 100644 index 0000000..727de27 --- /dev/null +++ b/claude.md @@ -0,0 +1,49 @@ +# Calendarr Android + +## Projektübersicht +Android-App für den Calendarr-Server. Funktionsgleich mit der iOS-App (Swift, liegt unter ../Calendarr iOS/). +Der Server-Code liegt unter ../Calendarr server/. + +## ⚠️ WICHTIG: Read-Only Ordner +Die folgenden Ordner dienen NUR zur Referenz. NIEMALS Dateien darin verändern, löschen oder erstellen: +- `../Calendarr iOS/` → READ ONLY – nur zur Analyse der Features und API-Nutzung +- `../Calendarr server/` → READ ONLY – nur zur Analyse der API-Endpunkte und Datenmodelle + +Alle Änderungen und neue Dateien kommen ausschliesslich in `../Calendarr Android/`. + +## Server +- Basis-URL (Prod): https://cal.scarriffle.com +- Auth: Erst Server-URL eingeben, dann Benutzername + Passwort +- Credentials werden auf Android im EncryptedSharedPreferences gespeichert (Äquivalent zu Apple Keychain) + +## Tech Stack +- Sprache: Kotlin +- UI: Jetpack Compose + Material 3 +- Architektur: MVVM +- Netzwerk: Retrofit + OkHttp +- Lokale Speicherung: EncryptedSharedPreferences (Credentials), Room (Cache) +- Async: Coroutines + Flow +- DI: Hilt + +## Projektstruktur +- `../Calendarr iOS/` → READ ONLY – Swift-App als Referenz +- `../Calendarr server/` → READ ONLY – Server als Referenz +- `../Calendarr Android/` → dieses Projekt, hier wird gebaut + +## Git +- Remote: https://git.scarriffle.com/Scarriffle/Calendarr-Android.git +- Nach jeder abgeschlossenen Funktionseinheit committen und pushen +- Commit-Messages auf Englisch, format: `feat: ...` / `fix: ...` / `chore: ...` +- Niemals halbfertigen oder nicht-kompilierenden Code pushen + +## Build +- Debug APK: `./gradlew assembleDebug` +- Release APK: `./gradlew assembleRelease` +- Tests: `./gradlew test` + +## Konventionen +- Kein XML für Layouts – ausschliesslich Jetpack Compose +- ViewModels dürfen keine Android-Framework-Klassen direkt referenzieren +- StateFlow für UI-State, keine LiveData +- Alle Strings in res/values/strings.xml +- Vor jedem Push: `./gradlew build` muss fehlerfrei durchlaufen diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7096c03 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,33 @@ +org.gradle.jvmargs=-Xmx2g \ + --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED +android.useAndroidX=true +android.enableJetifier=false +kotlin.code.style=official + +# kapt (Kotlin 1.9.x) needs access to internal javac APIs on JDK 16+. +# Without these exports the Kotlin daemon throws IllegalAccessError when +# generating annotation-processor stubs (Hilt). Applied to the Kotlin daemon. +kotlin.daemon.jvmargs=-Xmx2g \ + --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..a99c318 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "CalendarrAndroid" +include(":app") From 676b7ee2c6e681012a8b65c4c5e5729e270a8f1a Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 11:53:34 +0200 Subject: [PATCH 02/59] feat: data foundation (models, secure storage, Retrofit API, repository, L10n) - java.time via core library desugaring (minSdk 24) - Domain models: CalEvent, AppSettings, account/calendar models - CredentialStore (EncryptedSharedPreferences) + SettingsStore - Retrofit CalendarrApi covering all server endpoints - CalendarRepository: auth, settings, accounts, events (org.json parsing for mixed-type payloads), writable calendars, visibility toggles - Hilt DI (Moshi), dynamic-baseUrl ApiProvider, AuthInterceptor - L10n (de/en) ported from iOS Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 2 + .../calendarr/data/CalendarRepository.kt | 348 ++++++++++++++++++ .../calendarr/data/CredentialStore.kt | 83 +++++ .../calendarr/data/SettingsStore.kt | 90 +++++ .../calendarr/data/remote/ApiException.kt | 34 ++ .../calendarr/data/remote/ApiProvider.kt | 71 ++++ .../calendarr/data/remote/AuthInterceptor.kt | 27 ++ .../calendarr/data/remote/CalendarrApi.kt | 181 +++++++++ .../calendarr/data/remote/JsonBody.kt | 26 ++ .../com/scarriffle/calendarr/di/AppModule.kt | 21 ++ .../calendarr/domain/model/Accounts.kt | 97 +++++ .../calendarr/domain/model/AppSettings.kt | 27 ++ .../calendarr/domain/model/CalEvent.kt | 71 ++++ .../calendarr/domain/model/CalViewType.kt | 31 ++ .../java/com/scarriffle/calendarr/ui/L10n.kt | 220 +++++++++++ .../scarriffle/calendarr/util/ColorUtil.kt | 44 +++ .../com/scarriffle/calendarr/util/Dates.kt | 77 ++++ 17 files changed, 1450 insertions(+) create mode 100644 app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/data/CredentialStore.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/data/remote/ApiException.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/data/remote/ApiProvider.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/data/remote/AuthInterceptor.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/data/remote/JsonBody.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/di/AppModule.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/domain/model/CalViewType.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/util/Dates.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 55f5bf2..d5956c3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,6 +30,7 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true } kotlinOptions { jvmTarget = "17" @@ -51,6 +52,7 @@ android { } dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") implementation("androidx.core:core-ktx:1.12.0") implementation("com.google.android.material:material:1.11.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") diff --git a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt new file mode 100644 index 0000000..2b3b18c --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -0,0 +1,348 @@ +package com.scarriffle.calendarr.data + +import com.scarriffle.calendarr.data.remote.ApiException +import com.scarriffle.calendarr.data.remote.ApiProvider +import com.scarriffle.calendarr.data.remote.TwoFactorRequiredException +import com.scarriffle.calendarr.data.remote.UnauthorizedException +import com.scarriffle.calendarr.data.remote.ensureSuccess +import com.scarriffle.calendarr.data.remote.errorDetail +import com.scarriffle.calendarr.data.remote.jsonBody +import com.scarriffle.calendarr.domain.model.AppSettings +import com.scarriffle.calendarr.domain.model.CalDAVAccount +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.domain.model.GoogleAccount +import com.scarriffle.calendarr.domain.model.HomeAssistantAccount +import com.scarriffle.calendarr.domain.model.ICalSubscription +import com.scarriffle.calendarr.domain.model.LocalCalendar +import com.scarriffle.calendarr.domain.model.UserProfile +import com.scarriffle.calendarr.domain.model.WritableCalendar +import com.scarriffle.calendarr.util.Dates +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import retrofit2.HttpException +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +data class LoginResult(val token: String, val username: String, val isAdmin: Boolean) + +data class TotpSetup(val secret: String, val qrUrl: String) + +/** + * Single entry point for all server interaction. Wraps [com.scarriffle.calendarr.data.remote.CalendarrApi], + * converts HTTP failures into [ApiException]s carrying the server's `detail` + * message, and parses the mixed-type event payloads with org.json. + */ +@Singleton +class CalendarRepository @Inject constructor( + private val apiProvider: ApiProvider, + private val credentialStore: CredentialStore, + private val settingsStore: SettingsStore, +) { + private val api get() = apiProvider.api() + + private suspend fun guarded(block: suspend () -> T): T = withContext(Dispatchers.IO) { + try { + block() + } catch (e: HttpException) { + if (e.code() == 401) throw UnauthorizedException() + throw ApiException(errorDetail(e.response()?.errorBody(), e.code())) + } + } + + // ---- Auth ---- + + suspend fun setupRequired(baseUrl: String): Boolean = withContext(Dispatchers.IO) { + val resp = apiProvider.apiFor(baseUrl).setupRequired() + if (!resp.isSuccessful) return@withContext false + val raw = resp.body()?.string() ?: return@withContext false + runCatching { JSONObject(raw).optBoolean("required", false) }.getOrDefault(false) + } + + suspend fun login( + baseUrl: String, + username: String, + password: String, + totpCode: String?, + rememberMe: Boolean, + ): LoginResult = withContext(Dispatchers.IO) { + val body = jsonBody( + "username" to username, + "password" to password, + "remember_me" to rememberMe, + "totp_code" to totpCode, + ) + val resp = apiProvider.apiFor(baseUrl).login(body) + if (resp.code() == 401) { + val detail = runCatching { + JSONObject(resp.errorBody()?.string() ?: "").optString("detail") + }.getOrNull() + if (detail == "2fa_required") throw TwoFactorRequiredException() + throw UnauthorizedException() + } + if (!resp.isSuccessful) throw ApiException(errorDetail(resp.errorBody(), resp.code())) + + val json = JSONObject(resp.body()?.string() ?: throw ApiException("Leere Antwort")) + val token = json.optString("access_token").takeIf { it.isNotBlank() } + ?: throw ApiException("Antwort konnte nicht verarbeitet werden") + val user = json.optJSONObject("user") + val uname = user?.optString("username") ?: username + val isAdmin = user?.optBoolean("is_admin", false) ?: false + + credentialStore.serverUrl = ApiProvider.normalize(baseUrl) + credentialStore.saveLogin(token, uname, isAdmin) + apiProvider.invalidate() + LoginResult(token, uname, isAdmin) + } + + // ---- Settings / profile ---- + + suspend fun getSettings(): AppSettings = guarded { api.getSettings() } + + suspend fun updateSettings(s: AppSettings) = guarded { + api.updateSettings( + jsonBody( + "default_view" to s.defaultView, + "week_start_day" to s.weekStartDay, + "primary_color" to s.primaryColor, + "accent_color" to s.accentColor, + "today_color" to s.todayColor, + "dim_past_events" to s.dimPastEvents, + "text_contrast" to s.textContrast, + "line_contrast" to s.lineContrast, + "hour_height" to s.hourHeight, + "language" to s.language, + "month_divider_color" to s.monthDividerColor, + "month_label_color" to s.monthLabelColor, + ) + ).ensureSuccess() + } + + suspend fun getProfile(): UserProfile = guarded { api.getProfile() } + + suspend fun updateEmail(email: String) = guarded { + api.updateEmail(jsonBody("email" to email)).ensureSuccess() + } + + suspend fun changePassword(current: String, new: String) = guarded { + api.changePassword( + jsonBody("current_password" to current, "new_password" to new) + ).ensureSuccess() + } + + suspend fun setup2fa(): TotpSetup = guarded { + val resp = api.setup2fa() + resp.ensureSuccess() + val json = JSONObject(resp.body()?.string() ?: "{}") + TotpSetup(json.optString("secret"), json.optString("qr_url")) + } + + suspend fun enable2fa(code: String) = guarded { + api.enable2fa(jsonBody("code" to code)).ensureSuccess() + } + + suspend fun disable2fa(password: String) = guarded { + api.disable2fa(jsonBody("password" to password)).ensureSuccess() + } + + // ---- Accounts ---- + + suspend fun getCalDAVAccounts(): List = guarded { api.getCalDAVAccounts() } + + suspend fun addCalDAVAccount(name: String, url: String, username: String, password: String, color: String) = + guarded { + api.addCalDAVAccount( + jsonBody( + "name" to name, "url" to url, "username" to username, + "password" to password, "color" to color, + ) + ) + } + + suspend fun deleteCalDAVAccount(id: Int) = guarded { api.deleteCalDAVAccount(id).ensureSuccess() } + + suspend fun getLocalCalendars(): List = guarded { api.getLocalCalendars() } + + suspend fun addLocalCalendar(name: String, color: String) = + guarded { api.addLocalCalendar(jsonBody("name" to name, "color" to color)) } + + suspend fun deleteLocalCalendar(id: Int) = guarded { api.deleteLocalCalendar(id).ensureSuccess() } + + suspend fun getICalSubscriptions(): List = guarded { api.getICalSubscriptions() } + + suspend fun addICalSubscription(name: String, url: String, color: String, refreshMinutes: Int) = + guarded { + api.addICalSubscription( + jsonBody( + "name" to name, "url" to url, "color" to color, + "refresh_minutes" to refreshMinutes, + ) + ) + } + + suspend fun deleteICalSubscription(id: Int) = guarded { api.deleteICalSubscription(id).ensureSuccess() } + + suspend fun getGoogleAccounts(): List = guarded { api.getGoogleAccounts() } + + suspend fun deleteGoogleAccount(id: Int) = guarded { api.deleteGoogleAccount(id).ensureSuccess() } + + suspend fun getHomeAssistantAccounts(): List = + guarded { api.getHomeAssistantAccounts() } + + suspend fun addHomeAssistantAccount(name: String, url: String, token: String) = + guarded { + api.addHomeAssistantAccount( + jsonBody("name" to name, "url" to url, "token" to token, "auth_method" to "token") + ) + } + + suspend fun deleteHomeAssistantAccount(id: Int) = + guarded { api.deleteHomeAssistantAccount(id).ensureSuccess() } + + /** Toggle a calendar's server-side visibility (caldav/google/homeassistant only). */ + suspend fun setCalendarSidebarHidden(source: String, calendarId: Int, hidden: Boolean) = guarded { + val body = jsonBody("enabled" to !hidden, "sidebar_hidden" to hidden) + when (source) { + "caldav" -> api.updateCalDAVCalendar(calendarId, body).ensureSuccess() + "google" -> api.updateGoogleCalendar(calendarId, body).ensureSuccess() + "homeassistant" -> api.updateHACalendar(calendarId, body).ensureSuccess() + else -> Unit + } + } + + /** Resolve all calendars the user can create events in. */ + suspend fun getWritableCalendars(): List = withContext(Dispatchers.IO) { + val result = mutableListOf() + runCatching { api.getLocalCalendars() }.getOrDefault(emptyList()).forEach { cal -> + result += WritableCalendar("local-${cal.id}", cal.name, cal.color, "local", cal.id) + } + runCatching { api.getCalDAVAccounts() }.getOrDefault(emptyList()) + .filter { it.enabled } + .forEach { acc -> + acc.calendars.orEmpty().filter { it.enabled }.forEach { cal -> + result += WritableCalendar( + "caldav-${cal.id}", "${acc.name} – ${cal.name}", + cal.color ?: acc.color, "caldav", cal.id, + ) + } + } + runCatching { api.getGoogleAccounts() }.getOrDefault(emptyList()).forEach { acc -> + acc.calendars.orEmpty().filter { it.enabled }.forEach { cal -> + result += WritableCalendar( + "google-${cal.id}", "${acc.email} – ${cal.name}", + cal.color ?: "#4285f4", "google", cal.id, + ) + } + } + runCatching { api.getHomeAssistantAccounts() }.getOrDefault(emptyList()).forEach { acc -> + acc.calendars.orEmpty().filter { it.enabled }.forEach { cal -> + result += WritableCalendar( + "ha-${cal.id}", "${acc.name} – ${cal.name}", + cal.color ?: "#46bdc6", "homeassistant", cal.id, + ) + } + } + result + } + + // ---- Events ---- + + suspend fun fetchEvents(start: Instant, end: Instant): List = withContext(Dispatchers.IO) { + val resp = api.fetchEvents(Dates.isoUtc(start), Dates.isoUtc(end)) + resp.ensureSuccess() + val raw = resp.body()?.string() ?: return@withContext emptyList() + val root = JSONObject(raw) + val arr = root.optJSONArray("events") ?: return@withContext emptyList() + buildList { + for (i in 0 until arr.length()) { + val obj = arr.optJSONObject(i) ?: continue + CalEvent.fromJson(obj)?.let { add(it) } + } + } + } + + suspend fun createLocalEvent( + calendarId: Int, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, color: String?, + ) = guarded { + api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color)) + .ensureSuccess() + } + + suspend fun updateLocalEvent( + uid: String, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, color: String?, + ) = guarded { + api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color)) + .ensureSuccess() + } + + suspend fun deleteLocalEvent(uid: String) = guarded { api.deleteLocalEvent(uid).ensureSuccess() } + + suspend fun createCalDAVEvent( + calendarId: Int, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, color: String?, + ) = guarded { + api.createCalDAVEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color)) + .ensureSuccess() + } + + suspend fun updateCalDAVEvent( + uid: String, url: String, calendarId: Int?, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, color: String?, + ) = guarded { + api.updateCalDAVEvent( + uid, url, calendarId, + eventBody(null, title, start, end, isAllDay, location, description, color), + ).ensureSuccess() + } + + suspend fun deleteCalDAVEvent(uid: String, url: String, calendarId: Int?) = + guarded { api.deleteCalDAVEvent(uid, url, calendarId).ensureSuccess() } + + suspend fun createGoogleEvent( + calendarDbId: Int, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, + ) = guarded { + api.createGoogleEvent( + jsonBody( + "calendar_db_id" to calendarDbId, "title" to title, + "start" to Dates.format(start, isAllDay), "end" to Dates.format(end, isAllDay), + "allDay" to isAllDay, "location" to location, "description" to description, + ) + ).ensureSuccess() + } + + suspend fun createHAEvent( + calendarId: Int, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, + ) = guarded { + api.createHAEvent( + jsonBody( + "calendar_id" to calendarId, "title" to title, + "start" to Dates.format(start, isAllDay), "end" to Dates.format(end, isAllDay), + "allDay" to isAllDay, "location" to location, "description" to description, + ) + ).ensureSuccess() + } + + suspend fun deleteHAEvent(calendarId: Int, uid: String) = + guarded { api.deleteHAEvent(calendarId, uid).ensureSuccess() } + + private fun eventBody( + calendarId: Int?, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, color: String?, + ) = jsonBody( + buildMap { + calendarId?.let { put("calendar_id", it) } + put("title", title) + put("start", Dates.format(start, isAllDay)) + put("end", Dates.format(end, isAllDay)) + put("allDay", isAllDay) + put("location", location) + put("description", description) + if (!color.isNullOrBlank()) put("color", color) + } + ) +} diff --git a/app/src/main/java/com/scarriffle/calendarr/data/CredentialStore.kt b/app/src/main/java/com/scarriffle/calendarr/data/CredentialStore.kt new file mode 100644 index 0000000..d9c5ab6 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/CredentialStore.kt @@ -0,0 +1,83 @@ +package com.scarriffle.calendarr.data + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Securely stores the server URL and auth token in EncryptedSharedPreferences + * (Android's equivalent of the iOS Keychain). The iOS app keeps these in + * UserDefaults; on Android we encrypt them at rest. + */ +@Singleton +class CredentialStore @Inject constructor( + @ApplicationContext context: Context, +) { + private val prefs: SharedPreferences = run { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + "calendarr_secure_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + var serverUrl: String? + get() = prefs.getString(KEY_SERVER_URL, null) + set(value) = prefs.edit().putString(KEY_SERVER_URL, value).apply() + + var token: String? + get() = prefs.getString(KEY_TOKEN, null) + set(value) = prefs.edit().putString(KEY_TOKEN, value).apply() + + var username: String? + get() = prefs.getString(KEY_USERNAME, null) + set(value) = prefs.edit().putString(KEY_USERNAME, value).apply() + + var isAdmin: Boolean + get() = prefs.getBoolean(KEY_IS_ADMIN, false) + set(value) = prefs.edit().putBoolean(KEY_IS_ADMIN, value).apply() + + /** True once a server URL has been entered (setup step complete). */ + val isConfigured: Boolean get() = !serverUrl.isNullOrBlank() + + /** True once we hold an auth token (logged in). */ + val isLoggedIn: Boolean get() = !token.isNullOrBlank() + + fun saveLogin(token: String, username: String, isAdmin: Boolean) { + prefs.edit() + .putString(KEY_TOKEN, token) + .putString(KEY_USERNAME, username) + .putBoolean(KEY_IS_ADMIN, isAdmin) + .apply() + } + + /** Clear the token (logout) but keep the server URL. */ + fun clearToken() { + prefs.edit() + .remove(KEY_TOKEN) + .remove(KEY_USERNAME) + .remove(KEY_IS_ADMIN) + .apply() + } + + /** Full reset, including the server URL ("switch server"). */ + fun clearAll() { + prefs.edit().clear().apply() + } + + private companion object { + const val KEY_SERVER_URL = "server_url" + const val KEY_TOKEN = "auth_token" + const val KEY_USERNAME = "username" + const val KEY_IS_ADMIN = "is_admin" + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt b/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt new file mode 100644 index 0000000..cf7ab91 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt @@ -0,0 +1,90 @@ +package com.scarriffle.calendarr.data + +import android.content.Context +import android.content.SharedPreferences +import com.scarriffle.calendarr.domain.model.AppSettings +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Local persistence for cached appearance settings and the per-device + * calendar visibility sets (hidden / banished). Mirrors the UserDefaults + * usage in the iOS `CalendarStore`. + */ +@Singleton +class SettingsStore @Inject constructor( + @ApplicationContext context: Context, +) { + private val prefs: SharedPreferences = + context.getSharedPreferences("calendarr_prefs", Context.MODE_PRIVATE) + + // --- Appearance settings cache (mirror of the server) --- + + fun loadSettings(): AppSettings = AppSettings( + defaultView = prefs.getString(K_DEFAULT_VIEW, null) ?: "month", + weekStartDay = prefs.getString(K_WEEK_START, null) ?: "monday", + primaryColor = prefs.getString(K_PRIMARY, null) ?: "#4285f4", + accentColor = prefs.getString(K_ACCENT, null) ?: "#ea4335", + todayColor = prefs.getString(K_TODAY, null) ?: "#4285f4", + dimPastEvents = prefs.getBoolean(K_DIM_PAST, false), + textContrast = prefs.getInt(K_TEXT_CONTRAST, 3), + lineContrast = prefs.getInt(K_LINE_CONTRAST, 3), + hourHeight = prefs.getInt(K_HOUR_HEIGHT, 60), + language = prefs.getString(K_LANGUAGE, null) ?: "de", + monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0", + monthLabelColor = prefs.getString(K_LABEL, null) ?: "#7090c0", + ) + + fun saveSettings(s: AppSettings) { + prefs.edit() + .putString(K_DEFAULT_VIEW, s.defaultView) + .putString(K_WEEK_START, s.weekStartDay) + .putString(K_PRIMARY, s.primaryColor) + .putString(K_ACCENT, s.accentColor) + .putString(K_TODAY, s.todayColor) + .putBoolean(K_DIM_PAST, s.dimPastEvents) + .putInt(K_TEXT_CONTRAST, s.textContrast) + .putInt(K_LINE_CONTRAST, s.lineContrast) + .putInt(K_HOUR_HEIGHT, s.hourHeight) + .putString(K_LANGUAGE, s.language) + .putString(K_DIVIDER, s.monthDividerColor) + .putString(K_LABEL, s.monthLabelColor) + .apply() + } + + /** Device-local cache range in months around today (default 3). */ + var cacheMonths: Int + get() = prefs.getInt(K_CACHE_MONTHS, 3) + set(value) = prefs.edit().putInt(K_CACHE_MONTHS, value).apply() + + // --- Hidden calendars ("source:id") --- + + var hiddenCalendarKeys: Set + get() = prefs.getStringSet(K_HIDDEN, emptySet())?.toSet() ?: emptySet() + set(value) = prefs.edit().putStringSet(K_HIDDEN, value).apply() + + // --- Banished calendars ("source:id") --- + + var banishedCalendarKeys: Set + get() = prefs.getStringSet(K_BANISHED, emptySet())?.toSet() ?: emptySet() + set(value) = prefs.edit().putStringSet(K_BANISHED, value).apply() + + private companion object { + const val K_DEFAULT_VIEW = "default_view" + const val K_WEEK_START = "week_start_day" + const val K_PRIMARY = "primary_color" + const val K_ACCENT = "accent_color" + const val K_TODAY = "today_color" + const val K_DIM_PAST = "dim_past_events" + const val K_TEXT_CONTRAST = "text_contrast" + const val K_LINE_CONTRAST = "line_contrast" + const val K_HOUR_HEIGHT = "hour_height" + const val K_LANGUAGE = "language" + const val K_DIVIDER = "month_divider_color" + const val K_LABEL = "month_label_color" + const val K_CACHE_MONTHS = "cache_months" + const val K_HIDDEN = "hidden_calendar_keys" + const val K_BANISHED = "banished_calendar_keys" + } +} 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 new file mode 100644 index 0000000..526e52b --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiException.kt @@ -0,0 +1,34 @@ +package com.scarriffle.calendarr.data.remote + +import okhttp3.ResponseBody +import org.json.JSONObject +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") + +/** Server requires a TOTP code to finish login (HTTP 401, detail "2fa_required"). */ +class TwoFactorRequiredException : ApiException("2FA-Code erforderlich") + +/** Extract the `detail` field from an error response body, with a fallback. */ +fun errorDetail(errorBody: ResponseBody?, status: Int): String { + val raw = runCatching { errorBody?.string() }.getOrNull() + if (!raw.isNullOrBlank()) { + runCatching { + val detail = JSONObject(raw).optString("detail") + if (detail.isNotBlank()) return detail + } + } + return "Fehler $status" +} + +/** Throw an [ApiException] if the response is unsuccessful. */ +fun Response<*>.ensureSuccess() { + if (!isSuccessful) { + if (code() == 401) throw UnauthorizedException() + throw ApiException(errorDetail(errorBody(), code())) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiProvider.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiProvider.kt new file mode 100644 index 0000000..f7c7979 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiProvider.kt @@ -0,0 +1,71 @@ +package com.scarriffle.calendarr.data.remote + +import com.scarriffle.calendarr.data.CredentialStore +import com.squareup.moshi.Moshi +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Builds [CalendarrApi] instances. The base URL is dynamic (the user's server), + * so the cached instance is rebuilt whenever the stored server URL changes. + */ +@Singleton +class ApiProvider @Inject constructor( + private val credentialStore: CredentialStore, + private val moshi: Moshi, + private val authInterceptor: AuthInterceptor, +) { + private val client: OkHttpClient by lazy { + OkHttpClient.Builder() + .addInterceptor(authInterceptor) + .addInterceptor( + HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC } + ) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build() + } + + @Volatile private var cached: Pair? = null + + /** API bound to the currently stored server URL. Requires a configured server. */ + fun api(): CalendarrApi { + val base = normalize(credentialStore.serverUrl ?: error("No server URL configured")) + cached?.let { (url, api) -> if (url == base) return api } + val api = build(base) + cached = base to api + return api + } + + /** API for an explicit base URL (used during server setup / login). */ + fun apiFor(baseUrl: String): CalendarrApi = build(normalize(baseUrl)) + + /** Force the cached instance to be rebuilt (e.g. after switching servers). */ + fun invalidate() { cached = null } + + private fun build(baseUrl: String): CalendarrApi = + Retrofit.Builder() + .baseUrl(baseUrl) + .client(client) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + .create(CalendarrApi::class.java) + + companion object { + /** Ensure https:// prefix, strip trailing slashes, then add exactly one. */ + fun normalize(raw: String): String { + var url = raw.trim() + if (url.isEmpty()) return url + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "https://$url" + } + url = url.trimEnd('/') + return "$url/" + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/AuthInterceptor.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/AuthInterceptor.kt new file mode 100644 index 0000000..ab34832 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/AuthInterceptor.kt @@ -0,0 +1,27 @@ +package com.scarriffle.calendarr.data.remote + +import com.scarriffle.calendarr.data.CredentialStore +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject + +/** + * Attaches the current bearer token (read dynamically from the + * [CredentialStore]) to every request. Requests made before login simply + * carry no Authorization header. + */ +class AuthInterceptor @Inject constructor( + private val credentialStore: CredentialStore, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val token = credentialStore.token + val request = if (!token.isNullOrBlank()) { + chain.request().newBuilder() + .header("Authorization", "Bearer $token") + .build() + } else { + chain.request() + } + return chain.proceed(request) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt new file mode 100644 index 0000000..65cb42a --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt @@ -0,0 +1,181 @@ +package com.scarriffle.calendarr.data.remote + +import com.scarriffle.calendarr.domain.model.AppSettings +import com.scarriffle.calendarr.domain.model.CalDAVAccount +import com.scarriffle.calendarr.domain.model.GoogleAccount +import com.scarriffle.calendarr.domain.model.HomeAssistantAccount +import com.scarriffle.calendarr.domain.model.ICalSubscription +import com.scarriffle.calendarr.domain.model.LocalCalendar +import com.scarriffle.calendarr.domain.model.UserProfile +import okhttp3.RequestBody +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.HTTP +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Retrofit definition of the Calendarr server HTTP API. + * + * Endpoints whose responses contain mixed-type fields (event `id` / + * `calendar_id` arrive as either String or Int) return a raw [ResponseBody] + * and are parsed manually with org.json, mirroring the iOS client. + */ +interface CalendarrApi { + + // ---- Auth ---- + + @GET("api/auth/setup-required") + suspend fun setupRequired(): Response + + @POST("api/auth/login") + suspend fun login(@Body body: RequestBody): Response + + @GET("api/auth/me") + suspend fun getProfile(): UserProfile + + // ---- Settings ---- + + @GET("api/settings/") + suspend fun getSettings(): AppSettings + + @PUT("api/settings/") + suspend fun updateSettings(@Body body: RequestBody): Response + + // ---- Profile ---- + + @PATCH("api/profile/") + suspend fun updateEmail(@Body body: RequestBody): Response + + @POST("api/profile/password") + suspend fun changePassword(@Body body: RequestBody): Response + + @POST("api/profile/2fa/setup") + suspend fun setup2fa(): Response + + @POST("api/profile/2fa/enable") + suspend fun enable2fa(@Body body: RequestBody): Response + + @POST("api/profile/2fa/disable") + suspend fun disable2fa(@Body body: RequestBody): Response + + // ---- CalDAV ---- + + @GET("api/caldav/accounts") + suspend fun getCalDAVAccounts(): List + + @POST("api/caldav/accounts") + suspend fun addCalDAVAccount(@Body body: RequestBody): CalDAVAccount + + @DELETE("api/caldav/accounts/{id}") + suspend fun deleteCalDAVAccount(@Path("id") id: Int): Response + + @POST("api/caldav/accounts/{id}/sync") + suspend fun syncCalDAVAccount(@Path("id") id: Int): Response + + @PUT("api/caldav/calendars/{id}") + suspend fun updateCalDAVCalendar(@Path("id") id: Int, @Body body: RequestBody): Response + + // ---- Local ---- + + @GET("api/local/calendars") + suspend fun getLocalCalendars(): List + + @POST("api/local/calendars") + suspend fun addLocalCalendar(@Body body: RequestBody): LocalCalendar + + @DELETE("api/local/calendars/{id}") + suspend fun deleteLocalCalendar(@Path("id") id: Int): Response + + // ---- iCal subscriptions ---- + + @GET("api/ical/subscriptions") + suspend fun getICalSubscriptions(): List + + @POST("api/ical/subscriptions") + suspend fun addICalSubscription(@Body body: RequestBody): ICalSubscription + + @DELETE("api/ical/subscriptions/{id}") + suspend fun deleteICalSubscription(@Path("id") id: Int): Response + + @POST("api/ical/subscriptions/{id}/refresh") + suspend fun refreshICalSubscription(@Path("id") id: Int): Response + + // ---- Google ---- + + @GET("api/google/accounts") + suspend fun getGoogleAccounts(): List + + @DELETE("api/google/accounts/{id}") + suspend fun deleteGoogleAccount(@Path("id") id: Int): Response + + @PUT("api/google/calendars/{id}") + suspend fun updateGoogleCalendar(@Path("id") id: Int, @Body body: RequestBody): Response + + // ---- Home Assistant ---- + + @GET("api/homeassistant/accounts") + suspend fun getHomeAssistantAccounts(): List + + @POST("api/homeassistant/accounts") + suspend fun addHomeAssistantAccount(@Body body: RequestBody): HomeAssistantAccount + + @DELETE("api/homeassistant/accounts/{id}") + suspend fun deleteHomeAssistantAccount(@Path("id") id: Int): Response + + @PUT("api/homeassistant/calendars/{id}") + suspend fun updateHACalendar(@Path("id") id: Int, @Body body: RequestBody): Response + + // ---- Events ---- + + @GET("api/caldav/events") + suspend fun fetchEvents( + @Query("start") start: String, + @Query("end") end: String, + ): Response + + @POST("api/local/events") + suspend fun createLocalEvent(@Body body: RequestBody): Response + + @PUT("api/local/events/{uid}") + suspend fun updateLocalEvent(@Path("uid") uid: String, @Body body: RequestBody): Response + + @DELETE("api/local/events/{uid}") + suspend fun deleteLocalEvent(@Path("uid") uid: String): Response + + @POST("api/caldav/events") + suspend fun createCalDAVEvent(@Body body: RequestBody): Response + + @PUT("api/caldav/events/{uid}") + suspend fun updateCalDAVEvent( + @Path("uid") uid: String, + @Query("event_url") eventUrl: String, + @Query("calendar_id") calendarId: Int?, + @Body body: RequestBody, + ): Response + + @HTTP(method = "DELETE", path = "api/caldav/events/{uid}", hasBody = false) + suspend fun deleteCalDAVEvent( + @Path("uid") uid: String, + @Query("event_url") eventUrl: String, + @Query("calendar_id") calendarId: Int?, + ): Response + + @POST("api/google/events") + suspend fun createGoogleEvent(@Body body: RequestBody): Response + + @POST("api/homeassistant/events") + suspend fun createHAEvent(@Body body: RequestBody): Response + + @DELETE("api/homeassistant/events/{calendarId}/{uid}") + suspend fun deleteHAEvent( + @Path("calendarId") calendarId: Int, + @Path("uid") uid: String, + ): Response +} diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/JsonBody.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/JsonBody.kt new file mode 100644 index 0000000..b6d08d5 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/JsonBody.kt @@ -0,0 +1,26 @@ +package com.scarriffle.calendarr.data.remote + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject + +private val JSON = "application/json; charset=utf-8".toMediaType() + +/** Build a JSON request body from key/value pairs, dropping null values. */ +fun jsonBody(vararg pairs: Pair): RequestBody { + val obj = JSONObject() + for ((k, v) in pairs) { + if (v != null) obj.put(k, v) + } + return obj.toString().toRequestBody(JSON) +} + +/** Build a JSON request body from a map, dropping null values. */ +fun jsonBody(map: Map): RequestBody { + val obj = JSONObject() + for ((k, v) in map) { + if (v != null) obj.put(k, v) + } + return obj.toString().toRequestBody(JSON) +} diff --git a/app/src/main/java/com/scarriffle/calendarr/di/AppModule.kt b/app/src/main/java/com/scarriffle/calendarr/di/AppModule.kt new file mode 100644 index 0000000..95ba7a7 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/di/AppModule.kt @@ -0,0 +1,21 @@ +package com.scarriffle.calendarr.di + +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + @Provides + @Singleton + fun provideMoshi(): Moshi = + Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() +} diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt new file mode 100644 index 0000000..aa41878 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt @@ -0,0 +1,97 @@ +package com.scarriffle.calendarr.domain.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = false) +data class CalDAVAccount( + val id: Int, + val name: String = "", + val url: String = "", + val username: String = "", + val color: String = "#4285f4", + val enabled: Boolean = true, + val calendars: List? = null, +) + +@JsonClass(generateAdapter = false) +data class CalDAVCalendar( + val id: Int, + val name: String = "", + val color: String? = null, + val enabled: Boolean = true, + @Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false, +) + +@JsonClass(generateAdapter = false) +data class LocalCalendar( + val id: Int, + val name: String = "", + val color: String = "#34a853", + val enabled: Boolean = true, +) + +@JsonClass(generateAdapter = false) +data class ICalSubscription( + val id: Int, + val name: String = "", + val url: String = "", + val color: String = "#46bdc6", + val enabled: Boolean = true, + @Json(name = "refresh_minutes") val refreshMinutes: Int = 60, + @Json(name = "last_fetched") val lastFetched: String? = null, +) + +@JsonClass(generateAdapter = false) +data class GoogleAccount( + val id: Int, + val email: String = "", + val calendars: List? = null, +) + +@JsonClass(generateAdapter = false) +data class GoogleCalendar( + val id: Int, + val name: String = "", + val color: String? = null, + val enabled: Boolean = true, + @Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false, +) + +@JsonClass(generateAdapter = false) +data class HomeAssistantAccount( + val id: Int, + val name: String = "", + val url: String = "", + @Json(name = "auth_method") val authMethod: String = "token", + val calendars: List? = null, +) + +@JsonClass(generateAdapter = false) +data class HACalendar( + val id: Int, + val name: String = "", + @Json(name = "entity_id") val entityId: String = "", + val color: String? = null, + val enabled: Boolean = true, + @Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false, +) + +@JsonClass(generateAdapter = false) +data class UserProfile( + val id: Int, + val username: String = "", + val email: String? = null, + @Json(name = "is_admin") val isAdmin: Boolean = false, + @Json(name = "has_avatar") val hasAvatar: Boolean = false, + @Json(name = "totp_enabled") val totpEnabled: Boolean = false, +) + +/** A calendar the user can create events in (resolved from all writable sources). */ +data class WritableCalendar( + val id: String, + val name: String, + val color: String, + val source: String, + val numericId: Int, +) diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt new file mode 100644 index 0000000..2e5f5d0 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt @@ -0,0 +1,27 @@ +package com.scarriffle.calendarr.domain.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * User appearance / behaviour settings, synced with the server. + * Mirrors iOS `AppSettings`. Missing keys fall back to the defaults below + * (the server only persists a subset). + */ +@JsonClass(generateAdapter = false) +data class AppSettings( + @Json(name = "default_view") val defaultView: String = "month", + @Json(name = "week_start_day") val weekStartDay: String = "monday", + @Json(name = "primary_color") val primaryColor: String = "#4285f4", + @Json(name = "accent_color") val accentColor: String = "#ea4335", + @Json(name = "today_color") val todayColor: String = "#4285f4", + @Json(name = "dim_past_events") val dimPastEvents: Boolean = false, + @Json(name = "text_contrast") val textContrast: Int = 3, + @Json(name = "line_contrast") val lineContrast: Int = 3, + @Json(name = "hour_height") val hourHeight: Int = 60, + @Json(name = "language") val language: String = "de", + @Json(name = "month_divider_color") val monthDividerColor: String = "#7090c0", + @Json(name = "month_label_color") val monthLabelColor: String = "#7090c0", +) { + val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday" +} diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt new file mode 100644 index 0000000..a67ff6a --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt @@ -0,0 +1,71 @@ +package com.scarriffle.calendarr.domain.model + +import com.scarriffle.calendarr.util.Dates +import org.json.JSONObject +import java.time.Instant + +/** + * A unified calendar event, blended from all server sources + * (local, caldav, google, ical, homeassistant). Mirrors iOS `CalEvent`. + */ +data class CalEvent( + val id: String, + val url: String, + val title: String, + val startDate: Instant, + val endDate: Instant, + val isAllDay: Boolean, + val location: String, + val notes: String, + val color: String?, + val calendarId: String, + val calendarName: String, + val calendarColor: String, + val source: String, +) { + /** Per-event override colour, falling back to the calendar's colour. */ + val effectiveColor: String get() = color?.takeIf { it.isNotBlank() } ?: calendarColor + + companion object { + /** Parse one event object from the `/api/caldav/events` aggregate response. */ + fun fromJson(json: JSONObject): CalEvent? { + val title = json.optString("title").takeIf { json.has("title") } ?: return null + val startStr = json.optString("start").takeIf { json.has("start") } ?: return null + val endStr = json.optString("end").takeIf { json.has("end") } ?: return null + + // id may be a String (local UUID) or an Int (CalDAV numeric) + val id: String = when (val raw = json.opt("id")) { + is String -> raw + is Number -> raw.toString() + else -> return null + } + + val isAllDay = json.optBoolean("allDay", false) + val start = Dates.parse(startStr, isAllDay) ?: return null + val end = Dates.parse(endStr, isAllDay) ?: return null + + // calendar_id arrives as raw numeric (caldav) or "-" string + val calendarId = when (val raw = json.opt("calendar_id")) { + null, JSONObject.NULL -> "" + else -> raw.toString() + } + + val colorRaw = json.optString("color", "") + return CalEvent( + id = id, + url = json.optString("url", ""), + title = title, + startDate = start, + endDate = end, + isAllDay = isAllDay, + location = json.optString("location", ""), + notes = json.optString("description", ""), + color = colorRaw.takeIf { it.isNotBlank() }, + calendarId = calendarId, + calendarName = json.optString("calendar_name", ""), + calendarColor = json.optString("calendarColor", "#4285f4"), + source = json.optString("source", "local"), + ) + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalViewType.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalViewType.kt new file mode 100644 index 0000000..cb67305 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalViewType.kt @@ -0,0 +1,31 @@ +package com.scarriffle.calendarr.domain.model + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.CalendarViewWeek +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.ViewList +import androidx.compose.material.icons.filled.WbSunny +import androidx.compose.ui.graphics.vector.ImageVector + +enum class CalViewType(val key: String) { + MONTH("month"), + WEEK("week"), + DAY("day"), + QUARTER("quarter"), + AGENDA("agenda"); + + val icon: ImageVector + get() = when (this) { + MONTH -> Icons.Filled.CalendarMonth + WEEK -> Icons.Filled.CalendarViewWeek + DAY -> Icons.Filled.WbSunny + QUARTER -> Icons.Filled.DateRange + AGENDA -> Icons.Filled.ViewList + } + + companion object { + fun fromKey(key: String?): CalViewType = + entries.firstOrNull { it.key == key } ?: MONTH + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt new file mode 100644 index 0000000..5170248 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -0,0 +1,220 @@ +package com.scarriffle.calendarr.ui + +import java.util.Locale + +/** + * In-app localization mirroring the iOS `L10n`. The stored language may be + * "de", "en" or "system" (resolve to device language). + */ +object L10n { + + fun resolved(stored: String): String { + if (stored == "de" || stored == "en") return stored + val pref = Locale.getDefault().language.lowercase() + return if (pref.startsWith("de")) "de" else "en" + } + + fun locale(stored: String): Locale = Locale(resolved(stored)) + + fun t(key: String, stored: String): String { + val lang = resolved(stored) + return strings[lang]?.get(key) ?: strings["en"]?.get(key) ?: key + } + + fun t(key: String, stored: String, vararg args: Any): String { + val template = t(key, stored) + // Support both %@ (iOS) and %s / %d (Java) placeholders. + val javaTemplate = template.replace("%@", "%s") + return runCatching { String.format(locale(stored), javaTemplate, *args) } + .getOrDefault(template) + } + + private val strings: Map> = mapOf( + "de" to mapOf( + "nav.today" to "Heute", "nav.menu" to "Menü", "nav.done" to "Fertig", + "view.month" to "Monat", "view.week" to "Woche", "view.day" to "Tag", + "view.quarter" to "Quartal", "view.agenda" to "Termine", "view.change" to "Ansicht", + "cal.cw" to "KW", "cal.allday" to "Ganztägig", + "cal.no_events_title" to "Keine Termine", + "cal.no_events_body" to "In den nächsten 90 Tagen sind keine Termine vorhanden.", + "cal.loading_more" to "Lade weitere Wochen…", "cal.new_event" to "Neues Ereignis", + "menu.section.settings" to "Einstellungen", "menu.profile" to "Profil", + "menu.appearance" to "Darstellung", "menu.accounts" to "Konten & Kalender", + "menu.server" to "Server", "menu.logout" to "Abmelden", "menu.admin" to "Admin", + "menu.sync" to "Mit Server synchronisieren", "menu.sync.section" to "Synchronisierung", + "settings.title" to "Darstellung", "settings.loading" to "Lade Einstellungen…", + "settings.save" to "Speichern", "settings.saved" to "Gespeichert", + "settings.cache.title" to "Vorladen", "settings.cache.range" to "Zeitraum", + "settings.cache.1m" to "±1 Monat", "settings.cache.3m" to "±3 Monate", + "settings.cache.6m" to "±6 Monate", "settings.cache.1y" to "±1 Jahr", + "settings.language" to "Sprache", "lang.system" to "Systemstandard", + "lang.german" to "Deutsch", "lang.english" to "English", + "settings.colors" to "Farben", "settings.color.primary" to "Primärfarbe", + "settings.color.accent" to "Akzentfarbe", "settings.color.today" to "Heutige-Tag-Farbe", + "settings.color.divider" to "Monatswechsel-Linie", "settings.color.label" to "Monatskürzel", + "settings.textcontrast" to "Schriftkontrast", "settings.linecontrast" to "Linienkontrast", + "settings.calview" to "Kalenderansicht", "settings.defaultview" to "Standardansicht", + "settings.firstweekday" to "Erster Wochentag", "settings.monday" to "Montag", + "settings.sunday" to "Sonntag", "settings.dimpast" to "Vergangene Termine ausgrauen", + "settings.hourheight" to "Stundenhöhe", + "common.cancel" to "Abbrechen", "common.close" to "Schliessen", + "common.ok" to "OK", "common.error" to "Fehler", "common.delete" to "Löschen", + "common.save" to "Sichern", "common.retry" to "Erneut versuchen", + "server.title" to "Server", "server.connected" to "Verbundener Server", + "server.switch" to "Server wechseln", "server.logout_title" to "Abmelden", + "server.version" to "Version", + "profile.title" to "Profil", "profile.loading" to "Lade Profil…", + "profile.account" to "Konto", "profile.username" to "Benutzername", + "profile.role" to "Rolle", "profile.role.admin" to "Administrator", + "profile.role.user" to "Benutzer", "profile.email" to "E-Mail", + "profile.no_email" to "Keine E-Mail", "profile.save_email" to "E-Mail speichern", + "profile.email_saved" to "E-Mail gespeichert", "profile.change_password" to "Passwort ändern", + "profile.current_password" to "Aktuelles Passwort", "profile.new_password" to "Neues Passwort", + "profile.new_password_repeat" to "Neues Passwort wiederholen", + "profile.password_mismatch" to "Passwörter stimmen nicht überein", + "profile.password_changed" to "Passwort geändert", + "profile.twofa" to "Zwei-Faktor-Authentifizierung", + "profile.twofa.active" to "2FA ist aktiviert", "profile.twofa.inactive" to "2FA ist deaktiviert", + "profile.twofa.enable" to "2FA einrichten", "profile.twofa.disable" to "2FA deaktivieren", + "twofa.setup_title" to "2FA einrichten", + "twofa.scan_hint" to "Scanne den QR-Code mit deiner Authenticator-App.", + "twofa.code_placeholder" to "6-stelliger Code", "twofa.activate" to "Aktivieren", + "twofa.disable_title" to "2FA deaktivieren", "twofa.password_placeholder" to "Passwort", + "twofa.disable" to "Deaktivieren", + "event.title_placeholder" to "Titel", "event.allday" to "Ganztägig", + "event.start" to "Start", "event.end" to "Ende", "event.location" to "Ort", + "event.description" to "Beschreibung", "event.calendar_section" to "Kalender", + "event.no_writable" to "Keine beschreibbaren Kalender vorhanden", + "event.calendar_picker" to "Kalender", "event.color_section" to "Farbe", + "event.color" to "Terminfarbe", "event.reset_color" to "Zurücksetzen", + "event.edit_title" to "Termin bearbeiten", "event.new_title" to "Neuer Termin", + "event.save" to "Sichern", "event.add" to "Hinzufügen", + "event.delete_confirm" to "Diesen Termin löschen?", + "accounts.title" to "Konten", "accounts.loading" to "Lade Konten…", + "accounts.caldav.header" to "CalDAV-Konten", "accounts.caldav.empty" to "Keine CalDAV-Konten", + "accounts.caldav.add" to "CalDAV hinzufügen", "accounts.local.header" to "Lokale Kalender", + "accounts.local.empty" to "Keine lokalen Kalender", "accounts.local.add" to "Lokalen Kalender erstellen", + "accounts.ical.header" to "iCal-Abonnements", "accounts.ical.empty" to "Keine Abonnements", + "accounts.ical.add" to "iCal-URL abonnieren", "accounts.google.header" to "Google-Konten", + "accounts.google.empty" to "Keine Google-Konten", + "accounts.google.hint" to "Google-Konten werden über den Browser verknüpft", + "accounts.ha.header" to "Home Assistant", "accounts.ha.empty" to "Keine Home Assistant-Konten", + "accounts.ha.add" to "Home Assistant hinzufügen", + "filter.title" to "Kalender", "filter.empty" to "Keine Kalender vorhanden", + "filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden", + "filter.button" to "Kalender ein-/ausblenden", + "caldav.display_name" to "Anzeigename", "caldav.url" to "CalDAV-URL", + "caldav.username" to "Benutzername", "caldav.password" to "Passwort", + "caldav.color" to "Farbe", "caldav.connect" to "Verbinden", "caldav.title" to "CalDAV-Konto", + "local.title" to "Lokaler Kalender", "local.name" to "Name", "local.color" to "Farbe", + "local.create" to "Erstellen", + "ical.title" to "iCal abonnieren", "ical.name" to "Name", "ical.url" to "iCal-URL", + "ical.color" to "Farbe", "ical.interval" to "Intervall", "ical.subscribe" to "Abonnieren", + "ical.refresh.15m" to "Alle 15 Min.", "ical.refresh.30m" to "Alle 30 Min.", + "ical.refresh.1h" to "Stündlich", "ical.refresh.6h" to "Alle 6 Std.", "ical.refresh.1d" to "Täglich", + "ha.display_name" to "Anzeigename", "ha.url_placeholder" to "URL (z.B. http://homeassistant.local:8123)", + "ha.token" to "Long-Lived Access Token", "ha.connect" to "Verbinden", + // Auth screens + "auth.server_title" to "Server verbinden", "auth.server_url" to "Server-URL", + "auth.server_hint" to "Gib die Adresse deines Calendarr-Servers ein.", + "auth.continue" to "Weiter", "auth.checking" to "Verbinde…", + "auth.login_title" to "Anmelden", "auth.username" to "Benutzername", + "auth.password" to "Passwort", "auth.login" to "Anmelden", + "auth.totp" to "2FA-Code", "auth.remember" to "Angemeldet bleiben", + "auth.back" to "Zurück", + ), + "en" to mapOf( + "nav.today" to "Today", "nav.menu" to "Menu", "nav.done" to "Done", + "view.month" to "Month", "view.week" to "Week", "view.day" to "Day", + "view.quarter" to "Quarter", "view.agenda" to "Agenda", "view.change" to "View", + "cal.cw" to "W", "cal.allday" to "All-day", + "cal.no_events_title" to "No events", + "cal.no_events_body" to "No events in the next 90 days.", + "cal.loading_more" to "Loading more weeks…", "cal.new_event" to "New event", + "menu.section.settings" to "Settings", "menu.profile" to "Profile", + "menu.appearance" to "Appearance", "menu.accounts" to "Accounts & Calendars", + "menu.server" to "Server", "menu.logout" to "Sign out", "menu.admin" to "Admin", + "menu.sync" to "Sync with server", "menu.sync.section" to "Synchronization", + "settings.title" to "Appearance", "settings.loading" to "Loading settings…", + "settings.save" to "Save", "settings.saved" to "Saved", + "settings.cache.title" to "Preloading", "settings.cache.range" to "Range", + "settings.cache.1m" to "±1 month", "settings.cache.3m" to "±3 months", + "settings.cache.6m" to "±6 months", "settings.cache.1y" to "±1 year", + "settings.language" to "Language", "lang.system" to "System default", + "lang.german" to "Deutsch", "lang.english" to "English", + "settings.colors" to "Colors", "settings.color.primary" to "Primary color", + "settings.color.accent" to "Accent color", "settings.color.today" to "Today color", + "settings.color.divider" to "Month divider line", "settings.color.label" to "Month abbreviation", + "settings.textcontrast" to "Text contrast", "settings.linecontrast" to "Line contrast", + "settings.calview" to "Calendar view", "settings.defaultview" to "Default view", + "settings.firstweekday" to "First day of week", "settings.monday" to "Monday", + "settings.sunday" to "Sunday", "settings.dimpast" to "Dim past events", + "settings.hourheight" to "Hour height", + "common.cancel" to "Cancel", "common.close" to "Close", + "common.ok" to "OK", "common.error" to "Error", "common.delete" to "Delete", + "common.save" to "Save", "common.retry" to "Retry", + "server.title" to "Server", "server.connected" to "Connected server", + "server.switch" to "Switch server", "server.logout_title" to "Sign out", + "server.version" to "Version", + "profile.title" to "Profile", "profile.loading" to "Loading profile…", + "profile.account" to "Account", "profile.username" to "Username", + "profile.role" to "Role", "profile.role.admin" to "Administrator", + "profile.role.user" to "User", "profile.email" to "Email", + "profile.no_email" to "No email", "profile.save_email" to "Save email", + "profile.email_saved" to "Email saved", "profile.change_password" to "Change password", + "profile.current_password" to "Current password", "profile.new_password" to "New password", + "profile.new_password_repeat" to "Repeat new password", + "profile.password_mismatch" to "Passwords don't match", + "profile.password_changed" to "Password changed", + "profile.twofa" to "Two-factor authentication", + "profile.twofa.active" to "2FA is enabled", "profile.twofa.inactive" to "2FA is disabled", + "profile.twofa.enable" to "Set up 2FA", "profile.twofa.disable" to "Disable 2FA", + "twofa.setup_title" to "Set up 2FA", + "twofa.scan_hint" to "Scan the QR code with your authenticator app.", + "twofa.code_placeholder" to "6-digit code", "twofa.activate" to "Activate", + "twofa.disable_title" to "Disable 2FA", "twofa.password_placeholder" to "Password", + "twofa.disable" to "Disable", + "event.title_placeholder" to "Title", "event.allday" to "All-day", + "event.start" to "Start", "event.end" to "End", "event.location" to "Location", + "event.description" to "Description", "event.calendar_section" to "Calendar", + "event.no_writable" to "No writable calendars available", + "event.calendar_picker" to "Calendar", "event.color_section" to "Color", + "event.color" to "Event color", "event.reset_color" to "Reset", + "event.edit_title" to "Edit event", "event.new_title" to "New event", + "event.save" to "Save", "event.add" to "Add", + "event.delete_confirm" to "Delete this event?", + "accounts.title" to "Accounts", "accounts.loading" to "Loading accounts…", + "accounts.caldav.header" to "CalDAV accounts", "accounts.caldav.empty" to "No CalDAV accounts", + "accounts.caldav.add" to "Add CalDAV", "accounts.local.header" to "Local calendars", + "accounts.local.empty" to "No local calendars", "accounts.local.add" to "Create local calendar", + "accounts.ical.header" to "iCal subscriptions", "accounts.ical.empty" to "No subscriptions", + "accounts.ical.add" to "Subscribe to iCal URL", "accounts.google.header" to "Google accounts", + "accounts.google.empty" to "No Google accounts", + "accounts.google.hint" to "Google accounts are linked via the browser", + "accounts.ha.header" to "Home Assistant", "accounts.ha.empty" to "No Home Assistant accounts", + "accounts.ha.add" to "Add Home Assistant", + "filter.title" to "Calendars", "filter.empty" to "No calendars available", + "filter.show_all" to "Show all", "filter.hide_all" to "Hide all", + "filter.button" to "Show/hide calendars", + "caldav.display_name" to "Display name", "caldav.url" to "CalDAV URL", + "caldav.username" to "Username", "caldav.password" to "Password", + "caldav.color" to "Color", "caldav.connect" to "Connect", "caldav.title" to "CalDAV account", + "local.title" to "Local calendar", "local.name" to "Name", "local.color" to "Color", + "local.create" to "Create", + "ical.title" to "Subscribe to iCal", "ical.name" to "Name", "ical.url" to "iCal URL", + "ical.color" to "Color", "ical.interval" to "Interval", "ical.subscribe" to "Subscribe", + "ical.refresh.15m" to "Every 15 min", "ical.refresh.30m" to "Every 30 min", + "ical.refresh.1h" to "Hourly", "ical.refresh.6h" to "Every 6 hours", "ical.refresh.1d" to "Daily", + "ha.display_name" to "Display name", "ha.url_placeholder" to "URL (e.g. http://homeassistant.local:8123)", + "ha.token" to "Long-Lived Access Token", "ha.connect" to "Connect", + // Auth screens + "auth.server_title" to "Connect server", "auth.server_url" to "Server URL", + "auth.server_hint" to "Enter the address of your Calendarr server.", + "auth.continue" to "Continue", "auth.checking" to "Connecting…", + "auth.login_title" to "Sign in", "auth.username" to "Username", + "auth.password" to "Password", "auth.login" to "Sign in", + "auth.totp" to "2FA code", "auth.remember" to "Stay signed in", + "auth.back" to "Back", + ), + ) +} diff --git a/app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt b/app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt new file mode 100644 index 0000000..3220474 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt @@ -0,0 +1,44 @@ +package com.scarriffle.calendarr.util + +import androidx.compose.ui.graphics.Color + +/** Parse a "#RRGGBB" (or "RRGGBB") hex string into a Compose [Color]. */ +fun colorFromHex(hex: String?, fallback: Color = Color(0xFF4285F4)): Color { + if (hex.isNullOrBlank()) return fallback + val clean = hex.trim().removePrefix("#") + return when (clean.length) { + 6 -> runCatching { + val v = clean.toLong(16) + Color( + red = ((v shr 16) and 0xFF) / 255f, + green = ((v shr 8) and 0xFF) / 255f, + blue = (v and 0xFF) / 255f, + alpha = 1f, + ) + }.getOrDefault(fallback) + 8 -> runCatching { + val v = clean.toLong(16) + Color( + alpha = ((v shr 24) and 0xFF) / 255f, + red = ((v shr 16) and 0xFF) / 255f, + green = ((v shr 8) and 0xFF) / 255f, + blue = (v and 0xFF) / 255f, + ) + }.getOrDefault(fallback) + else -> fallback + } +} + +/** Convert a Compose [Color] back to a "#RRGGBB" string. */ +fun Color.toHex(): String { + val r = (red * 255).toInt().coerceIn(0, 255) + val g = (green * 255).toInt().coerceIn(0, 255) + val b = (blue * 255).toInt().coerceIn(0, 255) + return String.format("#%02X%02X%02X", r, g, b) +} + +/** Pick black or white text for legibility on a given background color. */ +fun Color.contrastingTextColor(): Color { + val luminance = 0.299 * red + 0.587 * green + 0.114 * blue + return if (luminance > 0.6) Color.Black else Color.White +} diff --git a/app/src/main/java/com/scarriffle/calendarr/util/Dates.kt b/app/src/main/java/com/scarriffle/calendarr/util/Dates.kt new file mode 100644 index 0000000..1f42f10 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/util/Dates.kt @@ -0,0 +1,77 @@ +package com.scarriffle.calendarr.util + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +/** + * Date parsing/formatting that mirrors the iOS client (CalEvent.swift). + * + * The backend may produce any of these forms: + * "2026-05-17" (all-day, date only) + * "2026-05-17T10:00:00Z" + * "2026-05-17T10:00:00+02:00" + * "2026-05-17T10:00:00.000Z" + * "2026-05-17T10:00:00" (no timezone -> treat as UTC) + * "2026-05-17 10:00:00+00:00" (Python isoformat, space separator) + */ +object Dates { + + private val zone: ZoneId get() = ZoneId.systemDefault() + + private val spaceSep: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssXXX") + private val noTz: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss") + + /** Parse a server date string into an [Instant]. Returns null if unparseable. */ + fun parse(raw: String, allDay: Boolean): Instant? { + val s = raw.trim() + if (s.isEmpty()) return null + + if (allDay || (s.length == 10 && !s.contains('T'))) { + return runCatching { + LocalDate.parse(s.take(10)).atStartOfDay(zone).toInstant() + }.getOrNull() + } + // ISO with offset / Z + runCatching { return OffsetDateTime.parse(s).toInstant() } + runCatching { return Instant.parse(s) } + // Python isoformat with a space + runCatching { return OffsetDateTime.parse(s, spaceSep).toInstant() } + // No timezone -> UTC + runCatching { + return LocalDateTime.parse(s.take(19), noTz).toInstant(ZoneOffset.UTC) + } + // Last resort: just the date part + return runCatching { + LocalDate.parse(s.take(10)).atStartOfDay(zone).toInstant() + }.getOrNull() + } + + /** Format an [Instant] for sending to the server. */ + fun format(instant: Instant, allDay: Boolean): String { + return if (allDay) { + LocalDate.ofInstant(instant, zone).toString() // yyyy-MM-dd + } else { + // ISO8601 with Z, no fractional seconds (matches iOS isoBasic) + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") + .withZone(ZoneOffset.UTC) + .format(instant) + } + } + + /** ISO8601 UTC string used for the events query range (`?start=&end=`). */ + fun isoUtc(instant: Instant): String = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") + .withZone(ZoneOffset.UTC) + .format(instant) + + fun localDate(instant: Instant): LocalDate = LocalDate.ofInstant(instant, zone) + + fun startOfDay(date: LocalDate): Instant = date.atStartOfDay(zone).toInstant() +} From aff6cef4930a4d22c9f1996d0e60f41c58ed7da6 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 12:22:56 +0200 Subject: [PATCH 03/59] 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 --- app/build.gradle.kts | 2 +- .../com/scarriffle/calendarr/MainActivity.kt | 17 +- .../scarriffle/calendarr/ui/CalendarrRoot.kt | 45 +++ .../scarriffle/calendarr/ui/LocalProviders.kt | 22 ++ .../scarriffle/calendarr/ui/MainViewModel.kt | 79 ++++ .../calendarr/ui/accounts/AccountsScreen.kt | 241 +++++++++++++ .../ui/accounts/AccountsViewModel.kt | 88 +++++ .../calendarr/ui/auth/AuthViewModel.kt | 97 +++++ .../calendarr/ui/auth/LoginScreen.kt | 124 +++++++ .../calendarr/ui/auth/ServerSetupScreen.kt | 87 +++++ .../calendarr/ui/calendar/AgendaView.kt | 109 ++++++ .../ui/calendar/CalendarFilterSheet.kt | 83 +++++ .../ui/calendar/CalendarFormatting.kt | 87 +++++ .../calendarr/ui/calendar/CalendarScreen.kt | 261 ++++++++++++++ .../ui/calendar/CalendarViewModel.kt | 340 ++++++++++++++++++ .../calendarr/ui/calendar/MonthView.kt | 166 +++++++++ .../calendarr/ui/calendar/QuarterView.kt | 104 ++++++ .../calendarr/ui/calendar/TimeGridView.kt | 219 +++++++++++ .../calendarr/ui/event/EventDetailSheet.kt | 123 +++++++ .../calendarr/ui/event/EventEditorSheet.kt | 275 ++++++++++++++ .../scarriffle/calendarr/ui/menu/MenuSheet.kt | 74 ++++ .../calendarr/ui/profile/ProfileScreen.kt | 163 +++++++++ .../calendarr/ui/profile/ProfileViewModel.kt | 91 +++++ .../calendarr/ui/settings/SettingsScreen.kt | 190 ++++++++++ .../ui/settings/SettingsViewModel.kt | 30 ++ .../scarriffle/calendarr/ui/theme/Theme.kt | 47 ++- 26 files changed, 3129 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/LocalProviders.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/auth/AuthViewModel.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/auth/LoginScreen.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/auth/ServerSetupScreen.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/calendar/QuarterView.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileScreen.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileViewModel.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d5956c3..f4833dc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -87,7 +87,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.6") + androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01")) androidTestImplementation("androidx.compose.ui:ui-test-junit4") diff --git a/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt b/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt index 029eafb..db5e8e7 100644 --- a/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt +++ b/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt @@ -3,14 +3,7 @@ package com.scarriffle.calendarr import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import com.scarriffle.calendarr.ui.theme.CalendarrTheme +import com.scarriffle.calendarr.ui.CalendarrRoot import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint @@ -18,13 +11,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - CalendarrTheme { - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { - Text(text = "Calendarr Android", style = MaterialTheme.typography.headlineMedium) - } - } - } + CalendarrRoot() } } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt new file mode 100644 index 0000000..b61e566 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt @@ -0,0 +1,45 @@ +package com.scarriffle.calendarr.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.ui.auth.LoginScreen +import com.scarriffle.calendarr.ui.auth.ServerSetupScreen +import com.scarriffle.calendarr.ui.calendar.CalendarScreen +import com.scarriffle.calendarr.ui.theme.CalendarrTheme + +@Composable +fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) { + val route by vm.route.collectAsState() + val settings by vm.settings.collectAsState() + + CalendarrTheme(settings) { + CompositionLocalProvider( + LocalLang provides L10n.resolved(settings.language), + LocalAppSettings provides settings, + ) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + when (route) { + AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured) + AppRoute.LOGIN -> LoginScreen( + serverUrl = vm.serverUrl, + onLoggedIn = vm::onLoggedIn, + onBack = vm::switchServer, + ) + AppRoute.MAIN -> CalendarScreen( + onLogout = vm::logout, + onSwitchServer = vm::switchServer, + onSettingsChanged = vm::applyLocalSettings, + onSettingsSynced = vm::refreshSettings, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/LocalProviders.kt b/app/src/main/java/com/scarriffle/calendarr/ui/LocalProviders.kt new file mode 100644 index 0000000..e11c9ff --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/LocalProviders.kt @@ -0,0 +1,22 @@ +package com.scarriffle.calendarr.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf +import com.scarriffle.calendarr.domain.model.AppSettings + +/** Resolved UI language ("de" / "en"), provided at the root. */ +val LocalLang = compositionLocalOf { "de" } + +/** Current appearance settings, provided at the root. */ +val LocalAppSettings = staticCompositionLocalOf { AppSettings() } + +/** Convenience translation lookup that reads the ambient language. */ +@Composable +@ReadOnlyComposable +fun tr(key: String): String = L10n.t(key, LocalLang.current) + +@Composable +@ReadOnlyComposable +fun tr(key: String, vararg args: Any): String = L10n.t(key, LocalLang.current, *args) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt new file mode 100644 index 0000000..1714702 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt @@ -0,0 +1,79 @@ +package com.scarriffle.calendarr.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.scarriffle.calendarr.data.CalendarRepository +import com.scarriffle.calendarr.data.CredentialStore +import com.scarriffle.calendarr.data.SettingsStore +import com.scarriffle.calendarr.data.remote.ApiProvider +import com.scarriffle.calendarr.domain.model.AppSettings +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +enum class AppRoute { SETUP, LOGIN, MAIN } + +@HiltViewModel +class MainViewModel @Inject constructor( + private val credentialStore: CredentialStore, + private val settingsStore: SettingsStore, + private val repository: CalendarRepository, + private val apiProvider: ApiProvider, +) : ViewModel() { + + private val _route = MutableStateFlow(computeRoute()) + val route: StateFlow = _route.asStateFlow() + + private val _settings = MutableStateFlow(settingsStore.loadSettings()) + val settings: StateFlow = _settings.asStateFlow() + + val serverUrl: String get() = credentialStore.serverUrl ?: "" + val username: String get() = credentialStore.username ?: "" + val isAdmin: Boolean get() = credentialStore.isAdmin + + init { + if (_route.value == AppRoute.MAIN) refreshSettings() + } + + private fun computeRoute(): AppRoute = when { + !credentialStore.isConfigured -> AppRoute.SETUP + !credentialStore.isLoggedIn -> AppRoute.LOGIN + else -> AppRoute.MAIN + } + + fun onServerConfigured() { _route.value = computeRoute() } + + fun onLoggedIn() { + _route.value = AppRoute.MAIN + refreshSettings() + } + + fun logout() { + credentialStore.clearToken() + _route.value = computeRoute() + } + + fun switchServer() { + credentialStore.clearAll() + apiProvider.invalidate() + _route.value = computeRoute() + } + + /** Pull appearance settings from the server, caching them locally. */ + fun refreshSettings() { + viewModelScope.launch { + runCatching { repository.getSettings() }.onSuccess { s -> + settingsStore.saveSettings(s) + _settings.value = s + } + } + } + + fun applyLocalSettings(s: AppSettings) { + settingsStore.saveSettings(s) + _settings.value = s + } +} 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 new file mode 100644 index 0000000..9888702 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt @@ -0,0 +1,241 @@ +package com.scarriffle.calendarr.ui.accounts + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.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.tr +import com.scarriffle.calendarr.util.colorFromHex + +private enum class AddType { LOCAL, CALDAV, ICAL, HA } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountsScreen( + onClose: () -> Unit, + onChanged: () -> Unit, + vm: AccountsViewModel = hiltViewModel(), +) { + var addDialog by remember { mutableStateOf(null) } + + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Scaffold( + topBar = { + androidx.compose.material3.TopAppBar( + title = { Text(tr("accounts.title")) }, + navigationIcon = { + IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) } + }, + ) + }, + ) { padding -> + if (vm.loading) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + return@Scaffold + } + LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { + // Local + item { SectionHeader(tr("accounts.local.header"), tr("accounts.local.add")) { addDialog = AddType.LOCAL } } + if (vm.local.isEmpty()) item { EmptyRow(tr("accounts.local.empty")) } + items(vm.local, key = { "l${it.id}" }) { cal -> + AccountRow(cal.name, cal.color) { vm.deleteLocal(cal.id, onChanged) } + } + + // CalDAV + item { SectionHeader(tr("accounts.caldav.header"), tr("accounts.caldav.add")) { addDialog = AddType.CALDAV } } + if (vm.caldav.isEmpty()) item { EmptyRow(tr("accounts.caldav.empty")) } + items(vm.caldav, key = { "c${it.id}" }) { acc -> + AccountRow("${acc.name} (${acc.username})", acc.color) { vm.deleteCalDAV(acc.id, onChanged) } + } + + // iCal + item { SectionHeader(tr("accounts.ical.header"), tr("accounts.ical.add")) { addDialog = AddType.ICAL } } + if (vm.ical.isEmpty()) item { EmptyRow(tr("accounts.ical.empty")) } + items(vm.ical, key = { "i${it.id}" }) { sub -> + AccountRow(sub.name, sub.color) { vm.deleteICal(sub.id, onChanged) } + } + + // Google + item { SectionHeaderNoAdd(tr("accounts.google.header")) } + if (vm.google.isEmpty()) item { EmptyRow(tr("accounts.google.hint")) } + items(vm.google, key = { "g${it.id}" }) { acc -> + AccountRow(acc.email, "#4285f4") { vm.deleteGoogle(acc.id, onChanged) } + } + + // Home Assistant + item { SectionHeader(tr("accounts.ha.header"), tr("accounts.ha.add")) { addDialog = AddType.HA } } + if (vm.homeAssistant.isEmpty()) item { EmptyRow(tr("accounts.ha.empty")) } + items(vm.homeAssistant, key = { "h${it.id}" }) { acc -> + AccountRow(acc.name, "#46bdc6") { vm.deleteHA(acc.id, onChanged) } + } + + vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } } + item { Spacer(Modifier.size(40.dp)) } + } + } + } + + when (addDialog) { + AddType.LOCAL -> LocalDialog(onDismiss = { addDialog = null }) { name, color -> + vm.addLocal(name, color, onChanged); addDialog = null + } + AddType.CALDAV -> CalDAVDialog(onDismiss = { addDialog = null }) { n, u, us, p, c -> + vm.addCalDAV(n, u, us, p, c, onChanged); addDialog = null + } + AddType.ICAL -> ICalDialog(onDismiss = { addDialog = null }) { n, u, c, r -> + vm.addICal(n, u, c, r, onChanged); addDialog = null + } + AddType.HA -> HADialog(onDismiss = { addDialog = null }) { n, u, t -> + vm.addHA(n, u, t, onChanged); addDialog = null + } + null -> Unit + } +} + +@Composable +private fun SectionHeader(title: String, addLabel: String, onAdd: () -> Unit) { + Row( + Modifier.fillMaxWidth().padding(top = 20.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + TextButton(onClick = onAdd) { + Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(4.dp)) + Text(addLabel) + } + } +} + +@Composable +private fun SectionHeaderNoAdd(title: String) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 20.dp, bottom = 6.dp)) +} + +@Composable +private fun EmptyRow(text: String) { + Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 6.dp)) +} + +@Composable +private fun AccountRow(name: String, color: String, onDelete: () -> Unit) { + Row( + Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(color))) + Text(name, modifier = Modifier.weight(1f).padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge) + IconButton(onClick = onDelete) { + Icon(Icons.Filled.Delete, contentDescription = tr("common.delete"), tint = MaterialTheme.colorScheme.error) + } + } +} + +// ---- Add dialogs ---- + +@Composable +private fun LocalDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) { + var name by remember { mutableStateOf("") } + val color = "#34a853" + FormDialog(tr("accounts.local.add"), onDismiss, confirmEnabled = name.isNotBlank(), onConfirm = { onConfirm(name.trim(), color) }) { + OutlinedTextField(name, { name = it }, label = { Text(tr("local.name")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun CalDAVDialog(onDismiss: () -> Unit, onConfirm: (String, String, String, String, String) -> Unit) { + var name by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + var user by remember { mutableStateOf("") } + var pass by remember { mutableStateOf("") } + val color = "#4285f4" + FormDialog(tr("accounts.caldav.add"), onDismiss, confirmEnabled = url.isNotBlank() && user.isNotBlank(), onConfirm = { onConfirm(name.trim(), url.trim(), user.trim(), pass, color) }) { + OutlinedTextField(name, { name = it }, label = { Text(tr("caldav.display_name")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(url, { url = it }, label = { Text(tr("caldav.url")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + 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()) + } +} + +@Composable +private fun ICalDialog(onDismiss: () -> Unit, onConfirm: (String, String, String, Int) -> Unit) { + var name by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + val color = "#46bdc6" + FormDialog(tr("accounts.ical.add"), onDismiss, confirmEnabled = url.isNotBlank(), onConfirm = { onConfirm(name.trim(), url.trim(), color, 60) }) { + OutlinedTextField(name, { name = it }, label = { Text(tr("ical.name")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(url, { url = it }, label = { Text(tr("ical.url")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun HADialog(onDismiss: () -> Unit, onConfirm: (String, String, String) -> Unit) { + var name by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + var token by remember { mutableStateOf("") } + FormDialog(tr("accounts.ha.add"), onDismiss, confirmEnabled = url.isNotBlank() && token.isNotBlank(), onConfirm = { onConfirm(name.trim(), url.trim(), token.trim()) }) { + OutlinedTextField(name, { name = it }, label = { Text(tr("ha.display_name")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(url, { url = it }, label = { Text(tr("ha.url_placeholder")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(token, { token = it }, label = { Text(tr("ha.token")) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun FormDialog( + title: String, + onDismiss: () -> Unit, + confirmEnabled: Boolean, + onConfirm: () -> Unit, + content: @Composable () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Column { content() } }, + confirmButton = { TextButton(onClick = onConfirm, enabled = confirmEnabled) { Text(tr("caldav.connect")) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(tr("common.cancel")) } }, + ) +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt new file mode 100644 index 0000000..3ced76d --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt @@ -0,0 +1,88 @@ +package com.scarriffle.calendarr.ui.accounts + +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.domain.model.CalDAVAccount +import com.scarriffle.calendarr.domain.model.GoogleAccount +import com.scarriffle.calendarr.domain.model.HomeAssistantAccount +import com.scarriffle.calendarr.domain.model.ICalSubscription +import com.scarriffle.calendarr.domain.model.LocalCalendar +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AccountsViewModel @Inject constructor( + private val repository: CalendarRepository, +) : ViewModel() { + + var loading by mutableStateOf(true) + private set + var error by mutableStateOf(null) + private set + + var caldav by mutableStateOf>(emptyList()) + private set + var local by mutableStateOf>(emptyList()) + private set + var ical by mutableStateOf>(emptyList()) + private set + var google by mutableStateOf>(emptyList()) + private set + var homeAssistant by mutableStateOf>(emptyList()) + private set + + init { load() } + + fun load() { + viewModelScope.launch { + loading = true + error = null + caldav = runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList()) + local = runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList()) + ical = runCatching { repository.getICalSubscriptions() }.getOrDefault(emptyList()) + google = runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList()) + homeAssistant = runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList()) + loading = false + } + } + + private fun mutate(onChanged: () -> Unit, block: suspend () -> Unit) { + viewModelScope.launch { + runCatching { block() } + .onSuccess { load(); onChanged() } + .onFailure { error = it.message } + } + } + + fun addLocal(name: String, color: String, onChanged: () -> Unit) = + mutate(onChanged) { repository.addLocalCalendar(name, color) } + + fun deleteLocal(id: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.deleteLocalCalendar(id) } + + fun addCalDAV(name: String, url: String, user: String, pass: String, color: String, onChanged: () -> Unit) = + mutate(onChanged) { repository.addCalDAVAccount(name, url, user, pass, color) } + + fun deleteCalDAV(id: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.deleteCalDAVAccount(id) } + + fun addICal(name: String, url: String, color: String, refreshMinutes: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.addICalSubscription(name, url, color, refreshMinutes) } + + fun deleteICal(id: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.deleteICalSubscription(id) } + + fun addHA(name: String, url: String, token: String, onChanged: () -> Unit) = + mutate(onChanged) { repository.addHomeAssistantAccount(name, url, token) } + + fun deleteHA(id: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.deleteHomeAssistantAccount(id) } + + fun deleteGoogle(id: Int, onChanged: () -> Unit) = + mutate(onChanged) { repository.deleteGoogleAccount(id) } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/auth/AuthViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/auth/AuthViewModel.kt new file mode 100644 index 0000000..034151f --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/auth/AuthViewModel.kt @@ -0,0 +1,97 @@ +package com.scarriffle.calendarr.ui.auth + +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.CredentialStore +import com.scarriffle.calendarr.data.remote.ApiProvider +import com.scarriffle.calendarr.data.remote.TwoFactorRequiredException +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AuthViewModel @Inject constructor( + private val repository: CalendarRepository, + private val credentialStore: CredentialStore, +) : ViewModel() { + + // Server setup + var serverUrl by mutableStateOf(credentialStore.serverUrl ?: "") + private set + var checking by mutableStateOf(false) + private set + var setupError by mutableStateOf(null) + private set + + // Login + var username by mutableStateOf("") + private set + var password by mutableStateOf("") + private set + var totpCode by mutableStateOf("") + private set + var showTotp by mutableStateOf(false) + private set + var rememberMe by mutableStateOf(true) + private set + var loggingIn by mutableStateOf(false) + private set + var loginError by mutableStateOf(null) + private set + + fun onServerUrlChange(v: String) { serverUrl = v; setupError = null } + fun onUsernameChange(v: String) { username = v; loginError = null } + fun onPasswordChange(v: String) { password = v; loginError = null } + fun onTotpChange(v: String) { totpCode = v; loginError = null } + fun onRememberChange(v: Boolean) { rememberMe = v } + + fun checkServer(onConfigured: () -> Unit) { + val url = serverUrl.trim() + if (url.isEmpty()) { setupError = "URL erforderlich"; return } + viewModelScope.launch { + checking = true + setupError = null + runCatching { repository.setupRequired(url) } + .onSuccess { + credentialStore.serverUrl = ApiProvider.normalize(url) + onConfigured() + } + .onFailure { setupError = it.message ?: "Verbindung fehlgeschlagen" } + checking = false + } + } + + fun login(onSuccess: () -> Unit) { + val base = credentialStore.serverUrl ?: return + if (username.isBlank() || password.isBlank()) { + loginError = "Benutzername und Passwort erforderlich" + return + } + viewModelScope.launch { + loggingIn = true + loginError = null + runCatching { + repository.login( + baseUrl = base, + username = username.trim(), + password = password, + totpCode = totpCode.trim().takeIf { it.isNotEmpty() }, + rememberMe = rememberMe, + ) + }.onSuccess { onSuccess() } + .onFailure { e -> + if (e is TwoFactorRequiredException) { + showTotp = true + loginError = null + } else { + loginError = e.message ?: "Anmeldung fehlgeschlagen" + } + } + loggingIn = false + } + } +} 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 new file mode 100644 index 0000000..d730125 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/auth/LoginScreen.kt @@ -0,0 +1,124 @@ +package com.scarriffle.calendarr.ui.auth + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.tr + +@Composable +fun LoginScreen( + serverUrl: String, + onLoggedIn: () -> Unit, + onBack: () -> Unit, + vm: AuthViewModel = hiltViewModel(), +) { + Column(modifier = Modifier.fillMaxSize().padding(horizontal = 28.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 12.dp)) { + IconButton(onClick = onBack) { + Icon(Icons.Filled.ArrowBack, contentDescription = tr("auth.back")) + } + Spacer(Modifier.height(0.dp)) + Text(serverUrl, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(tr("auth.login_title"), style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(24.dp)) + OutlinedTextField( + value = vm.username, + onValueChange = vm::onUsernameChange, + label = { Text(tr("auth.username")) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = vm.password, + onValueChange = vm::onPasswordChange, + label = { Text(tr("auth.password")) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + modifier = Modifier.fillMaxWidth(), + ) + AnimatedVisibility(vm.showTotp) { + Column { + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = vm.totpCode, + onValueChange = vm::onTotpChange, + label = { Text(tr("auth.totp")) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + modifier = Modifier.fillMaxWidth(), + ) + } + } + Spacer(Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(tr("auth.remember"), style = MaterialTheme.typography.bodyMedium) + Switch(checked = vm.rememberMe, onCheckedChange = vm::onRememberChange) + } + vm.loginError?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(20.dp)) + Button( + onClick = { vm.login(onLoggedIn) }, + enabled = !vm.loggingIn, + modifier = Modifier.fillMaxWidth(), + ) { + if (vm.loggingIn) { + CircularProgressIndicator(modifier = Modifier.height(20.dp), strokeWidth = 2.dp) + } else { + Text(tr("auth.login")) + } + } + TextButton(onClick = onBack) { Text(tr("server.switch")) } + } + } +} + +/** Read the configured server URL for display. */ +@Composable +fun rememberServerUrl(credentialStore: CredentialStore): String = + credentialStore.serverUrl ?: "" diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/auth/ServerSetupScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/auth/ServerSetupScreen.kt new file mode 100644 index 0000000..413e278 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/auth/ServerSetupScreen.kt @@ -0,0 +1,87 @@ +package com.scarriffle.calendarr.ui.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.ui.tr + +@Composable +fun ServerSetupScreen( + onConfigured: () -> Unit, + vm: AuthViewModel = hiltViewModel(), +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 28.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Filled.CalendarMonth, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.height(64.dp), + ) + Spacer(Modifier.height(16.dp)) + Text("Calendarr", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(8.dp)) + Text( + tr("auth.server_hint"), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(28.dp)) + OutlinedTextField( + value = vm.serverUrl, + onValueChange = vm::onServerUrlChange, + label = { Text(tr("auth.server_url")) }, + placeholder = { Text("https://cal.example.com") }, + singleLine = true, + isError = vm.setupError != null, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + ), + modifier = Modifier.fillMaxWidth(), + ) + vm.setupError?.let { + Spacer(Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(20.dp)) + Button( + onClick = { vm.checkServer(onConfigured) }, + enabled = !vm.checking && vm.serverUrl.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + if (vm.checking) { + CircularProgressIndicator(modifier = Modifier.height(20.dp), strokeWidth = 2.dp) + } else { + Text(tr("auth.continue")) + } + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt new file mode 100644 index 0000000..c2c2562 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt @@ -0,0 +1,109 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@Composable +fun AgendaView( + state: CalendarUiState, + vm: CalendarViewModel, + onEventClick: (CalEvent) -> Unit, +) { + val lang = LocalLang.current + val today = LocalDate.now() + val days = (0 until 90).map { today.plusDays(it.toLong()) } + .map { it to vm.eventsOn(it, state.events) } + .filter { it.second.isNotEmpty() } + + if (days.isEmpty()) { + Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(tr("cal.no_events_title"), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(6.dp)) + Text( + tr("cal.no_events_body"), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + return + } + + val headerFmt = DateTimeFormatter.ofPattern("EEEE, d. MMMM", com.scarriffle.calendarr.ui.L10n.locale(lang)) + + LazyColumn(Modifier.fillMaxSize().padding(horizontal = 12.dp)) { + days.forEach { (date, events) -> + item(key = "h-$date") { + Text( + headerFmt.format(date).replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = if (date == today) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(top = 16.dp, bottom = 6.dp), + ) + } + items(events, key = { "${date}-${it.id}" }) { ev -> + AgendaRow(ev, lang, onClick = { onEventClick(ev) }) + } + } + item { Spacer(Modifier.height(80.dp)) } + } +} + +@Composable +private fun AgendaRow(event: CalEvent, lang: String, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surface) + .clickable(onClick = onClick) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(10.dp).clip(CircleShape).background(colorFromHex(event.effectiveColor))) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(event.title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + if (event.location.isNotBlank()) { + Text(event.location, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Spacer(Modifier.width(8.dp)) + Text( + eventTimeRange(event, lang), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt new file mode 100644 index 0000000..b84a831 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt @@ -0,0 +1,83 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex + +data class CalendarFilterEntry(val key: String, val name: String, val color: String) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarFilterSheet( + events: List, + vm: CalendarViewModel, + onDismiss: () -> Unit, +) { + val state by vm.state.collectAsState() + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(tr("filter.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Row { + TextButton(onClick = { vm.setHiddenCalendars(emptySet()) }) { Text(tr("filter.show_all")) } + TextButton(onClick = { + vm.setHiddenCalendars(events.map { it.key }.toSet()) + }) { Text(tr("filter.hide_all")) } + } + } + if (events.isEmpty()) { + Text( + tr("filter.empty"), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 24.dp), + ) + } + events.forEach { entry -> + val visible = entry.key !in state.hiddenKeys + Row( + Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(entry.color))) + Text( + entry.name, + modifier = Modifier.weight(1f).padding(start = 12.dp), + style = MaterialTheme.typography.bodyLarge, + ) + Switch( + checked = visible, + onCheckedChange = { vm.setCalendarHidden(entry.key, hidden = !it) }, + ) + } + } + Box(Modifier.padding(bottom = 24.dp)) + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt new file mode 100644 index 0000000..01ee814 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt @@ -0,0 +1,87 @@ +package com.scarriffle.calendarr.ui.calendar + +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.domain.model.CalViewType +import com.scarriffle.calendarr.ui.L10n +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.TextStyle + +private val zone: ZoneId = ZoneId.systemDefault() + +fun titleForView(viewType: CalViewType, date: LocalDate, lang: String): String { + val loc = L10n.locale(lang) + return when (viewType) { + CalViewType.MONTH -> + DateTimeFormatter.ofPattern("LLLL yyyy", loc).format(date) + .replaceFirstChar { it.uppercase(loc) } + CalViewType.QUARTER -> { + val fmt = DateTimeFormatter.ofPattern("LLL yyyy", loc) + "${fmt.format(date)} – ${fmt.format(date.plusMonths(2))}" + } + CalViewType.WEEK -> { + val start = date + val fmt = DateTimeFormatter.ofPattern("d. MMM", loc) + val endFmt = DateTimeFormatter.ofPattern("d. MMM yyyy", loc) + "${fmt.format(start)} – ${endFmt.format(start.plusDays(6))}" + } + CalViewType.DAY -> + DateTimeFormatter.ofPattern("EEEE, d. MMMM yyyy", loc).format(date) + CalViewType.AGENDA -> L10n.t("view.agenda", lang) + } +} + +fun weekdayLabels(mondayFirst: Boolean, lang: String): List { + val loc = L10n.locale(lang) + val order = if (mondayFirst) { + listOf(1, 2, 3, 4, 5, 6, 7) + } else { + listOf(7, 1, 2, 3, 4, 5, 6) + } + return order.map { + java.time.DayOfWeek.of(it).getDisplayName(TextStyle.SHORT, loc).take(2) + } +} + +fun timeLabel(instant: Instant, lang: String): String = + DateTimeFormatter.ofPattern("HH:mm", L10n.locale(lang)).withZone(zone).format(instant) + +fun localTime(instant: Instant): LocalTime = LocalTime.ofInstant(instant, zone) + +fun localDate(instant: Instant): LocalDate = LocalDate.ofInstant(instant, zone) + +/** Minutes-from-midnight of the instant in the local zone. */ +fun minutesOfDay(instant: Instant): Int { + val t = localTime(instant) + return t.hour * 60 + t.minute +} + +fun eventTimeRange(event: CalEvent, lang: String): String { + if (event.isAllDay) return L10n.t("cal.allday", lang) + return "${timeLabel(event.startDate, lang)} – ${timeLabel(event.endDate, lang)}" +} + +/** Full human-readable date (+time) range for the detail sheet. */ +fun eventDateRange(event: CalEvent, lang: String): String { + val loc = L10n.locale(lang) + val dateFmt = DateTimeFormatter.ofPattern("EEE, d. MMM yyyy", loc) + val startDate = localDate(event.startDate) + val endDate = localDate(event.endDate) + if (event.isAllDay) { + // All-day end is exclusive on the server; show the inclusive last day. + val lastDay = endDate.minusDays(1) + return if (lastDay.isAfter(startDate)) { + "${dateFmt.format(startDate)} – ${dateFmt.format(lastDay)}" + } else { + "${dateFmt.format(startDate)} · ${L10n.t("cal.allday", lang)}" + } + } + return if (startDate == endDate) { + "${dateFmt.format(startDate)}\n${timeLabel(event.startDate, lang)} – ${timeLabel(event.endDate, lang)}" + } else { + "${dateFmt.format(startDate)} ${timeLabel(event.startDate, lang)}\n– ${dateFmt.format(endDate)} ${timeLabel(event.endDate, lang)}" + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt new file mode 100644 index 0000000..f9025be --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -0,0 +1,261 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Today +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.domain.model.AppSettings +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.domain.model.CalViewType +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.accounts.AccountsScreen +import com.scarriffle.calendarr.ui.event.EventDetailSheet +import com.scarriffle.calendarr.ui.event.EventEditorSheet +import com.scarriffle.calendarr.ui.menu.MenuSheet +import com.scarriffle.calendarr.ui.profile.ProfileScreen +import com.scarriffle.calendarr.ui.settings.SettingsScreen +import com.scarriffle.calendarr.ui.tr +import java.time.LocalDate + +private enum class Overlay { NONE, PROFILE, SETTINGS, ACCOUNTS } + +data class EditorRequest(val existing: CalEvent?, val date: LocalDate) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarScreen( + onLogout: () -> Unit, + onSwitchServer: () -> Unit, + onSettingsChanged: (AppSettings) -> Unit, + onSettingsSynced: () -> Unit, + vm: CalendarViewModel = hiltViewModel(), +) { + val state by vm.state.collectAsState() + val lang = LocalLang.current + + var viewMenuOpen by remember { mutableStateOf(false) } + var showMenu by remember { mutableStateOf(false) } + var showFilter by remember { mutableStateOf(false) } + var detailEvent by remember { mutableStateOf(null) } + var editor by remember { mutableStateOf(null) } + var overlay by remember { mutableStateOf(Overlay.NONE) } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + titleForView(state.viewType, state.currentDate, lang), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = { showMenu = true }) { + Icon(Icons.Filled.Menu, contentDescription = tr("nav.menu")) + } + }, + actions = { + IconButton(onClick = vm::navigatePrev) { + Icon(Icons.Filled.ChevronLeft, contentDescription = null) + } + IconButton(onClick = vm::moveToToday) { + Icon(Icons.Filled.Today, contentDescription = tr("nav.today")) + } + IconButton(onClick = vm::navigateNext) { + Icon(Icons.Filled.ChevronRight, contentDescription = null) + } + IconButton(onClick = { showFilter = true }) { + Icon(Icons.Filled.FilterList, contentDescription = tr("filter.button")) + } + Box { + IconButton(onClick = { viewMenuOpen = true }) { + Icon(state.viewType.icon, contentDescription = tr("view.change")) + } + DropdownMenu(expanded = viewMenuOpen, onDismissRequest = { viewMenuOpen = false }) { + CalViewType.entries.forEach { type -> + DropdownMenuItem( + text = { Text(tr("view.${type.key}")) }, + leadingIcon = { Icon(type.icon, contentDescription = null) }, + onClick = { + viewMenuOpen = false + vm.setViewType(type) + }, + ) + } + } + } + }, + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { editor = EditorRequest(null, state.currentDate) }) { + Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event")) + } + }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + if (state.isLoading || state.isBackgroundCaching) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + } + state.error?.let { err -> + ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError) + } + Box(Modifier.fillMaxSize()) { + CalendarBody( + state = state, + vm = vm, + onEventClick = { detailEvent = it }, + onDayClick = { date -> vm.goToDate(date, CalViewType.DAY) }, + onEmptySlotClick = { date -> editor = EditorRequest(null, date) }, + ) + } + } + } + + // ---- Sheets ---- + + if (showMenu) { + MenuSheet( + isAdmin = false, + onDismiss = { showMenu = false }, + onProfile = { showMenu = false; overlay = Overlay.PROFILE }, + onAppearance = { showMenu = false; overlay = Overlay.SETTINGS }, + onAccounts = { showMenu = false; overlay = Overlay.ACCOUNTS }, + onSync = { showMenu = false; vm.syncWithServer() }, + onLogout = { showMenu = false; onLogout() }, + onSwitchServer = { showMenu = false; onSwitchServer() }, + ) + } + + if (showFilter) { + CalendarFilterSheet( + events = remember(state.events, state.hiddenKeys) { allKnownCalendars(vm) }, + vm = vm, + onDismiss = { showFilter = false }, + ) + } + + detailEvent?.let { ev -> + EventDetailSheet( + event = ev, + onDismiss = { detailEvent = null }, + onEdit = { + detailEvent = null + editor = EditorRequest(ev, localDate(ev.startDate)) + }, + onDelete = { + vm.deleteEvent(ev) {} + detailEvent = null + }, + ) + } + + editor?.let { req -> + EventEditorSheet( + request = req, + writableCalendars = state.writableCalendars, + onDismiss = { editor = null }, + onSave = { cal, title, start, end, allDay, location, desc, color -> + vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color) { error -> + if (error == null) editor = null + } + }, + ) + } + + when (overlay) { + Overlay.PROFILE -> ProfileScreen(onClose = { overlay = Overlay.NONE }) + Overlay.SETTINGS -> SettingsScreen( + onClose = { overlay = Overlay.NONE }, + onSettingsChanged = onSettingsChanged, + onSettingsSynced = onSettingsSynced, + ) + Overlay.ACCOUNTS -> AccountsScreen( + onClose = { overlay = Overlay.NONE }, + onChanged = { vm.loadWritableCalendars(); vm.syncWithServer() }, + ) + Overlay.NONE -> Unit + } +} + +@Composable +private fun CalendarBody( + state: CalendarUiState, + vm: CalendarViewModel, + onEventClick: (CalEvent) -> Unit, + onDayClick: (LocalDate) -> Unit, + onEmptySlotClick: (LocalDate) -> Unit, +) { + when (state.viewType) { + CalViewType.MONTH -> MonthView(state, vm, onDayClick, onEventClick) + CalViewType.WEEK -> WeekView(state, vm, onEventClick) + CalViewType.DAY -> DayView(state, vm, onEventClick) + CalViewType.QUARTER -> QuarterView(state, onDayClick) + CalViewType.AGENDA -> AgendaView(state, vm, onEventClick) + } +} + +@Composable +private fun ErrorBanner(message: String, onRetry: () -> Unit, onDismiss: () -> Unit) { + androidx.compose.material3.Surface( + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(12.dp)) { + Text(message, color = MaterialTheme.colorScheme.onErrorContainer, style = MaterialTheme.typography.bodySmall) + androidx.compose.foundation.layout.Row { + TextButton(onClick = onRetry) { Text(tr("common.retry")) } + TextButton(onClick = onDismiss) { Text(tr("common.close")) } + } + } + } +} + +@Composable +private fun loadingPlaceholder() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } +} + +/** Distinct calendars currently present in the cache, for the filter sheet. */ +private fun allKnownCalendars(vm: CalendarViewModel): List { + val st = vm.state.value + return st.events + .map { CalendarFilterEntry(calendarKey(it.source, it.calendarId), it.calendarName.ifBlank { it.source }, it.effectiveColor) } + .distinctBy { it.key } + .sortedBy { it.name.lowercase() } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt new file mode 100644 index 0000000..362ea93 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -0,0 +1,340 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.scarriffle.calendarr.data.CalendarRepository +import com.scarriffle.calendarr.data.SettingsStore +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.domain.model.CalViewType +import com.scarriffle.calendarr.domain.model.WritableCalendar +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import javax.inject.Inject + +/** Build the "source:id" visibility key, stripping any "-" prefix. */ +fun calendarKey(source: String, calendarId: String): String { + val prefix = "$source-" + val id = if (calendarId.startsWith(prefix)) calendarId.removePrefix(prefix) else calendarId + return "$source:$id" +} + +data class CalendarUiState( + val viewType: CalViewType = CalViewType.MONTH, + val currentDate: LocalDate = LocalDate.now(), + val events: List = emptyList(), + val isLoading: Boolean = false, + val isBackgroundCaching: Boolean = false, + val error: String? = null, + val weekStartsOnMonday: Boolean = true, + val writableCalendars: List = emptyList(), + val hiddenKeys: Set = emptySet(), + val banishedKeys: Set = emptySet(), +) + +@HiltViewModel +class CalendarViewModel @Inject constructor( + private val repository: CalendarRepository, + private val settingsStore: SettingsStore, +) : ViewModel() { + + private val zone: ZoneId = ZoneId.systemDefault() + + private val _state = MutableStateFlow(initialState()) + val state: StateFlow = _state.asStateFlow() + + // Cache bookkeeping + private var cachedStart: Instant? = null + private var cachedEnd: Instant? = null + private var allCachedEvents: List = emptyList() + + init { + loadVisible() + loadWritableCalendars() + prefetchBackground() + } + + private fun initialState(): CalendarUiState { + val s = settingsStore.loadSettings() + return CalendarUiState( + viewType = CalViewType.fromKey(s.defaultView), + weekStartsOnMonday = s.weekStartsOnMonday, + hiddenKeys = settingsStore.hiddenCalendarKeys, + banishedKeys = settingsStore.banishedCalendarKeys, + ) + } + + // ---- Navigation ---- + + fun setViewType(type: CalViewType) { + _state.update { it.copy(viewType = type) } + loadVisible() + } + + fun moveToToday() { + _state.update { it.copy(currentDate = LocalDate.now()) } + loadVisible() + } + + fun navigatePrev() = navigate(-1) + fun navigateNext() = navigate(+1) + + private fun navigate(direction: Int) { + val st = _state.value + val date = st.currentDate + val newDate = when (st.viewType) { + CalViewType.WEEK -> date.plusWeeks(direction.toLong()) + CalViewType.DAY -> date.plusDays(direction.toLong()) + CalViewType.QUARTER -> date.plusMonths((3 * direction).toLong()) + else -> date.plusMonths(direction.toLong()) + } + _state.update { it.copy(currentDate = newDate) } + loadVisible() + } + + fun goToDate(date: LocalDate, viewType: CalViewType? = null) { + _state.update { it.copy(currentDate = date, viewType = viewType ?: it.viewType) } + loadVisible() + } + + // ---- Range ---- + + fun rangeForCurrentView(): Pair { + val st = _state.value + val date = st.currentDate + return when (st.viewType) { + CalViewType.MONTH -> { + val start = date.withDayOfMonth(1) + instant(start.minusMonths(1)) to instant(start.plusMonths(2)) + } + CalViewType.QUARTER -> { + val start = date.withDayOfMonth(1) + instant(start) to instant(start.plusMonths(4)) + } + CalViewType.WEEK -> { + val weekStart = startOfWeek(date, st.weekStartsOnMonday) + instant(weekStart) to instant(weekStart.plusDays(8)) + } + CalViewType.DAY -> instant(date) to instant(date.plusDays(1)) + CalViewType.AGENDA -> { + val today = LocalDate.now() + instant(today) to instant(today.plusDays(90)) + } + } + } + + private fun instant(date: LocalDate): Instant = date.atStartOfDay(zone).toInstant() + + private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate { + val dow = date.dayOfWeek.value // Mon=1 .. Sun=7 + val offset = if (mondayFirst) dow - 1 else dow % 7 + return date.minusDays(offset.toLong()) + } + + // ---- Loading ---- + + fun loadVisible(force: Boolean = false) { + val (start, end) = rangeForCurrentView() + if (!force && isCached(start, end)) { + refreshFromCache() + return + } + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + runCatching { repository.fetchEvents(start, end) } + .onSuccess { fetched -> + mergeIntoCache(fetched, start, end) + refreshFromCache() + _state.update { it.copy(isLoading = false) } + } + .onFailure { e -> + _state.update { it.copy(isLoading = false, error = e.message) } + } + } + } + + private fun prefetchBackground() { + val months = settingsStore.cacheMonths + val today = LocalDate.now().withDayOfMonth(1) + val start = instant(today.minusMonths(months.toLong())) + val end = instant(today.plusMonths((months + 1).toLong())) + if (isCached(start, end)) return + viewModelScope.launch { + _state.update { it.copy(isBackgroundCaching = true) } + runCatching { repository.fetchEvents(start, end) } + .onSuccess { fetched -> + mergeIntoCache(fetched, start, end) + refreshFromCache() + } + _state.update { it.copy(isBackgroundCaching = false) } + } + } + + private fun isCached(start: Instant, end: Instant): Boolean { + val cs = cachedStart ?: return false + val ce = cachedEnd ?: return false + return !cs.isAfter(start) && !ce.isBefore(end) + } + + private fun mergeIntoCache(newEvents: List, rangeStart: Instant, rangeEnd: Instant) { + val retained = allCachedEvents.filter { + !it.startDate.isBefore(rangeEnd) || !it.endDate.isAfter(rangeStart) + } + allCachedEvents = retained + newEvents + cachedStart = cachedStart?.let { minOf(it, rangeStart) } ?: rangeStart + cachedEnd = cachedEnd?.let { maxOf(it, rangeEnd) } ?: rangeEnd + } + + private fun refreshFromCache() { + val hidden = _state.value.hiddenKeys + val banished = _state.value.banishedKeys + val visible = allCachedEvents.filter { ev -> + val key = calendarKey(ev.source, ev.calendarId) + key !in hidden && key !in banished + } + _state.update { it.copy(events = visible) } + } + + private fun invalidateCache() { + cachedStart = null + cachedEnd = null + allCachedEvents = emptyList() + } + + fun syncWithServer() { + invalidateCache() + loadVisible(force = true) + prefetchBackground() + } + + fun clearError() = _state.update { it.copy(error = null) } + + // ---- Visibility filters ---- + + fun setCalendarHidden(key: String, hidden: Boolean) { + val next = _state.value.hiddenKeys.toMutableSet().apply { + if (hidden) add(key) else remove(key) + } + settingsStore.hiddenCalendarKeys = next + _state.update { it.copy(hiddenKeys = next) } + refreshFromCache() + } + + fun setHiddenCalendars(keys: Set) { + settingsStore.hiddenCalendarKeys = keys + _state.update { it.copy(hiddenKeys = keys) } + refreshFromCache() + } + + fun setCalendarBanished(key: String, banished: Boolean) { + val nextBanished = _state.value.banishedKeys.toMutableSet().apply { + if (banished) add(key) else remove(key) + } + val nextHidden = _state.value.hiddenKeys.toMutableSet().apply { + if (banished) remove(key) + } + settingsStore.banishedCalendarKeys = nextBanished + settingsStore.hiddenCalendarKeys = nextHidden + _state.update { it.copy(banishedKeys = nextBanished, hiddenKeys = nextHidden) } + refreshFromCache() + } + + // ---- Writable calendars ---- + + fun loadWritableCalendars() { + viewModelScope.launch { + runCatching { repository.getWritableCalendars() } + .onSuccess { cals -> _state.update { it.copy(writableCalendars = cals) } } + } + } + + // ---- Event mutations ---- + + private fun afterMutation() { + invalidateCache() + loadVisible(force = true) + prefetchBackground() + } + + fun saveEvent( + calendar: WritableCalendar, + existing: CalEvent?, + title: String, + start: Instant, + end: Instant, + isAllDay: Boolean, + location: String, + description: String, + color: String?, + onResult: (String?) -> Unit, + ) { + viewModelScope.launch { + val result = runCatching { + if (existing != null && existing.source == calendar.source) { + when (existing.source) { + "local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color) + "caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color) + else -> createForSource(calendar, title, start, end, isAllDay, location, description, color) + } + } else { + createForSource(calendar, title, start, end, isAllDay, location, description, color) + } + } + result.onSuccess { afterMutation(); onResult(null) } + .onFailure { onResult(it.message ?: "Fehler") } + } + } + + private suspend fun createForSource( + calendar: WritableCalendar, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, color: String?, + ) { + when (calendar.source) { + "local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color) + "caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color) + "google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description) + "homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description) + } + } + + fun deleteEvent(event: CalEvent, onResult: (String?) -> Unit) { + viewModelScope.launch { + val result = runCatching { + when (event.source) { + "local" -> repository.deleteLocalEvent(event.id) + "caldav" -> repository.deleteCalDAVEvent(event.id, event.url, calendarNumericId(event)) + "homeassistant" -> repository.deleteHAEvent(calendarNumericId(event) ?: 0, event.id) + else -> Unit + } + } + result.onSuccess { + removeCachedEvent(event.id) + onResult(null) + }.onFailure { onResult(it.message ?: "Fehler") } + } + } + + private fun calendarNumericId(event: CalEvent): Int? { + val key = calendarKey(event.source, event.calendarId) + return key.substringAfter(":").toIntOrNull() + } + + private fun removeCachedEvent(id: String) { + allCachedEvents = allCachedEvents.filterNot { it.id == id } + refreshFromCache() + } + + /** Events overlapping a single day, sorted by start. */ + fun eventsOn(date: LocalDate, events: List): List { + val dayStart = instant(date) + val dayEnd = instant(date.plusDays(1)) + return events.filter { it.startDate.isBefore(dayEnd) && it.endDate.isAfter(dayStart) } + .sortedBy { it.startDate } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt new file mode 100644 index 0000000..2690fa7 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt @@ -0,0 +1,166 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.ui.LocalAppSettings +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.contrastingTextColor +import java.time.LocalDate +import java.time.temporal.IsoFields + +@Composable +fun MonthView( + state: CalendarUiState, + vm: CalendarViewModel, + onDayClick: (LocalDate) -> Unit, + onEventClick: (CalEvent) -> Unit, +) { + val lang = LocalLang.current + val mondayFirst = state.weekStartsOnMonday + val today = LocalDate.now() + val firstOfMonth = state.currentDate.withDayOfMonth(1) + val firstVisible = startOfWeekFor(firstOfMonth, mondayFirst) + val weeks = (0 until 6).map { w -> (0 until 7).map { d -> firstVisible.plusDays((w * 7 + d).toLong()) } } + + Column(Modifier.fillMaxSize()) { + // Header: CW + weekdays + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Box(Modifier.width(28.dp)) + weekdayLabels(mondayFirst, lang).forEach { label -> + Text( + label, + modifier = Modifier.weight(1f), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + weeks.forEach { week -> + Row(Modifier.fillMaxWidth().weight(1f)) { + val cw = week.first().get(IsoFields.WEEK_OF_WEEK_BASED_YEAR) + Box(Modifier.width(28.dp).fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Text( + "$cw", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + fontSize = 9.sp, + modifier = Modifier.padding(top = 4.dp), + ) + } + week.forEach { day -> + DayCell( + day = day, + inMonth = day.month == firstOfMonth.month, + isToday = day == today, + events = vm.eventsOn(day, state.events), + onClick = { onDayClick(day) }, + onEventClick = onEventClick, + modifier = Modifier.weight(1f).fillMaxSize(), + ) + } + } + } + } +} + +@Composable +private fun DayCell( + day: LocalDate, + inMonth: Boolean, + isToday: Boolean, + events: List, + onClick: () -> Unit, + onEventClick: (CalEvent) -> Unit, + modifier: Modifier = Modifier, +) { + val todayColor = colorFromHex(LocalAppSettings.current.todayColor) + Column( + modifier = modifier + .padding(1.dp) + .clickable(onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(top = 2.dp)) { + if (isToday) { + Box( + Modifier + .height(22.dp).width(22.dp) + .clip(RoundedCornerShape(11.dp)) + .background(todayColor), + ) + } + Text( + "${day.dayOfMonth}", + style = MaterialTheme.typography.labelMedium, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, + color = when { + isToday -> todayColor.contrastingTextColor() + inMonth -> MaterialTheme.colorScheme.onBackground + else -> MaterialTheme.colorScheme.outline + }, + ) + } + events.take(3).forEach { ev -> + EventChip(ev, onClick = { onEventClick(ev) }) + } + if (events.size > 3) { + Text( + "+${events.size - 3}", + style = MaterialTheme.typography.labelSmall, + fontSize = 8.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun EventChip(event: CalEvent, onClick: () -> Unit) { + val color = colorFromHex(event.effectiveColor) + Box( + Modifier + .fillMaxWidth() + .padding(horizontal = 1.dp, vertical = 1.dp) + .clip(RoundedCornerShape(3.dp)) + .background(color) + .clickable(onClick = onClick) + .padding(horizontal = 3.dp, vertical = 1.dp), + ) { + Text( + event.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 9.sp, + color = color.contrastingTextColor(), + ) + } +} + +private fun startOfWeekFor(date: LocalDate, mondayFirst: Boolean): LocalDate { + val dow = date.dayOfWeek.value + val offset = if (mondayFirst) dow - 1 else dow % 7 + return date.minusDays(offset.toLong()) +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/QuarterView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/QuarterView.kt new file mode 100644 index 0000000..e6387b3 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/QuarterView.kt @@ -0,0 +1,104 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.scarriffle.calendarr.ui.LocalAppSettings +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.contrastingTextColor +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@Composable +fun QuarterView(state: CalendarUiState, onDayClick: (LocalDate) -> Unit) { + val lang = LocalLang.current + val months = (0 until 3).map { state.currentDate.withDayOfMonth(1).plusMonths(it.toLong()) } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp)) { + months.forEach { month -> + MiniMonth(month, state, lang, onDayClick) + } + } +} + +@Composable +private fun MiniMonth(month: LocalDate, state: CalendarUiState, lang: String, onDayClick: (LocalDate) -> Unit) { + val mondayFirst = state.weekStartsOnMonday + val today = LocalDate.now() + val titleFmt = DateTimeFormatter.ofPattern("LLLL yyyy", com.scarriffle.calendarr.ui.L10n.locale(lang)) + val dow = month.dayOfWeek.value + val offset = if (mondayFirst) dow - 1 else dow % 7 + val firstVisible = month.minusDays(offset.toLong()) + val cells = (0 until 42).map { firstVisible.plusDays(it.toLong()) } + + Column(Modifier.padding(bottom = 16.dp)) { + Text( + titleFmt.format(month).replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 4.dp), + ) + Row(Modifier.fillMaxWidth()) { + weekdayLabels(mondayFirst, lang).forEach { + Text(it, Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 9.sp, color = MaterialTheme.colorScheme.outline) + } + } + cells.chunked(7).forEach { week -> + Row(Modifier.fillMaxWidth()) { + week.forEach { day -> + val hasEvents = state.events.any { + localDate(it.startDate) <= day && localDate(it.endDate) >= day + } + val firstColor = state.events.firstOrNull { + localDate(it.startDate) <= day && localDate(it.endDate) >= day + }?.effectiveColor + Box( + Modifier.weight(1f).aspectRatio(1f).clickable { onDayClick(day) }, + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + val isToday = day == today + Box(contentAlignment = Alignment.Center) { + if (isToday) { + Box( + Modifier.clip(CircleShape) + .background(colorFromHex(LocalAppSettings.current.todayColor)) + .padding(horizontal = 5.dp, vertical = 2.dp), + ) { Text("${day.dayOfMonth}", fontSize = 10.sp, color = colorFromHex(LocalAppSettings.current.todayColor).contrastingTextColor()) } + } else { + Text( + "${day.dayOfMonth}", + fontSize = 10.sp, + color = if (day.month == month.month) MaterialTheme.colorScheme.onBackground else MaterialTheme.colorScheme.outline, + ) + } + } + if (hasEvents) { + Box(Modifier.padding(top = 1.dp).clip(CircleShape).background(colorFromHex(firstColor)).padding(2.dp)) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt new file mode 100644 index 0000000..3ea85bb --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt @@ -0,0 +1,219 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.layout +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.ui.LocalAppSettings +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.contrastingTextColor +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +private val GUTTER = 48.dp + +@Composable +fun TimeGridView( + days: List, + state: CalendarUiState, + vm: CalendarViewModel, + onEventClick: (CalEvent) -> Unit, +) { + val hourHeight = LocalAppSettings.current.hourHeight.coerceIn(28, 100).dp + val lang = LocalLang.current + val today = LocalDate.now() + + Column(Modifier.fillMaxSize()) { + // Day headers (only for multi-day / week view) + if (days.size > 1) { + Row(Modifier.fillMaxWidth()) { + Box(Modifier.width(GUTTER)) + val dayFmt = DateTimeFormatter.ofPattern("EEE d", com.scarriffle.calendarr.ui.L10n.locale(lang)) + days.forEach { day -> + Text( + dayFmt.format(day), + modifier = Modifier.weight(1f).padding(vertical = 4.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelSmall, + fontWeight = if (day == today) FontWeight.Bold else FontWeight.Normal, + color = if (day == today) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // All-day row + val allDay = days.map { d -> d to vm.eventsOn(d, state.events).filter { it.isAllDay } } + if (allDay.any { it.second.isNotEmpty() }) { + Row(Modifier.fillMaxWidth().padding(bottom = 2.dp)) { + Box(Modifier.width(GUTTER), contentAlignment = Alignment.Center) { + Text(com.scarriffle.calendarr.ui.tr("cal.allday"), fontSize = 8.sp, color = MaterialTheme.colorScheme.outline) + } + allDay.forEach { (_, evs) -> + Column(Modifier.weight(1f).padding(horizontal = 1.dp)) { + evs.forEach { ev -> AllDayChip(ev, onClick = { onEventClick(ev) }) } + } + } + } + Divider() + } + + // Scrollable hour grid + Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + Box(Modifier.fillMaxWidth().height(hourHeight * 24)) { + // Hour lines + labels + for (h in 0..24) { + val y = hourHeight * h.toFloat() + Divider( + modifier = Modifier + .fillMaxWidth() + .padding(start = GUTTER) + .offset(y = y), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + ) + if (h < 24) { + Text( + "%02d:00".format(h), + modifier = Modifier.width(GUTTER).offset(y = y + 2.dp).padding(end = 4.dp), + textAlign = TextAlign.End, + fontSize = 9.sp, + color = MaterialTheme.colorScheme.outline, + ) + } + } + + // Timed events per day column + days.forEachIndexed { index, day -> + val timed = vm.eventsOn(day, state.events).filter { !it.isAllDay } + timed.forEach { ev -> + TimedEvent( + event = ev, + day = day, + index = index, + dayCount = days.size, + hourHeight = hourHeight, + onClick = { onEventClick(ev) }, + ) + } + } + } + } + } +} + +@Composable +private fun TimedEvent( + event: CalEvent, + day: LocalDate, + index: Int, + dayCount: Int, + hourHeight: androidx.compose.ui.unit.Dp, + onClick: () -> Unit, +) { + val startMin = if (localDate(event.startDate).isBefore(day)) 0 else minutesOfDay(event.startDate) + val endMin = if (localDate(event.endDate).isAfter(day)) 24 * 60 else minutesOfDay(event.endDate) + val duration = (endMin - startMin).coerceAtLeast(30) + val top = hourHeight * (startMin / 60f) + val height = (hourHeight * (duration / 60f)) + val color = colorFromHex(event.effectiveColor) + val lang = LocalLang.current + + Box( + modifier = Modifier + .offset(y = top) + .columnSlot(index, dayCount) + .height(height) + .padding(horizontal = 1.dp, vertical = 0.5.dp) + .clip(RoundedCornerShape(4.dp)) + .background(color) + .clickable(onClick = onClick) + .padding(horizontal = 4.dp, vertical = 2.dp), + ) { + Column { + Text( + event.title, + fontSize = 10.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = color.contrastingTextColor(), + fontWeight = FontWeight.Medium, + ) + if (height > 36.dp) { + Text( + eventTimeRange(event, lang), + fontSize = 8.sp, + color = color.contrastingTextColor().copy(alpha = 0.85f), + ) + } + } + } +} + +/** Position a child in column [index] of [dayCount] columns, after the gutter. */ +private fun Modifier.columnSlot(index: Int, dayCount: Int): Modifier = layout { measurable, constraints -> + val gutterPx = GUTTER.roundToPx() + val available = (constraints.maxWidth - gutterPx).coerceAtLeast(0) + val colWidth = available / dayCount + val placeable = measurable.measure( + constraints.copy(minWidth = colWidth, maxWidth = colWidth) + ) + layout(constraints.maxWidth, placeable.height) { + placeable.place(gutterPx + colWidth * index, 0) + } +} + +@Composable +private fun AllDayChip(event: CalEvent, onClick: () -> Unit) { + val color = colorFromHex(event.effectiveColor) + Box( + Modifier + .fillMaxWidth() + .padding(vertical = 1.dp) + .clip(RoundedCornerShape(3.dp)) + .background(color) + .clickable(onClick = onClick) + .padding(horizontal = 3.dp, vertical = 1.dp), + ) { + Text(event.title, fontSize = 9.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, color = color.contrastingTextColor()) + } +} + +@Composable +fun DayView(state: CalendarUiState, vm: CalendarViewModel, onEventClick: (CalEvent) -> Unit) { + TimeGridView(listOf(state.currentDate), state, vm, onEventClick) +} + +@Composable +fun WeekView(state: CalendarUiState, vm: CalendarViewModel, onEventClick: (CalEvent) -> Unit) { + val mondayFirst = state.weekStartsOnMonday + val dow = state.currentDate.dayOfWeek.value + val offset = if (mondayFirst) dow - 1 else dow % 7 + val weekStart = state.currentDate.minusDays(offset.toLong()) + val days = (0 until 7).map { weekStart.plusDays(it.toLong()) } + TimeGridView(days, state, vm, onEventClick) +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt new file mode 100644 index 0000000..0d9532f --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt @@ -0,0 +1,123 @@ +package com.scarriffle.calendarr.ui.event + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Notes +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.calendar.eventDateRange +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventDetailSheet( + event: CalEvent, + onDismiss: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + val lang = LocalLang.current + var confirmDelete by remember { mutableStateOf(false) } + val canEdit = event.source == "local" || event.source == "caldav" + val canDelete = event.source == "local" || event.source == "caldav" || event.source == "homeassistant" + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(16.dp).clip(CircleShape).background(colorFromHex(event.effectiveColor))) + Spacer(Modifier.size(10.dp)) + Text( + event.title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + } + Spacer(Modifier.size(16.dp)) + DetailRow(Icons.Filled.Schedule, eventDateRange(event, lang)) + if (event.calendarName.isNotBlank()) { + DetailRow(Icons.Filled.Notes, event.calendarName) + } + if (event.location.isNotBlank()) { + DetailRow(Icons.Filled.LocationOn, event.location) + } + if (event.notes.isNotBlank()) { + DetailRow(Icons.Filled.Notes, event.notes) + } + Spacer(Modifier.size(20.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (canEdit) { + OutlinedButton(onClick = onEdit) { + Icon(Icons.Filled.Edit, contentDescription = null) + Spacer(Modifier.size(6.dp)) + Text(tr("event.edit_title")) + } + } + if (canDelete) { + OutlinedButton(onClick = { confirmDelete = true }) { + Icon(Icons.Filled.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) + Spacer(Modifier.size(6.dp)) + Text(tr("common.delete"), color = MaterialTheme.colorScheme.error) + } + } + } + } + } + + if (confirmDelete) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + title = { Text(tr("common.delete")) }, + text = { Text(tr("event.delete_confirm")) }, + confirmButton = { + TextButton(onClick = { confirmDelete = false; onDelete() }) { + Text(tr("common.delete"), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { confirmDelete = false }) { Text(tr("common.cancel")) } + }, + ) + } +} + +@Composable +private fun DetailRow(icon: ImageVector, text: String) { + Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.Top) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp)) + Spacer(Modifier.size(12.dp)) + Text(text, style = MaterialTheme.typography.bodyLarge) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt new file mode 100644 index 0000000..dee00e3 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt @@ -0,0 +1,275 @@ +package com.scarriffle.calendarr.ui.event + +import android.app.DatePickerDialog +import android.app.TimePickerDialog +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.domain.model.WritableCalendar +import com.scarriffle.calendarr.ui.L10n +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.calendar.EditorRequest +import com.scarriffle.calendarr.ui.calendar.calendarKey +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +private val PRESET_COLORS = listOf( + "#4285f4", "#ea4335", "#34a853", "#fbbc05", + "#46bdc6", "#9c27b0", "#ff7043", "#7090c0", +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventEditorSheet( + request: EditorRequest, + writableCalendars: List, + onDismiss: () -> Unit, + onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?) -> Unit, +) { + val zone = ZoneId.systemDefault() + val context = LocalContext.current + val lang = LocalLang.current + val existing = request.existing + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var title by remember { mutableStateOf(existing?.title ?: "") } + var allDay by remember { mutableStateOf(existing?.isAllDay ?: false) } + var location by remember { mutableStateOf(existing?.location ?: "") } + var description by remember { mutableStateOf(existing?.notes ?: "") } + var color by remember { mutableStateOf(existing?.color) } + + val initialStart = existing?.startDate + val initialEnd = existing?.endDate + var startDate by remember { + mutableStateOf(initialStart?.let { LocalDate.ofInstant(it, zone) } ?: request.date) + } + var startTime by remember { + mutableStateOf(initialStart?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: LocalTime.of(9, 0)) + } + var endDate by remember { + mutableStateOf( + initialEnd?.let { + val d = LocalDate.ofInstant(it, zone) + if (existing?.isAllDay == true) d.minusDays(1) else d + } ?: request.date + ) + } + var endTime by remember { + mutableStateOf(initialEnd?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: LocalTime.of(10, 0)) + } + + val preselected = existing?.let { ev -> + val id = calendarKey(ev.source, ev.calendarId).substringAfter(":").toIntOrNull() + writableCalendars.firstOrNull { it.source == ev.source && it.numericId == id } + } + var calendar by remember { mutableStateOf(preselected ?: writableCalendars.firstOrNull()) } + var calMenuOpen by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + val dateFmt = DateTimeFormatter.ofPattern("EEE, d. MMM yyyy") + val timeFmt = DateTimeFormatter.ofPattern("HH:mm") + + fun pickDate(current: LocalDate, onPicked: (LocalDate) -> Unit) { + DatePickerDialog( + context, + { _, y, m, d -> onPicked(LocalDate.of(y, m + 1, d)) }, + current.year, current.monthValue - 1, current.dayOfMonth, + ).show() + } + + fun pickTime(current: LocalTime, onPicked: (LocalTime) -> Unit) { + TimePickerDialog( + context, + { _, h, min -> onPicked(LocalTime.of(h, min)) }, + current.hour, current.minute, true, + ).show() + } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .padding(bottom = 32.dp), + ) { + Text( + if (existing == null) tr("event.new_title") else tr("event.edit_title"), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(vertical = 8.dp), + ) + + OutlinedTextField( + value = title, + onValueChange = { title = it; error = null }, + label = { Text(tr("event.title_placeholder")) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(12.dp)) + + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(tr("event.allday"), style = MaterialTheme.typography.bodyLarge) + Switch(checked = allDay, onCheckedChange = { allDay = it }) + } + Spacer(Modifier.size(8.dp)) + + // Start + Text(tr("event.start"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { pickDate(startDate) { startDate = it; if (endDate.isBefore(it)) endDate = it } }) { + Text(dateFmt.format(startDate)) + } + if (!allDay) { + OutlinedButton(onClick = { pickTime(startTime) { startTime = it } }) { Text(timeFmt.format(startTime)) } + } + } + Spacer(Modifier.size(8.dp)) + + // End + Text(tr("event.end"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { pickDate(endDate) { endDate = it } }) { Text(dateFmt.format(endDate)) } + if (!allDay) { + OutlinedButton(onClick = { pickTime(endTime) { endTime = it } }) { Text(timeFmt.format(endTime)) } + } + } + Spacer(Modifier.size(12.dp)) + + OutlinedTextField( + value = location, + onValueChange = { location = it }, + label = { Text(tr("event.location")) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(12.dp)) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text(tr("event.description")) }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(12.dp)) + + // Calendar picker + Text(tr("event.calendar_section"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (writableCalendars.isEmpty()) { + Text(tr("event.no_writable"), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } else { + Box { + OutlinedButton(onClick = { calMenuOpen = true }, modifier = Modifier.fillMaxWidth()) { + Box(Modifier.size(12.dp).clip(CircleShape).background(colorFromHex(calendar?.color))) + Spacer(Modifier.size(8.dp)) + Text(calendar?.name ?: tr("event.calendar_picker"), modifier = Modifier.weight(1f)) + Icon(Icons.Filled.ArrowDropDown, contentDescription = null) + } + DropdownMenu(expanded = calMenuOpen, onDismissRequest = { calMenuOpen = false }) { + writableCalendars.forEach { c -> + DropdownMenuItem( + text = { Text(c.name) }, + leadingIcon = { Box(Modifier.size(12.dp).clip(CircleShape).background(colorFromHex(c.color))) }, + onClick = { calendar = c; calMenuOpen = false }, + ) + } + } + } + } + Spacer(Modifier.size(12.dp)) + + // Color + Text(tr("event.color_section"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(Modifier.padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PRESET_COLORS.forEach { hex -> + val selected = color?.equals(hex, ignoreCase = true) == true + Box( + Modifier + .size(28.dp) + .clip(CircleShape) + .background(colorFromHex(hex)) + .then(if (selected) Modifier.border(2.dp, Color.White, CircleShape) else Modifier) + .clickable { color = hex }, + contentAlignment = Alignment.Center, + ) { + if (selected) Icon(Icons.Filled.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp)) + } + } + } + if (color != null) { + androidx.compose.material3.TextButton(onClick = { color = null }) { Text(tr("event.reset_color")) } + } + + error?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 8.dp)) + } + Spacer(Modifier.size(16.dp)) + Button( + onClick = { + val cal = calendar + if (title.isBlank()) { error = L10n.t("event.title_placeholder", lang); return@Button } + if (cal == null) { error = L10n.t("event.no_writable", lang); return@Button } + val start: Instant + val end: Instant + if (allDay) { + start = startDate.atStartOfDay(zone).toInstant() + end = endDate.plusDays(1).atStartOfDay(zone).toInstant() + } else { + start = startDate.atTime(startTime).atZone(zone).toInstant() + end = endDate.atTime(endTime).atZone(zone).toInstant() + } + onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color) + }, + enabled = writableCalendars.isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (existing == null) tr("event.add") else tr("event.save")) + } + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt new file mode 100644 index 0000000..2f05481 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt @@ -0,0 +1,74 @@ +package com.scarriffle.calendarr.ui.menu + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Logout +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.ui.tr + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MenuSheet( + isAdmin: Boolean, + onDismiss: () -> Unit, + onProfile: () -> Unit, + onAppearance: () -> Unit, + onAccounts: () -> Unit, + onSync: () -> Unit, + onLogout: () -> Unit, + onSwitchServer: () -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(Modifier.fillMaxWidth().padding(bottom = 24.dp)) { + Text( + "Calendarr", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + MenuRow(Icons.Filled.AccountCircle, tr("menu.profile"), onProfile) + MenuRow(Icons.Filled.Palette, tr("menu.appearance"), onAppearance) + MenuRow(Icons.Filled.CalendarMonth, tr("menu.accounts"), onAccounts) + Divider(Modifier.padding(vertical = 4.dp)) + MenuRow(Icons.Filled.Sync, tr("menu.sync"), onSync) + Divider(Modifier.padding(vertical = 4.dp)) + MenuRow(Icons.Filled.Logout, tr("menu.logout"), onLogout) + MenuRow(Icons.Filled.Dns, tr("server.switch"), onSwitchServer) + } + } +} + +@Composable +private fun MenuRow(icon: ImageVector, label: String, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(18.dp)) + Text(label, style = MaterialTheme.typography.bodyLarge) + } +} 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 new file mode 100644 index 0000000..80d8061 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileScreen.kt @@ -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(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) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileViewModel.kt new file mode 100644 index 0000000..4e3e084 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/profile/ProfileViewModel.kt @@ -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(null) + private set + var email by mutableStateOf("") + private set + var loading by mutableStateOf(true) + private set + var message by mutableStateOf(null) + private set + + // 2FA enrolment + var totpSetup by mutableStateOf(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 = "" } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..6478841 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt @@ -0,0 +1,190 @@ +package com.scarriffle.calendarr.ui.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.domain.model.AppSettings +import com.scarriffle.calendarr.domain.model.CalViewType +import com.scarriffle.calendarr.ui.LocalAppSettings +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex + +private val PALETTE = listOf( + "#4285f4", "#ea4335", "#34a853", "#fbbc05", "#46bdc6", "#9c27b0", "#ff7043", "#7090c0", +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onClose: () -> Unit, + onSettingsChanged: (AppSettings) -> Unit, + onSettingsSynced: () -> Unit, + vm: SettingsViewModel = hiltViewModel(), +) { + val initialSettings = LocalAppSettings.current + var settings by remember { mutableStateOf(initialSettings) } + var cacheMonths by remember { mutableStateOf(vm.cacheMonths) } + + fun update(newSettings: AppSettings) { + settings = newSettings + onSettingsChanged(newSettings) + vm.apply(newSettings, onSettingsSynced) + } + + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(tr("settings.title")) }, + navigationIcon = { + IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) } + }, + ) + }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) { + + Section(tr("settings.calview")) + ChipRow( + options = CalViewType.entries.map { it.key to tr("view.${it.key}") }, + selected = settings.defaultView, + onSelect = { update(settings.copy(defaultView = it)) }, + ) + Spacer(Modifier.size(16.dp)) + + Section(tr("settings.firstweekday")) + ChipRow( + options = listOf("monday" to tr("settings.monday"), "sunday" to tr("settings.sunday")), + selected = settings.weekStartDay, + onSelect = { update(settings.copy(weekStartDay = it)) }, + ) + Spacer(Modifier.size(16.dp)) + + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(tr("settings.dimpast"), style = MaterialTheme.typography.bodyLarge) + Switch(checked = settings.dimPastEvents, onCheckedChange = { update(settings.copy(dimPastEvents = it)) }) + } + Divider(Modifier.padding(vertical = 16.dp)) + + Section(tr("settings.language")) + ChipRow( + options = listOf("system" to tr("lang.system"), "de" to tr("lang.german"), "en" to tr("lang.english")), + selected = settings.language, + onSelect = { update(settings.copy(language = it)) }, + ) + Divider(Modifier.padding(vertical = 16.dp)) + + Section(tr("settings.colors")) + ColorChooser(tr("settings.color.primary"), settings.primaryColor) { update(settings.copy(primaryColor = it)) } + ColorChooser(tr("settings.color.accent"), settings.accentColor) { update(settings.copy(accentColor = it)) } + ColorChooser(tr("settings.color.today"), settings.todayColor) { update(settings.copy(todayColor = it)) } + Divider(Modifier.padding(vertical = 16.dp)) + + Section(tr("settings.hourheight")) + Text("${settings.hourHeight} dp", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Slider( + value = settings.hourHeight.toFloat(), + onValueChange = { settings = settings.copy(hourHeight = it.toInt()) }, + onValueChangeFinished = { update(settings) }, + valueRange = 28f..100f, + ) + Spacer(Modifier.size(8.dp)) + + Section(tr("settings.textcontrast")) + ContrastStepper(settings.textContrast) { update(settings.copy(textContrast = it)) } + Section(tr("settings.linecontrast")) + ContrastStepper(settings.lineContrast) { update(settings.copy(lineContrast = it)) } + Divider(Modifier.padding(vertical = 16.dp)) + + Section(tr("settings.cache.title")) + ChipRow( + options = listOf("1" to tr("settings.cache.1m"), "3" to tr("settings.cache.3m"), "6" to tr("settings.cache.6m"), "12" to tr("settings.cache.1y")), + selected = cacheMonths.toString(), + onSelect = { cacheMonths = it.toInt(); vm.cacheMonths = it.toInt() }, + ) + Spacer(Modifier.size(40.dp)) + } + } + } +} + +@Composable +private fun Section(title: String) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp)) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChipRow(options: List>, selected: String, onSelect: (String) -> Unit) { + Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + options.forEach { (key, label) -> + FilterChip(selected = key == selected, onClick = { onSelect(key) }, label = { Text(label) }) + } + } +} + +@Composable +private fun ColorChooser(label: String, current: String, onPick: (String) -> Unit) { + Column(Modifier.padding(vertical = 6.dp)) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PALETTE.forEach { hex -> + val selected = hex.equals(current, ignoreCase = true) + Box( + Modifier.size(28.dp).clip(CircleShape).background(colorFromHex(hex)) + .then(if (selected) Modifier.border(2.dp, Color.White, CircleShape) else Modifier) + .clickable { onPick(hex) }, + ) + } + } + } +} + +@Composable +private fun ContrastStepper(value: Int, onChange: (Int) -> Unit) { + Slider( + value = value.toFloat(), + onValueChange = { onChange(it.toInt().coerceIn(1, 4)) }, + valueRange = 1f..4f, + steps = 2, + ) +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..18f507d --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt @@ -0,0 +1,30 @@ +package com.scarriffle.calendarr.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.scarriffle.calendarr.data.CalendarRepository +import com.scarriffle.calendarr.data.SettingsStore +import com.scarriffle.calendarr.domain.model.AppSettings +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val repository: CalendarRepository, + private val settingsStore: SettingsStore, +) : ViewModel() { + + /** Persist locally immediately, then sync to the server in the background. */ + fun apply(settings: AppSettings, onSynced: () -> Unit) { + settingsStore.saveSettings(settings) + viewModelScope.launch { + runCatching { repository.updateSettings(settings) } + onSynced() + } + } + + var cacheMonths: Int + get() = settingsStore.cacheMonths + set(value) { settingsStore.cacheMonths = value } +} 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 6cc1e7b..7a5057e 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 @@ -1,35 +1,44 @@ package com.scarriffle.calendarr.ui.theme -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.scarriffle.calendarr.domain.model.AppSettings +import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.contrastingTextColor -private val LightColors = lightColorScheme( - primary = md_theme_light_primary, - onPrimary = md_theme_light_onPrimary, - background = md_theme_light_background, - onBackground = md_theme_light_onBackground, -) - -private val DarkColors = darkColorScheme( - primary = md_theme_dark_primary, - onPrimary = md_theme_dark_onPrimary, - background = md_theme_dark_background, - onBackground = md_theme_dark_onBackground, -) - +/** + * The Calendarr UI is a dark theme whose accent colours follow the user's + * synced [AppSettings] (mirrors the iOS appearance settings). + */ @Composable fun CalendarrTheme( - darkTheme: Boolean = isSystemInDarkTheme(), + settings: AppSettings = AppSettings(), content: @Composable () -> Unit, ) { - val colors = if (darkTheme) DarkColors else LightColors + val primary = colorFromHex(settings.primaryColor, Color(0xFF4285F4)) + val accent = colorFromHex(settings.accentColor, Color(0xFFEA4335)) + + val colors = darkColorScheme( + primary = primary, + onPrimary = primary.contrastingTextColor(), + secondary = accent, + onSecondary = accent.contrastingTextColor(), + tertiary = accent, + background = Color(0xFF000000), + onBackground = Color(0xFFF2F2F7), + surface = Color(0xFF1C1C1E), + onSurface = Color(0xFFF2F2F7), + surfaceVariant = Color(0xFF2C2C2E), + onSurfaceVariant = Color(0xFFBEBEC4), + outline = Color(0xFF3A3A3C), + ) MaterialTheme( colorScheme = colors, - typography = androidx.compose.material3.Typography(), + typography = Typography(), content = content, ) } From a1c36a8a03d972dfe3d56a3aa98371404d89c488 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 12:48:53 +0200 Subject: [PATCH 04/59] 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 --- .../calendarr/data/CalendarRepository.kt | 2 +- .../calendarr/data/remote/ApiException.kt | 4 +- .../calendarr/ui/accounts/AccountsScreen.kt | 3 +- .../calendarr/ui/auth/LoginScreen.kt | 12 +-- .../calendarr/ui/components/PasswordField.kt | 88 ++++++++++++++++++ .../calendarr/ui/profile/ProfileScreen.kt | 10 +- .../scarriffle/calendarr/ui/theme/Theme.kt | 18 ++-- .../res/drawable/ic_launcher_foreground.xml | 27 ------ .../res/mipmap-anydpi-v26/ic_launcher.xml | 4 - .../mipmap-anydpi-v26/ic_launcher_round.xml | 4 - app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3694 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 3694 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2260 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2260 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 5324 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 5324 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 8986 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 8986 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 12848 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 12848 bytes .../main/res/values/ic_launcher_colors.xml | 3 - claude.md | 2 +- 22 files changed, 113 insertions(+), 64 deletions(-) create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/components/PasswordField.kt delete mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml delete mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml delete mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png delete mode 100644 app/src/main/res/values/ic_launcher_colors.xml 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 0000000000000000000000000000000000000000..59317c13e436b74c838b42c50c0100ba75fdc844 GIT binary patch literal 3694 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 M07*qoM6N<$f`ilZ761SM literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..59317c13e436b74c838b42c50c0100ba75fdc844 GIT binary patch literal 3694 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 M07*qoM6N<$f`ilZ761SM literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c84e2e5fd069e8aa04a1d95596bf5cd629c44d6e GIT binary patch literal 2260 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­ZgD__rJ|-I@t$ey<2_;{Vr589fNFe^L(yV70yz zw0Ut|u!dM7NA1qu7Gsj*N(PBNS#~cZ+^bq0^;GUT(-Dkm25#Q>(vCnLx75(4KH>0XWQ-L+KZMzD*>q+(DgN6f@uKsHyr&rY| zEtf%3p|ZVI^o;wE2M{KMAc^1E3K_UQO{Hoh?mhl)oZbsCsNwm`zAi31##b1hQ&8qE3xy1L56;kT8G#ZP}h z`vI*i;78j~PHH+qHN@yMq9Jnr)MstpuyrJ#7j$q~PNBkRdf>1K-qDl#M1g*Wn~ zp(xFS;7y*8o3ZkvH0P_6I{C}-?$rF=hO4HVvyNruaP~~r#)Chol1)qma&=?#zPwZb z<7q~Sl;~N1#iBqRQr(b9isEQxc`CG0YG@IGpuYMU4+~#rn5`*C+x~sUa6OU&?tM4{ z%w%Qi{!YRuylhkMW+FJBhA-ZZoYE(SVhiQsfn+KGkq|^(VGKjnwa5x(HmHOdR%8av}6Q+PxmY2G)`7`;2O_7P)P`!YQ zDb4(Av!P5I2za0|?a3(Xr|?{tU$5G#FZdVbj(%*_?a9xa)N&XRa!k0}l(qhu{ z6!-A~bxg%rb{h#NHkxmfPI3|5J!J0+COTV|N_eP3B!ifz#YW`+*q)AG23%WWt)dlZ>b1ZSW!OU4sAJiLtn( zAFgbq`ibzZSjK6<7UK_9Hxt$~?8d?I*I`zd`rMnt&%}vd3IVyF7!sE0jv{l-E5|;6 z*^Yf>(QUz0S(94$y>%c#~oA><%952#`4(mHSmk&44 z?<71HXM=9u8Vne!-lL_G%c%xNA?bT5(wZ9`;`@k0r(lIf|r zx#azCzRrC5-k>Aoee`#K|Mer)p>Q@f>Epg92hZ@%D&@}gTGD-E4WEdyREH1^9=s4J zt*X4EF9dUlE`9K)!b9^m6=e0<*Z9vKjBgn1ep;qNh@X&=r+`JD)wDq6DODp2Qo6m1C=GjVT@yI%T*44$$n@@GF00RDAU zdGndK)Fu&wJfL6(a$k$lh@{Nm#R-E=e)*QMA|DYM{B$`GdGr5g z_xfXya?AjQi96uCP7gi^8$4jiO;)Ii)G?DOmyCUl2->jmc_(=rXn|oz(>!2_2`&#M zRs+2|Kj+!cY4?&UHUHB6bETXu#FIUB0QMFT{Dr8xhdf||yo*F9AK7I{jF;`&CN76d zxVcC#Gat;cyvdpN6ifUzK@A9w(&Jy$jctcqnlQv*4{;YvV~21}Cb-1&FO*^ob@k=v<1 z5ar)k1s<-#Kv~V;%UX|+qkW4T?p722gl68$SiUjf4?#m{Ux{gQUq$84y@fgzp>^G8 zVE+`sp_Qsb#j)dJH}PiU!|G4`e?1m*pRbvD5>+F7Q`nl+~BK=z4uHI zjfaS52$U zfsbh?=L3Ve;=cb8X&;xmtl356++nDvqvODKk%IIq|=GNvi>Pyq;k!5+5 zzQT=;p%JcoQb|4ZAa{F1I33-mkutFVgnzMBcMO0eE7^mv4(z097u+@lDBv{0rVHIW zz9=Mx_jIkBv%W#JHg}l8f=)cGje3)^k;`A zJNn>bc*W6SAzhx=`{&qp{oEY#=2scgZOFoZXJ$Jnw2 zLpl-_E!pGK)$yhcz|#kT?xF9ATaU9}ze6DlN#(ZBsE8&9b~O3B#|3YzHc2&oHmo%{ ul=y*UJim<^@H&Ts@uQRuT(?X9nhhy?SDZ{P?BCxbp87Lwl?o-Ru>S$V(ia2( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..78751869f53ade5a7ee8d052dc1f7f209690b8a8 GIT binary patch literal 5324 zcmYjVbyO6yEmhRl81d)=ZmQaMH8&+whOO);uK|+>p z5LiI!;d$@;-tUh)cj7zW-1%nCJ@-sBR2y=SjF}7%5AUA3nu^}7xB6!!1h=i*-`^g$ z4&O%)qJ&p5%(8hqP_Z*sx7X6b1lz=t#RD}i~{;5_3?!ftUZ&mIXA z%i=8U`Gx>T>51bE-#t0JW$*s}lfzdy^?z4?ycCvR;%C+T)ObSERT`Dds0_pQL$#n%l z_Z0H|9TlMij*^|m5Ln-XBE8nJ(h2R~GlXS#i=?6yFmMjEc6xUqtYHw0;WI}~i0hiA z$C8*ZQl`G(h4oG4(b}@EbX&HDE_5XntA(olrr3oRSF?j_Z3O9+(e&2-GDi6gq6~m9 zLM))q(BkBJHFO%-z*+x+q7JDy{Y@K*E^KC`9jy1H*SK<2d)-`yAPLL_UJz=0p%_)8 zaC>}?F{nK+9V0su#Nqf-(#$?jlglHCSqRb8nG`rxscX#M4yK)WQFiye(<4e#d=)RJ z|KL_Hr;_>tdH@V0Jp6t;gHv27XmzMKk6b{-f1_^MlK-&*l&VRnmw7uK2m&TUGad{p z-4fPFjd3btzTzpu0gB|`m!6W_@$GA}Ci#Sk*3bi?G!Dnjh+(CC86)R+wfuUX|G@;r zPQJlRaG|JUBE#cN@^w?_jxl$>4X;}4Gp5&-8TWso)*`En#*Ul)-<6{Kn+s8zt@gW) z8ti8L8Qq-xW;nYw@6vY^MFfN@@c$O?UK-dOOOj`724xG|5;G`)KurQ2C2}0TLQ_Cd z>9)5*7`8<;5w6Z{6Dq>4vhLk7iG0FthE^gED5}2(k(YJb`_#DFj zA^)>bsvNB(;I+xCO1+lvEiB0Y#*n`B_KnDJ_WN+fL=|ti+PO)v#!taI$m}#1tdYf; z6d7An*swO16$@1#;mj5e2L2CE1v&d;c{2?$2HR66d|AWA=Q|^*FC@$8$hadvP6<)< z+ttK5jp<*o=-M*G^@gfKfk~jfy`H!anZen7j?hi--lRNzD+L19KjTSqT+pNGr=o!`=CWwuvBjB^9BKk4Zz7@%nfeQ#QaVBWcS8_)Qbf5BaAuVDGhAPi8;c24IS$(zUUfX9bJrCJ?14r^7T6apFmxeH$rW zml{{{6N;U#Y%%j}^^%Dvt^qNsqh{!&5;~3ahLICfQylG)Ajy=Ys727qL9|ap!It zG~QWO_fy7%LaRmPBy%y#Chsq6UBJhfj5qqqYzRILX1Ob=(wH|~aopXMhog`<_BH3C z0!?yZO`|OEe4A~E7+-#9baXU%D#*UGk38plDo8s{=XZ^H(w!m1do}Z}G6`qLTwyUW zF;N-Lk?>9UTK`m1^_cM784)@y5BgMH;%03Mz*?u>WLTz#Xb>%YdhHyq`JCI5&z1}e zDg-1~uow4mw_V^)*?!fOCwg?~;^ONU1#orszI6pg;$uG_8Y}dz?5rH0E}SpD`{h?h zPxf7DI;E!Xz$YS6!VjYZ4;_v%Q?h|#Rp65P+(y@{u!t8jw+SRg zR@p9dJO+ggF4FJLE_Y0@t+A-cKCe`MB`)7~ddNe?B6T*gykBu1-UDmYbmW<$9COKZ z+Kv`k1b-)xA)#OpuDRKcHDO8a`^VJ4?!(@Y3UP|gQY_j5>%EB#!zKmw0-OO*!3qb{s@ z@W1Rce4zXFYPR}inMI8MQVkI+J~OEgg*Ts;bsh+CfBJVL+)FB+HFjP2k>ztE)6=%7 zZeqynv|nwRV|^4=-8S5=uD#%b{6Wq|cl9)XZ9kjaYbTFIL+4P+=GvoW;Em_m;%b+K z(&~pg+|H|wAAJK|Qv;?(g0;#4v#N=Ktqu8ZSgaPaLq|t%M$H})L;Hb7Ew5NlqGTTa zfjAI?K}kU9tZ4e0653VX+Xy!=i5rW<3bQZLaV5* z7c#>fw#ULb!)_a9h-R5Aor%HwKs(Q!OGv@Gz4Pj#MYA6~ z@G>VMm`Aj|NNc>)kklkJNpOuhwddV=*`lxstHxt(*4@VsfW?MUi47Te<2ERrUN0Bl zKQ8-iI5xH`g&mbIR94h>dgzq$fos?8{i}hC#f}bSPU$*wvr_dVV`GbS3`TK6#0J1+ z4SLJG`>F6RPn1!fVmWg*cz(#=Q$e}b*-Q43-aVJFWt~>zbZnCG zxmee*HCcED?mbC>RCY{Iy^&+{d%>0#`;qPNi^Hm+c}kD2iC|G#kq^a@CCtqA)2|V8 z#­ZgD__rJ|-I@t$ey<2_;{Vr589fNFe^L(yV70yz zw0Ut|u!dM7NA1qu7Gsj*N(PBNS#~cZ+^bq0^;GUT(-Dkm25#Q>(vCnLx75(4KH>0XWQ-L+KZMzD*>q+(DgN6f@uKsHyr&rY| zEtf%3p|ZVI^o;wE2M{KMAc^1E3K_UQO{Hoh?mhl)oZbsCsNwm`zAi31##b1hQ&8qE3xy1L56;kT8G#ZP}h z`vI*i;78j~PHH+qHN@yMq9Jnr)MstpuyrJ#7j$q~PNBkRdf>1K-qDl#M1g*Wn~ zp(xFS;7y*8o3ZkvH0P_6I{C}-?$rF=hO4HVvyNruaP~~r#)Chol1)qma&=?#zPwZb z<7q~Sl;~N1#iBqRQr(b9isEQxc`CG0YG@IGpuYMU4+~#rn5`*C+x~sUa6OU&?tM4{ z%w%Qi{!YRuylhkMW+FJBhA-ZZoYE(SVhiQsfn+KGkq|^(VGKjnwa5x(HmHOdR%8av}6Q+PxmY2G)`7`;2O_7P)P`!YQ zDb4(Av!P5I2za0|?a3(Xr|?{tU$5G#FZdVbj(%*_?a9xa)N&XRa!k0}l(qhu{ z6!-A~bxg%rb{h#NHkxmfPI3|5J!J0+COTV|N_eP3B!ifz#YW`+*q)AG23%WWt)dlZ>b1ZSW!OU4sAJiLtn( zAFgbq`ibzZSjK6<7UK_9Hxt$~?8d?I*I`zd`rMnt&%}vd3IVyF7!sE0jv{l-E5|;6 z*^Yf>(QUz0S(94$y>%c#~oA><%952#`4(mHSmk&44 z?<71HXM=9u8Vne!-lL_G%c%xNA?bT5(wZ9`;`@k0r(lIf|r zx#azCzRrC5-k>Aoee`#K|Mer)p>Q@f>Epg92hZ@%D&@}gTGD-E4WEdyREH1^9=s4J zt*X4EF9dUlE`9K)!b9^m6=e0<*Z9vKjBgn1ep;qNh@X&=r+`JD)wDq6DODp2Qo6m1C=GjVT@yI%T*44$$n@@GF00RDAU zdGndK)Fu&wJfL6(a$k$lh@{Nm#R-E=e)*QMA|DYM{B$`GdGr5g z_xfXya?AjQi96uCP7gi^8$4jiO;)Ii)G?DOmyCUl2->jmc_(=rXn|oz(>!2_2`&#M zRs+2|Kj+!cY4?&UHUHB6bETXu#FIUB0QMFT{Dr8xhdf||yo*F9AK7I{jF;`&CN76d zxVcC#Gat;cyvdpN6ifUzK@A9w(&Jy$jctcqnlQv*4{;YvV~21}Cb-1&FO*^ob@k=v<1 z5ar)k1s<-#Kv~V;%UX|+qkW4T?p722gl68$SiUjf4?#m{Ux{gQUq$84y@fgzp>^G8 zVE+`sp_Qsb#j)dJH}PiU!|G4`e?1m*pRbvD5>+F7Q`nl+~BK=z4uHI zjfaS52$U zfsbh?=L3Ve;=cb8X&;xmtl356++nDvqvODKk%IIq|=GNvi>Pyq;k!5+5 zzQT=;p%JcoQb|4ZAa{F1I33-mkutFVgnzMBcMO0eE7^mv4(z097u+@lDBv{0rVHIW zz9=Mx_jIkBv%W#JHg}l8f=)cGje3)^k;`A zJNn>bc*W6SAzhx=`{&qp{oEY#=2scgZOFoZXJ$Jnw2 zLpl-_E!pGK)$yhcz|#kT?xF9ATaU9}ze6DlN#(ZBsE8&9b~O3B#|3YzHc2&oHmo%{ ul=y*UJim<^@H&Ts@uQRuT(?X9nhhy?SDZ{P?BCxbp87Lwl?o-Ru>S$V(ia2( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a99ac08f6f5bc739197a01db4fc4b375e71c19ee GIT binary patch literal 8986 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 - #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) From c236db7fe9196fc0dc4b835c24ef85e08419ea7e Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 13:08:54 +0200 Subject: [PATCH 05/59] feat: scrolling month calendar, green theme fixes, long-press create, event colors - Month view rewritten as a continuous vertical scroll (iOS-style): no prev/next buttons, compact fixed-height week rows, month label on the 1st, top-bar title follows the visible month, Today scrolls back to today - Long-press a day opens the event editor pre-filled with that date - Theme: green container/tint colors so the FAB and accents are no longer the Material default purple - Event colors: parse #RGB shorthand, and fall back to a stable per-calendar palette colour instead of a constant blue when the server omits a colour - Top bar hides prev/next arrows in month view (scroll instead) Co-Authored-By: Claude Opus 4.8 --- .../calendarr/domain/model/CalEvent.kt | 22 +- .../calendarr/ui/calendar/CalendarScreen.kt | 57 +++-- .../ui/calendar/CalendarViewModel.kt | 15 ++ .../calendarr/ui/calendar/MonthView.kt | 224 ++++++++++++------ .../scarriffle/calendarr/ui/theme/Theme.kt | 10 + .../scarriffle/calendarr/util/ColorUtil.kt | 9 +- 6 files changed, 248 insertions(+), 89 deletions(-) diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt index a67ff6a..e4eaf34 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt @@ -23,10 +23,26 @@ data class CalEvent( val calendarColor: String, val source: String, ) { - /** Per-event override colour, falling back to the calendar's colour. */ - val effectiveColor: String get() = color?.takeIf { it.isNotBlank() } ?: calendarColor + /** + * Per-event override colour, then the calendar's colour, then a stable + * per-calendar palette colour (so events never collapse to one default). + */ + val effectiveColor: String + get() = color?.takeIf { it.isNotBlank() } + ?: calendarColor.takeIf { it.isNotBlank() } + ?: fallbackColorFor("$source:$calendarId") companion object { + private val FALLBACK_PALETTE = listOf( + "#34a853", "#4285f4", "#ea4335", "#fbbc05", + "#46bdc6", "#9c27b0", "#ff7043", "#7090c0", + ) + + private fun fallbackColorFor(key: String): String { + val idx = (key.hashCode().and(Int.MAX_VALUE)) % FALLBACK_PALETTE.size + return FALLBACK_PALETTE[idx] + } + /** Parse one event object from the `/api/caldav/events` aggregate response. */ fun fromJson(json: JSONObject): CalEvent? { val title = json.optString("title").takeIf { json.has("title") } ?: return null @@ -63,7 +79,7 @@ data class CalEvent( color = colorRaw.takeIf { it.isNotBlank() }, calendarId = calendarId, calendarName = json.optString("calendar_name", ""), - calendarColor = json.optString("calendarColor", "#4285f4"), + calendarColor = json.optString("calendarColor", ""), source = json.optString("source", "local"), ) } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index f9025be..ded34dd 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -25,9 +25,11 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -72,15 +74,19 @@ fun CalendarScreen( var editor by remember { mutableStateOf(null) } var overlay by remember { mutableStateOf(Overlay.NONE) } + // Continuous month scrolling + val monthListState = rememberLazyListState() + var todaySignal by remember { mutableIntStateOf(0) } + var visibleMonth by remember { mutableStateOf(state.currentDate) } + val isMonth = state.viewType == CalViewType.MONTH + val barTitle = if (isMonth) titleForView(CalViewType.MONTH, visibleMonth, lang) + else titleForView(state.viewType, state.currentDate, lang) + Scaffold( topBar = { TopAppBar( title = { - Text( - titleForView(state.viewType, state.currentDate, lang), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Text(barTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) }, navigationIcon = { IconButton(onClick = { showMenu = true }) { @@ -88,14 +94,18 @@ fun CalendarScreen( } }, actions = { - IconButton(onClick = vm::navigatePrev) { - Icon(Icons.Filled.ChevronLeft, contentDescription = null) + if (!isMonth) { + IconButton(onClick = vm::navigatePrev) { + Icon(Icons.Filled.ChevronLeft, contentDescription = null) + } } - IconButton(onClick = vm::moveToToday) { + IconButton(onClick = { if (isMonth) todaySignal++ else vm.moveToToday() }) { Icon(Icons.Filled.Today, contentDescription = tr("nav.today")) } - IconButton(onClick = vm::navigateNext) { - Icon(Icons.Filled.ChevronRight, contentDescription = null) + if (!isMonth) { + IconButton(onClick = vm::navigateNext) { + Icon(Icons.Filled.ChevronRight, contentDescription = null) + } } IconButton(onClick = { showFilter = true }) { Icon(Icons.Filled.FilterList, contentDescription = tr("filter.button")) @@ -121,7 +131,11 @@ fun CalendarScreen( ) }, floatingActionButton = { - FloatingActionButton(onClick = { editor = EditorRequest(null, state.currentDate) }) { + FloatingActionButton( + onClick = { editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event")) } }, @@ -137,9 +151,12 @@ fun CalendarScreen( CalendarBody( state = state, vm = vm, + monthListState = monthListState, + scrollToTodaySignal = todaySignal, + onVisibleMonthChange = { visibleMonth = it }, onEventClick = { detailEvent = it }, onDayClick = { date -> vm.goToDate(date, CalViewType.DAY) }, - onEmptySlotClick = { date -> editor = EditorRequest(null, date) }, + onDayLongPress = { date -> editor = EditorRequest(null, date) }, ) } } @@ -215,12 +232,24 @@ fun CalendarScreen( private fun CalendarBody( state: CalendarUiState, vm: CalendarViewModel, + monthListState: androidx.compose.foundation.lazy.LazyListState, + scrollToTodaySignal: Int, + onVisibleMonthChange: (LocalDate) -> Unit, onEventClick: (CalEvent) -> Unit, onDayClick: (LocalDate) -> Unit, - onEmptySlotClick: (LocalDate) -> Unit, + onDayLongPress: (LocalDate) -> Unit, ) { when (state.viewType) { - CalViewType.MONTH -> MonthView(state, vm, onDayClick, onEventClick) + CalViewType.MONTH -> MonthView( + state = state, + vm = vm, + listState = monthListState, + scrollToTodaySignal = scrollToTodaySignal, + onVisibleMonthChange = onVisibleMonthChange, + onDayClick = onDayClick, + onDayLongPress = onDayLongPress, + onEventClick = onEventClick, + ) CalViewType.WEEK -> WeekView(state, vm, onEventClick) CalViewType.DAY -> DayView(state, vm, onEventClick) CalViewType.QUARTER -> QuarterView(state, onDayClick) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index 362ea93..0853c09 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -159,6 +159,21 @@ class CalendarViewModel @Inject constructor( } } + /** On-demand load for a month the user scrolled to in the continuous calendar. */ + fun ensureMonthLoaded(monthAnchor: LocalDate) { + val first = monthAnchor.withDayOfMonth(1) + val start = instant(first.minusMonths(1)) + val end = instant(first.plusMonths(2)) + if (isCached(start, end)) return + viewModelScope.launch { + _state.update { it.copy(isLoading = true) } + runCatching { repository.fetchEvents(start, end) } + .onSuccess { mergeIntoCache(it, start, end); refreshFromCache() } + .onFailure { e -> _state.update { it.copy(error = e.message) } } + _state.update { it.copy(isLoading = false) } + } + } + private fun prefetchBackground() { val months = settingsStore.cacheMonths val today = LocalDate.now().withDayOfMonth(1) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt index 2690fa7..04a8a5a 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt @@ -1,7 +1,9 @@ package com.scarriffle.calendarr.ui.calendar +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,15 +12,23 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Divider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -28,111 +38,183 @@ import com.scarriffle.calendarr.ui.LocalLang import com.scarriffle.calendarr.util.colorFromHex import com.scarriffle.calendarr.util.contrastingTextColor import java.time.LocalDate -import java.time.temporal.IsoFields +import java.time.format.TextStyle +import java.time.temporal.ChronoUnit +private const val MONTHS_BACK = 18L +private const val MONTHS_AHEAD = 18L + +/** + * Continuous, vertically scrolling month calendar (matches the iOS app). + * There are no prev/next buttons — the user scrolls through weeks and the + * top-bar title follows the currently visible month. + */ @Composable fun MonthView( state: CalendarUiState, vm: CalendarViewModel, + listState: LazyListState, + scrollToTodaySignal: Int, + onVisibleMonthChange: (LocalDate) -> Unit, onDayClick: (LocalDate) -> Unit, + onDayLongPress: (LocalDate) -> Unit, onEventClick: (CalEvent) -> Unit, ) { val lang = LocalLang.current val mondayFirst = state.weekStartsOnMonday val today = LocalDate.now() - val firstOfMonth = state.currentDate.withDayOfMonth(1) - val firstVisible = startOfWeekFor(firstOfMonth, mondayFirst) - val weeks = (0 until 6).map { w -> (0 until 7).map { d -> firstVisible.plusDays((w * 7 + d).toLong()) } } + + val firstVisible = remember(mondayFirst) { + startOfWeek(today.withDayOfMonth(1).minusMonths(MONTHS_BACK), mondayFirst) + } + val end = remember(mondayFirst) { + startOfWeek(today.withDayOfMonth(1).plusMonths(MONTHS_AHEAD), mondayFirst) + } + val weekCount = remember(firstVisible, end) { + (ChronoUnit.WEEKS.between(firstVisible, end).toInt() + 1).coerceAtLeast(1) + } + val todayIndex = remember(firstVisible) { + ChronoUnit.WEEKS.between(firstVisible, startOfWeek(today, mondayFirst)).toInt() + } + + // Initial scroll to today's week. + LaunchedEffect(Unit) { + listState.scrollToItem((todayIndex - 1).coerceAtLeast(0)) + } + // "Today" button. + LaunchedEffect(scrollToTodaySignal) { + if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0)) + } + // Track visible month + trigger on-demand loads. + LaunchedEffect(listState, weekCount) { + snapshotFlow { listState.firstVisibleItemIndex }.collect { idx -> + val weekStart = firstVisible.plusWeeks(idx.toLong()) + val month = weekStart.plusDays(3) // mid-week → representative month + onVisibleMonthChange(month) + vm.ensureMonthLoaded(month) + } + } Column(Modifier.fillMaxSize()) { - // Header: CW + weekdays - Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { - Box(Modifier.width(28.dp)) + // Fixed weekday header + Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { weekdayLabels(mondayFirst, lang).forEach { label -> Text( label, modifier = Modifier.weight(1f), - textAlign = androidx.compose.ui.text.style.TextAlign.Center, + textAlign = TextAlign.Center, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - weeks.forEach { week -> - Row(Modifier.fillMaxWidth().weight(1f)) { - val cw = week.first().get(IsoFields.WEEK_OF_WEEK_BASED_YEAR) - Box(Modifier.width(28.dp).fillMaxSize(), contentAlignment = Alignment.TopCenter) { - Text( - "$cw", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.outline, - fontSize = 9.sp, - modifier = Modifier.padding(top = 4.dp), - ) - } - week.forEach { day -> - DayCell( - day = day, - inMonth = day.month == firstOfMonth.month, - isToday = day == today, - events = vm.eventsOn(day, state.events), - onClick = { onDayClick(day) }, - onEventClick = onEventClick, - modifier = Modifier.weight(1f).fillMaxSize(), - ) - } + Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)) + + LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { + items(weekCount) { index -> + val weekStart = firstVisible.plusWeeks(index.toLong()) + WeekRow( + weekStart = weekStart, + today = today, + events = state.events, + vm = vm, + lang = lang, + onDayClick = onDayClick, + onDayLongPress = onDayLongPress, + onEventClick = onEventClick, + ) } } } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun WeekRow( + weekStart: LocalDate, + today: LocalDate, + events: List, + vm: CalendarViewModel, + lang: String, + onDayClick: (LocalDate) -> Unit, + onDayLongPress: (LocalDate) -> Unit, + onEventClick: (CalEvent) -> Unit, +) { + val days = (0 until 7).map { weekStart.plusDays(it.toLong()) } + Row( + Modifier + .fillMaxWidth() + .height(78.dp), + ) { + days.forEach { day -> + DayCell( + day = day, + isToday = day == today, + events = vm.eventsOn(day, events), + lang = lang, + onClick = { onDayClick(day) }, + onLongClick = { onDayLongPress(day) }, + onEventClick = onEventClick, + modifier = Modifier.weight(1f).fillMaxSize(), + ) + } + } + Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)) +} + +@OptIn(ExperimentalFoundationApi::class) @Composable private fun DayCell( day: LocalDate, - inMonth: Boolean, isToday: Boolean, events: List, + lang: String, onClick: () -> Unit, + onLongClick: () -> Unit, onEventClick: (CalEvent) -> Unit, modifier: Modifier = Modifier, ) { - val todayColor = colorFromHex(LocalAppSettings.current.todayColor) + val settings = LocalAppSettings.current + val todayColor = colorFromHex(settings.todayColor) + val monthLabelColor = colorFromHex(settings.monthLabelColor, MaterialTheme.colorScheme.onSurfaceVariant) + val isFirst = day.dayOfMonth == 1 + Column( modifier = modifier - .padding(1.dp) - .clickable(onClick = onClick), + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(horizontal = 1.dp, vertical = 2.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(top = 2.dp)) { - if (isToday) { - Box( - Modifier - .height(22.dp).width(22.dp) - .clip(RoundedCornerShape(11.dp)) - .background(todayColor), + Row(verticalAlignment = Alignment.CenterVertically) { + if (isFirst) { + Text( + day.month.getDisplayName(TextStyle.SHORT, com.scarriffle.calendarr.ui.L10n.locale(lang)), + fontSize = 8.sp, + color = monthLabelColor, + modifier = Modifier.padding(end = 2.dp), + ) + } + Box(contentAlignment = Alignment.Center) { + if (isToday) { + Box( + Modifier + .height(18.dp) + .width(18.dp) + .clip(RoundedCornerShape(9.dp)) + .background(todayColor), + ) + } + Text( + "${day.dayOfMonth}", + fontSize = 11.sp, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, + color = if (isToday) todayColor.contrastingTextColor() else MaterialTheme.colorScheme.onBackground, ) } - Text( - "${day.dayOfMonth}", - style = MaterialTheme.typography.labelMedium, - fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, - color = when { - isToday -> todayColor.contrastingTextColor() - inMonth -> MaterialTheme.colorScheme.onBackground - else -> MaterialTheme.colorScheme.outline - }, - ) - } - events.take(3).forEach { ev -> - EventChip(ev, onClick = { onEventClick(ev) }) } + events.take(3).forEach { ev -> EventChip(ev) { onEventClick(ev) } } if (events.size > 3) { - Text( - "+${events.size - 3}", - style = MaterialTheme.typography.labelSmall, - fontSize = 8.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Text("+${events.size - 3}", fontSize = 8.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } @@ -140,26 +222,26 @@ private fun DayCell( @Composable private fun EventChip(event: CalEvent, onClick: () -> Unit) { val color = colorFromHex(event.effectiveColor) - Box( - Modifier + Surface( + color = color, + shape = RoundedCornerShape(3.dp), + modifier = Modifier .fillMaxWidth() - .padding(horizontal = 1.dp, vertical = 1.dp) - .clip(RoundedCornerShape(3.dp)) - .background(color) - .clickable(onClick = onClick) - .padding(horizontal = 3.dp, vertical = 1.dp), + .padding(horizontal = 1.dp, vertical = 0.5.dp) + .clickable(onClick = onClick), ) { Text( event.title, maxLines = 1, overflow = TextOverflow.Ellipsis, - fontSize = 9.sp, + fontSize = 8.sp, color = color.contrastingTextColor(), + modifier = Modifier.padding(horizontal = 3.dp, vertical = 1.dp), ) } } -private fun startOfWeekFor(date: LocalDate, mondayFirst: Boolean): LocalDate { +private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate { val dow = date.dayOfWeek.value val offset = if (mondayFirst) dow - 1 else dow % 7 return date.minusDays(offset.toLong()) 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 eb64f06..ff7da21 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 @@ -25,12 +25,22 @@ fun CalendarrTheme( ) { val primary = BrandGreen + val container = Color(0xFF14532D) + val onContainer = Color(0xFFB7F0C6) val colors = darkColorScheme( primary = primary, onPrimary = primary.contrastingTextColor(), + primaryContainer = container, + onPrimaryContainer = onContainer, secondary = primary, onSecondary = primary.contrastingTextColor(), + secondaryContainer = container, + onSecondaryContainer = onContainer, tertiary = primary, + onTertiary = primary.contrastingTextColor(), + tertiaryContainer = container, + onTertiaryContainer = onContainer, + surfaceTint = primary, background = Color(0xFF000000), onBackground = Color(0xFFF2F2F7), surface = Color(0xFF1C1C1E), diff --git a/app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt b/app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt index 3220474..1046116 100644 --- a/app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt +++ b/app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt @@ -5,8 +5,15 @@ import androidx.compose.ui.graphics.Color /** Parse a "#RRGGBB" (or "RRGGBB") hex string into a Compose [Color]. */ fun colorFromHex(hex: String?, fallback: Color = Color(0xFF4285F4)): Color { if (hex.isNullOrBlank()) return fallback - val clean = hex.trim().removePrefix("#") + val clean = hex.trim().removePrefix("#").filter { it.isLetterOrDigit() } return when (clean.length) { + 3 -> runCatching { + // #RGB shorthand -> expand each nibble + val r = clean[0].digitToInt(16) * 17 + val g = clean[1].digitToInt(16) * 17 + val b = clean[2].digitToInt(16) * 17 + Color(r / 255f, g / 255f, b / 255f, 1f) + }.getOrDefault(fallback) 6 -> runCatching { val v = clean.toLong(16) Color( From 608580fc7e25ad8cbfccd4e3b30b344de0128647 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 13:27:03 +0200 Subject: [PATCH 06/59] fix: event colors/null parsing, edit+copy, settings chips, scroll perf, splash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CalEvent parsing: handle Android's JSONObject.optString JSON-null quirk that returned the string "null" — this made every event blue (color "null" → fallback) and showed "null" for empty location/description. Now null-safe. - Event detail: copy-to-calendar action; calendar row uses a calendar icon (was identical to the notes icon); empty location/notes rows hidden - Event editor: copy mode (prefill from an existing event, save as new) - Settings: hour height, text/line contrast are now button chips (like the cache range); color picker wraps, shows the current colour and a check mark - Month scroll perf: precompute a day→events index once per change and only reload when the visible month actually changes (was filtering every cell every frame → laggy) - Splash: dark window/splash background + inset launcher icon (was an ugly white box on startup) Co-Authored-By: Claude Opus 4.8 --- .../calendarr/domain/model/CalEvent.kt | 32 +++++--- .../java/com/scarriffle/calendarr/ui/L10n.kt | 14 ++++ .../calendarr/ui/calendar/CalendarScreen.kt | 6 +- .../calendarr/ui/calendar/MonthView.kt | 48 ++++++++--- .../calendarr/ui/event/EventDetailSheet.kt | 16 +++- .../calendarr/ui/event/EventEditorSheet.kt | 27 +++--- .../calendarr/ui/settings/SettingsScreen.kt | 82 ++++++++++++------- app/src/main/res/drawable/splash_icon.xml | 5 ++ app/src/main/res/values-v31/themes.xml | 10 +++ app/src/main/res/values/themes.xml | 7 +- 10 files changed, 181 insertions(+), 66 deletions(-) create mode 100644 app/src/main/res/drawable/splash_icon.xml create mode 100644 app/src/main/res/values-v31/themes.xml diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt index e4eaf34..5a3e0b2 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt @@ -43,11 +43,22 @@ data class CalEvent( return FALLBACK_PALETTE[idx] } + /** + * Read a string field, treating JSON null correctly. Android's + * [JSONObject.optString] returns the literal string "null" for a JSON + * null value, which previously made every event blue (color "null" → + * unparseable → fallback) and showed "null" for empty location/notes. + */ + private fun JSONObject.strOrNull(key: String): String? { + if (!has(key) || isNull(key)) return null + return optString(key, "").takeIf { it.isNotBlank() && it != "null" } + } + /** Parse one event object from the `/api/caldav/events` aggregate response. */ fun fromJson(json: JSONObject): CalEvent? { - val title = json.optString("title").takeIf { json.has("title") } ?: return null - val startStr = json.optString("start").takeIf { json.has("start") } ?: return null - val endStr = json.optString("end").takeIf { json.has("end") } ?: return null + val title = json.strOrNull("title") ?: return null + val startStr = json.strOrNull("start") ?: return null + val endStr = json.strOrNull("end") ?: return null // id may be a String (local UUID) or an Int (CalDAV numeric) val id: String = when (val raw = json.opt("id")) { @@ -66,21 +77,20 @@ data class CalEvent( else -> raw.toString() } - val colorRaw = json.optString("color", "") return CalEvent( id = id, - url = json.optString("url", ""), + url = json.strOrNull("url") ?: "", title = title, startDate = start, endDate = end, isAllDay = isAllDay, - location = json.optString("location", ""), - notes = json.optString("description", ""), - color = colorRaw.takeIf { it.isNotBlank() }, + location = json.strOrNull("location") ?: "", + notes = json.strOrNull("description") ?: "", + color = json.strOrNull("color"), calendarId = calendarId, - calendarName = json.optString("calendar_name", ""), - calendarColor = json.optString("calendarColor", ""), - source = json.optString("source", "local"), + calendarName = json.strOrNull("calendar_name") ?: "", + calendarColor = json.strOrNull("calendarColor") ?: "", + source = json.strOrNull("source") ?: "local", ) } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt index 5170248..7ff2d54 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -53,10 +53,16 @@ object L10n { "settings.color.accent" to "Akzentfarbe", "settings.color.today" to "Heutige-Tag-Farbe", "settings.color.divider" to "Monatswechsel-Linie", "settings.color.label" to "Monatskürzel", "settings.textcontrast" to "Schriftkontrast", "settings.linecontrast" to "Linienkontrast", + "settings.contrast.dark" to "Dunkel", "settings.contrast.medium" to "Mittel", + "settings.contrast.bright" to "Hell", "settings.contrast.max" to "Maximum", + "settings.linecontrast.barely" to "Kaum", "settings.linecontrast.subtle" to "Subtil", + "settings.linecontrast.normal" to "Normal", "settings.linecontrast.strong" to "Stark", "settings.calview" to "Kalenderansicht", "settings.defaultview" to "Standardansicht", "settings.firstweekday" to "Erster Wochentag", "settings.monday" to "Montag", "settings.sunday" to "Sonntag", "settings.dimpast" to "Vergangene Termine ausgrauen", "settings.hourheight" to "Stundenhöhe", + "settings.hourheight.compact" to "Kompakt", "settings.hourheight.normal" to "Normal", + "settings.hourheight.comfort" to "Komfort", "settings.hourheight.large" to "Gross", "common.cancel" to "Abbrechen", "common.close" to "Schliessen", "common.ok" to "OK", "common.error" to "Fehler", "common.delete" to "Löschen", "common.save" to "Sichern", "common.retry" to "Erneut versuchen", @@ -88,6 +94,7 @@ object L10n { "event.calendar_picker" to "Kalender", "event.color_section" to "Farbe", "event.color" to "Terminfarbe", "event.reset_color" to "Zurücksetzen", "event.edit_title" to "Termin bearbeiten", "event.new_title" to "Neuer Termin", + "event.copy_title" to "Termin kopieren", "event.copy_to" to "In Kalender kopieren", "event.save" to "Sichern", "event.add" to "Hinzufügen", "event.delete_confirm" to "Diesen Termin löschen?", "accounts.title" to "Konten", "accounts.loading" to "Lade Konten…", @@ -146,10 +153,16 @@ object L10n { "settings.color.accent" to "Accent color", "settings.color.today" to "Today color", "settings.color.divider" to "Month divider line", "settings.color.label" to "Month abbreviation", "settings.textcontrast" to "Text contrast", "settings.linecontrast" to "Line contrast", + "settings.contrast.dark" to "Dark", "settings.contrast.medium" to "Medium", + "settings.contrast.bright" to "Bright", "settings.contrast.max" to "Maximum", + "settings.linecontrast.barely" to "Barely", "settings.linecontrast.subtle" to "Subtle", + "settings.linecontrast.normal" to "Normal", "settings.linecontrast.strong" to "Strong", "settings.calview" to "Calendar view", "settings.defaultview" to "Default view", "settings.firstweekday" to "First day of week", "settings.monday" to "Monday", "settings.sunday" to "Sunday", "settings.dimpast" to "Dim past events", "settings.hourheight" to "Hour height", + "settings.hourheight.compact" to "Compact", "settings.hourheight.normal" to "Normal", + "settings.hourheight.comfort" to "Comfort", "settings.hourheight.large" to "Large", "common.cancel" to "Cancel", "common.close" to "Close", "common.ok" to "OK", "common.error" to "Error", "common.delete" to "Delete", "common.save" to "Save", "common.retry" to "Retry", @@ -181,6 +194,7 @@ object L10n { "event.calendar_picker" to "Calendar", "event.color_section" to "Color", "event.color" to "Event color", "event.reset_color" to "Reset", "event.edit_title" to "Edit event", "event.new_title" to "New event", + "event.copy_title" to "Copy event", "event.copy_to" to "Copy to calendar", "event.save" to "Save", "event.add" to "Add", "event.delete_confirm" to "Delete this event?", "accounts.title" to "Accounts", "accounts.loading" to "Loading accounts…", diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index ded34dd..df89cab 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -53,7 +53,7 @@ import java.time.LocalDate private enum class Overlay { NONE, PROFILE, SETTINGS, ACCOUNTS } -data class EditorRequest(val existing: CalEvent?, val date: LocalDate) +data class EditorRequest(val existing: CalEvent?, val date: LocalDate, val prefill: CalEvent? = null) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -197,6 +197,10 @@ fun CalendarScreen( vm.deleteEvent(ev) {} detailEvent = null }, + onCopy = { + detailEvent = null + editor = EditorRequest(existing = null, date = localDate(ev.startDate), prefill = ev) + }, ) } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt index 04a8a5a..9c7db02 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt @@ -40,6 +40,8 @@ import com.scarriffle.calendarr.util.contrastingTextColor import java.time.LocalDate import java.time.format.TextStyle import java.time.temporal.ChronoUnit +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map private const val MONTHS_BACK = 18L private const val MONTHS_AHEAD = 18L @@ -85,16 +87,21 @@ fun MonthView( LaunchedEffect(scrollToTodaySignal) { if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0)) } - // Track visible month + trigger on-demand loads. + // Track visible month + trigger on-demand loads (only when the month changes). LaunchedEffect(listState, weekCount) { - snapshotFlow { listState.firstVisibleItemIndex }.collect { idx -> - val weekStart = firstVisible.plusWeeks(idx.toLong()) - val month = weekStart.plusDays(3) // mid-week → representative month - onVisibleMonthChange(month) - vm.ensureMonthLoaded(month) - } + snapshotFlow { listState.firstVisibleItemIndex } + .map { firstVisible.plusWeeks(it.toLong()).plusDays(3).withDayOfMonth(1) } + .distinctUntilChanged() + .collect { month -> + onVisibleMonthChange(month) + vm.ensureMonthLoaded(month) + } } + // Precompute a day → events index once per event-list change (avoids + // filtering the whole list for every cell on every recomposition → smooth scroll). + val eventsByDay = remember(state.events) { buildEventsByDay(state.events) } + Column(Modifier.fillMaxSize()) { // Fixed weekday header Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { @@ -116,8 +123,7 @@ fun MonthView( WeekRow( weekStart = weekStart, today = today, - events = state.events, - vm = vm, + eventsByDay = eventsByDay, lang = lang, onDayClick = onDayClick, onDayLongPress = onDayLongPress, @@ -133,8 +139,7 @@ fun MonthView( private fun WeekRow( weekStart: LocalDate, today: LocalDate, - events: List, - vm: CalendarViewModel, + eventsByDay: Map>, lang: String, onDayClick: (LocalDate) -> Unit, onDayLongPress: (LocalDate) -> Unit, @@ -150,7 +155,7 @@ private fun WeekRow( DayCell( day = day, isToday = day == today, - events = vm.eventsOn(day, events), + events = eventsByDay[day] ?: emptyList(), lang = lang, onClick = { onDayClick(day) }, onLongClick = { onDayLongPress(day) }, @@ -246,3 +251,22 @@ private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate { val offset = if (mondayFirst) dow - 1 else dow % 7 return date.minusDays(offset.toLong()) } + +/** Index events by each local day they overlap, for O(1) lookup while scrolling. */ +private fun buildEventsByDay(events: List): Map> { + val map = HashMap>() + for (ev in events) { + val first = localDate(ev.startDate) + // end is exclusive; the last covered day is the instant just before it + val last = localDate(ev.endDate.minusSeconds(1)).coerceAtLeast(first) + var d = first + var guard = 0 + while (!d.isAfter(last) && guard < 400) { + map.getOrPut(d) { mutableListOf() }.add(ev) + d = d.plusDays(1) + guard++ + } + } + map.values.forEach { list -> list.sortBy { it.startDate } } + return map +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt index 0d9532f..a54512d 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt @@ -5,12 +5,16 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.LocationOn @@ -41,13 +45,14 @@ import com.scarriffle.calendarr.ui.calendar.eventDateRange import com.scarriffle.calendarr.ui.tr import com.scarriffle.calendarr.util.colorFromHex -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun EventDetailSheet( event: CalEvent, onDismiss: () -> Unit, onEdit: () -> Unit, onDelete: () -> Unit, + onCopy: () -> Unit, ) { val lang = LocalLang.current var confirmDelete by remember { mutableStateOf(false) } @@ -68,7 +73,7 @@ fun EventDetailSheet( Spacer(Modifier.size(16.dp)) DetailRow(Icons.Filled.Schedule, eventDateRange(event, lang)) if (event.calendarName.isNotBlank()) { - DetailRow(Icons.Filled.Notes, event.calendarName) + DetailRow(Icons.Filled.CalendarMonth, event.calendarName) } if (event.location.isNotBlank()) { DetailRow(Icons.Filled.LocationOn, event.location) @@ -77,7 +82,7 @@ fun EventDetailSheet( DetailRow(Icons.Filled.Notes, event.notes) } Spacer(Modifier.size(20.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { if (canEdit) { OutlinedButton(onClick = onEdit) { Icon(Icons.Filled.Edit, contentDescription = null) @@ -85,6 +90,11 @@ fun EventDetailSheet( Text(tr("event.edit_title")) } } + OutlinedButton(onClick = onCopy) { + Icon(Icons.Filled.ContentCopy, contentDescription = null) + Spacer(Modifier.size(6.dp)) + Text(tr("event.copy_title")) + } if (canDelete) { OutlinedButton(onClick = { confirmDelete = true }) { Icon(Icons.Filled.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt index dee00e3..a510d32 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt @@ -75,16 +75,19 @@ fun EventEditorSheet( val context = LocalContext.current val lang = LocalLang.current val existing = request.existing + // Fields are prefilled from the event being edited OR copied. + val template = existing ?: request.prefill + val isCopy = existing == null && request.prefill != null val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - var title by remember { mutableStateOf(existing?.title ?: "") } - var allDay by remember { mutableStateOf(existing?.isAllDay ?: false) } - var location by remember { mutableStateOf(existing?.location ?: "") } - var description by remember { mutableStateOf(existing?.notes ?: "") } - var color by remember { mutableStateOf(existing?.color) } + var title by remember { mutableStateOf(template?.title ?: "") } + var allDay by remember { mutableStateOf(template?.isAllDay ?: false) } + var location by remember { mutableStateOf(template?.location ?: "") } + var description by remember { mutableStateOf(template?.notes ?: "") } + var color by remember { mutableStateOf(template?.color) } - val initialStart = existing?.startDate - val initialEnd = existing?.endDate + val initialStart = template?.startDate + val initialEnd = template?.endDate var startDate by remember { mutableStateOf(initialStart?.let { LocalDate.ofInstant(it, zone) } ?: request.date) } @@ -95,7 +98,7 @@ fun EventEditorSheet( mutableStateOf( initialEnd?.let { val d = LocalDate.ofInstant(it, zone) - if (existing?.isAllDay == true) d.minusDays(1) else d + if (template?.isAllDay == true) d.minusDays(1) else d } ?: request.date ) } @@ -103,7 +106,7 @@ fun EventEditorSheet( mutableStateOf(initialEnd?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: LocalTime.of(10, 0)) } - val preselected = existing?.let { ev -> + val preselected = template?.let { ev -> val id = calendarKey(ev.source, ev.calendarId).substringAfter(":").toIntOrNull() writableCalendars.firstOrNull { it.source == ev.source && it.numericId == id } } @@ -139,7 +142,11 @@ fun EventEditorSheet( .padding(bottom = 32.dp), ) { Text( - if (existing == null) tr("event.new_title") else tr("event.edit_title"), + when { + existing != null -> tr("event.edit_title") + isCopy -> tr("event.copy_title") + else -> tr("event.new_title") + }, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(vertical = 8.dp), diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt index 6478841..46f4dc6 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -17,6 +19,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api @@ -25,7 +28,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -38,7 +40,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -47,6 +48,7 @@ import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.ui.LocalAppSettings import com.scarriffle.calendarr.ui.tr import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.contrastingTextColor private val PALETTE = listOf( "#4285f4", "#ea4335", "#34a853", "#fbbc05", "#46bdc6", "#9c27b0", "#ff7043", "#7090c0", @@ -120,19 +122,37 @@ fun SettingsScreen( Divider(Modifier.padding(vertical = 16.dp)) Section(tr("settings.hourheight")) - Text("${settings.hourHeight} dp", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - Slider( - value = settings.hourHeight.toFloat(), - onValueChange = { settings = settings.copy(hourHeight = it.toInt()) }, - onValueChangeFinished = { update(settings) }, - valueRange = 28f..100f, + ChipRow( + options = listOf( + "28" to tr("settings.hourheight.compact"), + "44" to tr("settings.hourheight.normal"), + "60" to tr("settings.hourheight.comfort"), + "80" to tr("settings.hourheight.large"), + ), + selected = settings.hourHeight.toString(), + onSelect = { update(settings.copy(hourHeight = it.toInt())) }, ) - Spacer(Modifier.size(8.dp)) + Spacer(Modifier.size(16.dp)) Section(tr("settings.textcontrast")) - ContrastStepper(settings.textContrast) { update(settings.copy(textContrast = it)) } + ChipRow( + options = listOf( + "1" to tr("settings.contrast.dark"), "2" to tr("settings.contrast.medium"), + "3" to tr("settings.contrast.bright"), "4" to tr("settings.contrast.max"), + ), + selected = settings.textContrast.toString(), + onSelect = { update(settings.copy(textContrast = it.toInt())) }, + ) + Spacer(Modifier.size(16.dp)) Section(tr("settings.linecontrast")) - ContrastStepper(settings.lineContrast) { update(settings.copy(lineContrast = it)) } + ChipRow( + options = listOf( + "1" to tr("settings.linecontrast.barely"), "2" to tr("settings.linecontrast.subtle"), + "3" to tr("settings.linecontrast.normal"), "4" to tr("settings.linecontrast.strong"), + ), + selected = settings.lineContrast.toString(), + onSelect = { update(settings.copy(lineContrast = it.toInt())) }, + ) Divider(Modifier.padding(vertical = 16.dp)) Section(tr("settings.cache.title")) @@ -162,29 +182,35 @@ private fun ChipRow(options: List>, selected: String, onSel } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun ColorChooser(label: String, current: String, onPick: (String) -> Unit) { - Column(Modifier.padding(vertical = 6.dp)) { - Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - Row(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { - PALETTE.forEach { hex -> + // Show the user's current colour even if it isn't one of the presets. + val swatches = remember(current) { + (if (PALETTE.any { it.equals(current, ignoreCase = true) }) PALETTE else listOf(current) + PALETTE) + } + Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(18.dp).clip(CircleShape).background(colorFromHex(current))) + Spacer(Modifier.size(8.dp)) + Text(label, style = MaterialTheme.typography.bodyMedium) + } + FlowRow( + Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + swatches.forEach { hex -> val selected = hex.equals(current, ignoreCase = true) Box( - Modifier.size(28.dp).clip(CircleShape).background(colorFromHex(hex)) - .then(if (selected) Modifier.border(2.dp, Color.White, CircleShape) else Modifier) + Modifier.size(34.dp).clip(CircleShape).background(colorFromHex(hex)) + .then(if (selected) Modifier.border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape) else Modifier) .clickable { onPick(hex) }, - ) + contentAlignment = Alignment.Center, + ) { + if (selected) Icon(Icons.Filled.Check, contentDescription = null, tint = colorFromHex(hex).contrastingTextColor(), modifier = Modifier.size(18.dp)) + } } } } } - -@Composable -private fun ContrastStepper(value: Int, onChange: (Int) -> Unit) { - Slider( - value = value.toFloat(), - onValueChange = { onChange(it.toInt().coerceIn(1, 4)) }, - valueRange = 1f..4f, - steps = 2, - ) -} diff --git a/app/src/main/res/drawable/splash_icon.xml b/app/src/main/res/drawable/splash_icon.xml new file mode 100644 index 0000000..a72e494 --- /dev/null +++ b/app/src/main/res/drawable/splash_icon.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml new file mode 100644 index 0000000..70e8a9d --- /dev/null +++ b/app/src/main/res/values-v31/themes.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index a564b56..7b56210 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,10 @@ + #0B1220 + From b8eb6597ecc0bb8932edf31983114920b380f0a0 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 14:19:56 +0200 Subject: [PATCH 07/59] feat: HA/Google edit, full-page event detail, KW+zigzag divider, HSV picker, splash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Home Assistant & Google events now editable/deletable (server PUT/DELETE endpoints wired; saveEvent had no HA/Google update branch -> created dupes) - Event detail is now a full screen (Scaffold) instead of a bottom sheet: long notes scroll, action buttons no longer hidden behind the system nav bar; distinct calendar vs notes icons - Month view: calendar-week number on Mondays, month-boundary divider with zigzag step (month_divider_color), month label in month_label_color, text/line contrast applied - Compact top bar with smaller title; prev/next chevrons present in month view and scroll one month at a time - Real HSV colour picker (SV field + hue slider + hex) for primary, accent, today, month-divider and month-label colours - Black status/navigation bars; portrait-locked; crisp custom splash with app name, high-res icon and © Scarriffle Co-Authored-By: Claude Opus 4.8 --- app/src/main/AndroidManifest.xml | 1 + .../calendarr/data/CalendarRepository.kt | 53 +++-- .../calendarr/data/remote/CalendarrApi.kt | 22 +- .../scarriffle/calendarr/ui/CalendarrRoot.kt | 14 ++ .../java/com/scarriffle/calendarr/ui/L10n.kt | 2 + .../scarriffle/calendarr/ui/SplashScreen.kt | 45 ++++ .../ui/calendar/CalendarFormatting.kt | 16 ++ .../calendarr/ui/calendar/CalendarScreen.kt | 155 ++++++++---- .../ui/calendar/CalendarViewModel.kt | 3 + .../calendarr/ui/calendar/MonthView.kt | 223 ++++++++++++------ .../ui/components/ColorPickerDialog.kt | 152 ++++++++++++ .../calendarr/ui/event/EventDetailScreen.kt | 158 +++++++++++++ .../calendarr/ui/event/EventDetailSheet.kt | 133 ----------- .../calendarr/ui/settings/SettingsScreen.kt | 56 ++--- .../res/drawable-nodpi/ic_splash_logo.png | Bin 0 -> 22168 bytes app/src/main/res/drawable/splash_icon.xml | 4 +- app/src/main/res/values-v31/themes.xml | 7 +- app/src/main/res/values/themes.xml | 9 +- 18 files changed, 738 insertions(+), 315 deletions(-) create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/components/ColorPickerDialog.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt delete mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt create mode 100644 app/src/main/res/drawable-nodpi/ic_splash_logo.png diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6494c5c..b7303f7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ 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 6aadd8b..3cb56e6 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -305,31 +305,56 @@ class CalendarRepository @Inject constructor( calendarDbId: Int, title: String, start: Instant, end: Instant, isAllDay: Boolean, location: String, description: String, ) = guarded { - api.createGoogleEvent( - jsonBody( - "calendar_db_id" to calendarDbId, "title" to title, - "start" to Dates.format(start, isAllDay), "end" to Dates.format(end, isAllDay), - "allDay" to isAllDay, "location" to location, "description" to description, - ) - ).ensureSuccess() + api.createGoogleEvent(simpleEventBody("calendar_db_id", calendarDbId, title, start, end, isAllDay, location, description)) + .ensureSuccess() } + suspend fun updateGoogleEvent( + gcalDbId: Int, eventId: String, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, + ) = guarded { + api.updateGoogleEvent(gcalDbId, eventId, simpleEventBody(null, null, title, start, end, isAllDay, location, description)) + .ensureSuccess() + } + + suspend fun deleteGoogleEvent(gcalDbId: Int, eventId: String) = + guarded { api.deleteGoogleEvent(gcalDbId, eventId).ensureSuccess() } + suspend fun createHAEvent( calendarId: Int, title: String, start: Instant, end: Instant, isAllDay: Boolean, location: String, description: String, ) = guarded { - api.createHAEvent( - jsonBody( - "calendar_id" to calendarId, "title" to title, - "start" to Dates.format(start, isAllDay), "end" to Dates.format(end, isAllDay), - "allDay" to isAllDay, "location" to location, "description" to description, - ) - ).ensureSuccess() + api.createHAEvent(simpleEventBody("calendar_id", calendarId, title, start, end, isAllDay, location, description)) + .ensureSuccess() + } + + suspend fun updateHAEvent( + calendarId: Int, uid: String, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, + ) = guarded { + api.updateHAEvent(calendarId, uid, simpleEventBody(null, null, title, start, end, isAllDay, location, description)) + .ensureSuccess() } suspend fun deleteHAEvent(calendarId: Int, uid: String) = guarded { api.deleteHAEvent(calendarId, uid).ensureSuccess() } + /** Body for Google/HA events (no per-event colour). */ + private fun simpleEventBody( + calKey: String?, calId: Int?, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, + ) = jsonBody( + buildMap { + if (calKey != null && calId != null) put(calKey, calId) + put("title", title) + put("start", Dates.format(start, isAllDay)) + put("end", Dates.format(end, isAllDay)) + put("allDay", isAllDay) + put("location", location) + put("description", description) + } + ) + private fun eventBody( calendarId: Int?, title: String, start: Instant, end: Instant, isAllDay: Boolean, location: String, description: String, color: String?, diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt index 65cb42a..d831a0d 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt @@ -170,10 +170,30 @@ interface CalendarrApi { @POST("api/google/events") suspend fun createGoogleEvent(@Body body: RequestBody): Response + @PUT("api/google/events/{gcalDbId}/{eventId}") + suspend fun updateGoogleEvent( + @Path("gcalDbId") gcalDbId: Int, + @Path("eventId") eventId: String, + @Body body: RequestBody, + ): Response + + @HTTP(method = "DELETE", path = "api/google/events/{gcalDbId}/{eventId}", hasBody = false) + suspend fun deleteGoogleEvent( + @Path("gcalDbId") gcalDbId: Int, + @Path("eventId") eventId: String, + ): Response + @POST("api/homeassistant/events") suspend fun createHAEvent(@Body body: RequestBody): Response - @DELETE("api/homeassistant/events/{calendarId}/{uid}") + @PUT("api/homeassistant/events/{calendarId}/{uid}") + suspend fun updateHAEvent( + @Path("calendarId") calendarId: Int, + @Path("uid") uid: String, + @Body body: RequestBody, + ): Response + + @HTTP(method = "DELETE", path = "api/homeassistant/events/{calendarId}/{uid}", hasBody = false) suspend fun deleteHAEvent( @Path("calendarId") calendarId: Int, @Path("uid") uid: String, diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt index b61e566..c8e63fc 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt @@ -5,8 +5,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.hilt.navigation.compose.hiltViewModel import com.scarriffle.calendarr.ui.auth.LoginScreen @@ -19,12 +23,22 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) { val route by vm.route.collectAsState() val settings by vm.settings.collectAsState() + var showSplash by remember { mutableStateOf(true) } + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(1200) + showSplash = false + } + CalendarrTheme(settings) { CompositionLocalProvider( LocalLang provides L10n.resolved(settings.language), LocalAppSettings provides settings, ) { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + if (showSplash) { + SplashScreen() + return@Surface + } when (route) { AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured) AppRoute.LOGIN -> LoginScreen( diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt index 7ff2d54..30de0b8 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -95,6 +95,7 @@ object L10n { "event.color" to "Terminfarbe", "event.reset_color" to "Zurücksetzen", "event.edit_title" to "Termin bearbeiten", "event.new_title" to "Neuer Termin", "event.copy_title" to "Termin kopieren", "event.copy_to" to "In Kalender kopieren", + "event.detail_title" to "Termin", "event.source" to "Quelle", "event.save" to "Sichern", "event.add" to "Hinzufügen", "event.delete_confirm" to "Diesen Termin löschen?", "accounts.title" to "Konten", "accounts.loading" to "Lade Konten…", @@ -195,6 +196,7 @@ object L10n { "event.color" to "Event color", "event.reset_color" to "Reset", "event.edit_title" to "Edit event", "event.new_title" to "New event", "event.copy_title" to "Copy event", "event.copy_to" to "Copy to calendar", + "event.detail_title" to "Event", "event.source" to "Source", "event.save" to "Save", "event.add" to "Add", "event.delete_confirm" to "Delete this event?", "accounts.title" to "Accounts", "accounts.loading" to "Loading accounts…", diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt new file mode 100644 index 0000000..9bf141a --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt @@ -0,0 +1,45 @@ +package com.scarriffle.calendarr.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.R + +/** Custom startup screen: app name on top, crisp icon centered, copyright at the bottom. */ +@Composable +fun SplashScreen() { + Box(Modifier.fillMaxSize().background(Color.Black)) { + Text( + "Calendarr", + modifier = Modifier.align(Alignment.TopCenter).padding(top = 96.dp), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + color = Color.White, + ) + Image( + painter = painterResource(R.drawable.ic_splash_logo), + contentDescription = null, + modifier = Modifier.align(Alignment.Center).size(148.dp).clip(RoundedCornerShape(32.dp)), + ) + Text( + "© Scarriffle", + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 40.dp), + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.6f), + ) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt index 01ee814..71c016c 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt @@ -49,6 +49,22 @@ fun weekdayLabels(mondayFirst: Boolean, lang: String): List { fun timeLabel(instant: Instant, lang: String): String = DateTimeFormatter.ofPattern("HH:mm", L10n.locale(lang)).withZone(zone).format(instant) +/** Opacity for secondary text (week number, overflow count) per text_contrast 1..4. */ +fun secondaryTextOpacity(contrast: Int): Float = when (contrast.coerceIn(1, 4)) { + 1 -> 0.40f + 2 -> 0.55f + 3 -> 0.72f + else -> 0.92f +} + +/** Opacity for regular grid lines per line_contrast 1..4. */ +fun gridLineOpacity(contrast: Int): Float = when (contrast.coerceIn(1, 4)) { + 1 -> 0.15f + 2 -> 0.30f + 3 -> 0.50f + else -> 0.80f +} + fun localTime(instant: Instant): LocalTime = LocalTime.ofInstant(instant, zone) fun localDate(instant: Instant): LocalDate = LocalDate.ofInstant(instant, zone) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index df89cab..4dfe8f3 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -2,9 +2,13 @@ package com.scarriffle.calendarr.ui.calendar import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ChevronLeft @@ -22,9 +26,9 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -35,15 +39,17 @@ 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.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.scarriffle.calendarr.domain.model.AppSettings import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.ui.LocalLang import com.scarriffle.calendarr.ui.accounts.AccountsScreen -import com.scarriffle.calendarr.ui.event.EventDetailSheet +import com.scarriffle.calendarr.ui.event.EventDetailScreen import com.scarriffle.calendarr.ui.event.EventEditorSheet import com.scarriffle.calendarr.ui.menu.MenuSheet import com.scarriffle.calendarr.ui.profile.ProfileScreen @@ -77,57 +83,36 @@ fun CalendarScreen( // Continuous month scrolling val monthListState = rememberLazyListState() var todaySignal by remember { mutableIntStateOf(0) } + var monthJumpSignal by remember { mutableIntStateOf(0) } + var monthJumpTarget by remember { mutableStateOf(null) } var visibleMonth by remember { mutableStateOf(state.currentDate) } val isMonth = state.viewType == CalViewType.MONTH val barTitle = if (isMonth) titleForView(CalViewType.MONTH, visibleMonth, lang) else titleForView(state.viewType, state.currentDate, lang) + fun goPrev() { + if (isMonth) { monthJumpTarget = visibleMonth.minusMonths(1); monthJumpSignal++ } else vm.navigatePrev() + } + fun goNext() { + if (isMonth) { monthJumpTarget = visibleMonth.plusMonths(1); monthJumpSignal++ } else vm.navigateNext() + } + fun goToday() { + if (isMonth) todaySignal++ else vm.moveToToday() + } + Scaffold( topBar = { - TopAppBar( - title = { - Text(barTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) - }, - navigationIcon = { - IconButton(onClick = { showMenu = true }) { - Icon(Icons.Filled.Menu, contentDescription = tr("nav.menu")) - } - }, - actions = { - if (!isMonth) { - IconButton(onClick = vm::navigatePrev) { - Icon(Icons.Filled.ChevronLeft, contentDescription = null) - } - } - IconButton(onClick = { if (isMonth) todaySignal++ else vm.moveToToday() }) { - Icon(Icons.Filled.Today, contentDescription = tr("nav.today")) - } - if (!isMonth) { - IconButton(onClick = vm::navigateNext) { - Icon(Icons.Filled.ChevronRight, contentDescription = null) - } - } - IconButton(onClick = { showFilter = true }) { - Icon(Icons.Filled.FilterList, contentDescription = tr("filter.button")) - } - Box { - IconButton(onClick = { viewMenuOpen = true }) { - Icon(state.viewType.icon, contentDescription = tr("view.change")) - } - DropdownMenu(expanded = viewMenuOpen, onDismissRequest = { viewMenuOpen = false }) { - CalViewType.entries.forEach { type -> - DropdownMenuItem( - text = { Text(tr("view.${type.key}")) }, - leadingIcon = { Icon(type.icon, contentDescription = null) }, - onClick = { - viewMenuOpen = false - vm.setViewType(type) - }, - ) - } - } - } - }, + CompactTopBar( + title = barTitle, + viewType = state.viewType, + viewMenuOpen = viewMenuOpen, + onMenu = { showMenu = true }, + onPrev = { goPrev() }, + onToday = { goToday() }, + onNext = { goNext() }, + onFilter = { showFilter = true }, + onViewMenuToggle = { viewMenuOpen = it }, + onSelectView = { vm.setViewType(it) }, ) }, floatingActionButton = { @@ -153,6 +138,8 @@ fun CalendarScreen( vm = vm, monthListState = monthListState, scrollToTodaySignal = todaySignal, + monthJumpSignal = monthJumpSignal, + monthJumpTarget = monthJumpTarget, onVisibleMonthChange = { visibleMonth = it }, onEventClick = { detailEvent = it }, onDayClick = { date -> vm.goToDate(date, CalViewType.DAY) }, @@ -186,21 +173,21 @@ fun CalendarScreen( } detailEvent?.let { ev -> - EventDetailSheet( + EventDetailScreen( event = ev, - onDismiss = { detailEvent = null }, + onClose = { detailEvent = null }, onEdit = { detailEvent = null editor = EditorRequest(ev, localDate(ev.startDate)) }, - onDelete = { - vm.deleteEvent(ev) {} - detailEvent = null - }, onCopy = { detailEvent = null editor = EditorRequest(existing = null, date = localDate(ev.startDate), prefill = ev) }, + onDelete = { + vm.deleteEvent(ev) {} + detailEvent = null + }, ) } @@ -238,6 +225,8 @@ private fun CalendarBody( vm: CalendarViewModel, monthListState: androidx.compose.foundation.lazy.LazyListState, scrollToTodaySignal: Int, + monthJumpSignal: Int, + monthJumpTarget: LocalDate?, onVisibleMonthChange: (LocalDate) -> Unit, onEventClick: (CalEvent) -> Unit, onDayClick: (LocalDate) -> Unit, @@ -249,6 +238,8 @@ private fun CalendarBody( vm = vm, listState = monthListState, scrollToTodaySignal = scrollToTodaySignal, + monthJumpSignal = monthJumpSignal, + monthJumpTarget = monthJumpTarget, onVisibleMonthChange = onVisibleMonthChange, onDayClick = onDayClick, onDayLongPress = onDayLongPress, @@ -284,6 +275,66 @@ private fun loadingPlaceholder() { } } +@Composable +private fun CompactTopBar( + title: String, + viewType: CalViewType, + viewMenuOpen: Boolean, + onMenu: () -> Unit, + onPrev: () -> Unit, + onToday: () -> Unit, + onNext: () -> Unit, + onFilter: () -> Unit, + onViewMenuToggle: (Boolean) -> Unit, + onSelectView: (CalViewType) -> Unit, +) { + Surface(color = MaterialTheme.colorScheme.background) { + Row( + Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CompactIcon(Icons.Filled.ChevronLeft, onPrev) + TextButton(onClick = onToday, contentPadding = PaddingValues(horizontal = 6.dp)) { + Text(tr("nav.today"), fontSize = 13.sp) + } + CompactIcon(Icons.Filled.ChevronRight, onNext) + Text( + title, + modifier = Modifier.weight(1f).padding(horizontal = 4.dp), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + ) + CompactIcon(Icons.Filled.FilterList, onFilter, tr("filter.button")) + Box { + CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change")) + DropdownMenu(expanded = viewMenuOpen, onDismissRequest = { onViewMenuToggle(false) }) { + CalViewType.entries.forEach { type -> + DropdownMenuItem( + text = { Text(tr("view.${type.key}")) }, + leadingIcon = { Icon(type.icon, contentDescription = null) }, + onClick = { onViewMenuToggle(false); onSelectView(type) }, + ) + } + } + } + CompactIcon(Icons.Filled.Menu, onMenu, tr("nav.menu")) + } + } +} + +@Composable +private fun CompactIcon( + icon: androidx.compose.ui.graphics.vector.ImageVector, + onClick: () -> Unit, + contentDescription: String? = null, +) { + IconButton(onClick = onClick, modifier = Modifier.size(40.dp)) { + Icon(icon, contentDescription = contentDescription, modifier = Modifier.size(22.dp)) + } +} + /** Distinct calendars currently present in the cache, for the filter sheet. */ private fun allKnownCalendars(vm: CalendarViewModel): List { val st = vm.state.value diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index 0853c09..d48bb08 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -295,6 +295,8 @@ class CalendarViewModel @Inject constructor( when (existing.source) { "local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color) "caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color) + "homeassistant" -> repository.updateHAEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description) + "google" -> repository.updateGoogleEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description) else -> createForSource(calendar, title, start, end, isAllDay, location, description, color) } } else { @@ -325,6 +327,7 @@ class CalendarViewModel @Inject constructor( "local" -> repository.deleteLocalEvent(event.id) "caldav" -> repository.deleteCalDAVEvent(event.id, event.url, calendarNumericId(event)) "homeassistant" -> repository.deleteHAEvent(calendarNumericId(event) ?: 0, event.id) + "google" -> repository.deleteGoogleEvent(calendarNumericId(event) ?: 0, event.id) else -> Unit } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt index 9c7db02..3210a80 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt @@ -5,18 +5,19 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Divider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -27,6 +28,9 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -35,37 +39,47 @@ import androidx.compose.ui.unit.sp import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.ui.LocalAppSettings import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.tr import com.scarriffle.calendarr.util.colorFromHex import com.scarriffle.calendarr.util.contrastingTextColor +import java.time.DayOfWeek import java.time.LocalDate import java.time.format.TextStyle import java.time.temporal.ChronoUnit +import java.time.temporal.IsoFields import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map private const val MONTHS_BACK = 18L private const val MONTHS_AHEAD = 18L +private val ROW_HEIGHT = 82.dp -/** - * Continuous, vertically scrolling month calendar (matches the iOS app). - * There are no prev/next buttons — the user scrolls through weeks and the - * top-bar title follows the currently visible month. - */ +private enum class DividerEdge { NONE, TOP, BOTTOM } + +/** Continuous, vertically scrolling month calendar (matches the iOS app). */ @Composable fun MonthView( state: CalendarUiState, vm: CalendarViewModel, listState: LazyListState, scrollToTodaySignal: Int, + monthJumpSignal: Int, + monthJumpTarget: LocalDate?, onVisibleMonthChange: (LocalDate) -> Unit, onDayClick: (LocalDate) -> Unit, onDayLongPress: (LocalDate) -> Unit, onEventClick: (CalEvent) -> Unit, ) { val lang = LocalLang.current + val settings = LocalAppSettings.current val mondayFirst = state.weekStartsOnMonday val today = LocalDate.now() + val dividerColor = colorFromHex(settings.monthDividerColor, Color(0xFF7090C0)) + val labelColor = colorFromHex(settings.monthLabelColor, MaterialTheme.colorScheme.onSurfaceVariant) + val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = gridLineOpacity(settings.lineContrast)) + val secondaryText = MaterialTheme.colorScheme.onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast)) + val firstVisible = remember(mondayFirst) { startOfWeek(today.withDayOfMonth(1).minusMonths(MONTHS_BACK), mondayFirst) } @@ -75,19 +89,20 @@ fun MonthView( val weekCount = remember(firstVisible, end) { (ChronoUnit.WEEKS.between(firstVisible, end).toInt() + 1).coerceAtLeast(1) } - val todayIndex = remember(firstVisible) { - ChronoUnit.WEEKS.between(firstVisible, startOfWeek(today, mondayFirst)).toInt() - } + fun weekIndexOf(date: LocalDate): Int = + ChronoUnit.WEEKS.between(firstVisible, startOfWeek(date, mondayFirst)).toInt().coerceIn(0, weekCount - 1) - // Initial scroll to today's week. - LaunchedEffect(Unit) { - listState.scrollToItem((todayIndex - 1).coerceAtLeast(0)) - } - // "Today" button. + val todayIndex = remember(firstVisible) { weekIndexOf(today) } + + LaunchedEffect(Unit) { listState.scrollToItem((todayIndex - 1).coerceAtLeast(0)) } LaunchedEffect(scrollToTodaySignal) { if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0)) } - // Track visible month + trigger on-demand loads (only when the month changes). + LaunchedEffect(monthJumpSignal) { + if (monthJumpSignal > 0 && monthJumpTarget != null) { + listState.animateScrollToItem(weekIndexOf(monthJumpTarget.withDayOfMonth(1))) + } + } LaunchedEffect(listState, weekCount) { snapshotFlow { listState.firstVisibleItemIndex } .map { firstVisible.plusWeeks(it.toLong()).plusDays(3).withDayOfMonth(1) } @@ -98,13 +113,10 @@ fun MonthView( } } - // Precompute a day → events index once per event-list change (avoids - // filtering the whole list for every cell on every recomposition → smooth scroll). val eventsByDay = remember(state.events) { buildEventsByDay(state.events) } Column(Modifier.fillMaxSize()) { - // Fixed weekday header - Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) { weekdayLabels(mondayFirst, lang).forEach { label -> Text( label, @@ -115,16 +127,19 @@ fun MonthView( ) } } - Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)) LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { items(weekCount) { index -> - val weekStart = firstVisible.plusWeeks(index.toLong()) WeekRow( - weekStart = weekStart, + weekStart = firstVisible.plusWeeks(index.toLong()), today = today, eventsByDay = eventsByDay, lang = lang, + dividerColor = dividerColor, + gridColor = gridColor, + labelColor = labelColor, + secondaryText = secondaryText, + todayColor = colorFromHex(settings.todayColor), onDayClick = onDayClick, onDayLongPress = onDayLongPress, onEventClick = onEventClick, @@ -134,37 +149,67 @@ fun MonthView( } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun WeekRow( weekStart: LocalDate, today: LocalDate, eventsByDay: Map>, lang: String, + dividerColor: Color, + gridColor: Color, + labelColor: Color, + secondaryText: Color, + todayColor: Color, onDayClick: (LocalDate) -> Unit, onDayLongPress: (LocalDate) -> Unit, onEventClick: (CalEvent) -> Unit, ) { val days = (0 until 7).map { weekStart.plusDays(it.toLong()) } - Row( - Modifier - .fillMaxWidth() - .height(78.dp), - ) { - days.forEach { day -> - DayCell( - day = day, - isToday = day == today, - events = eventsByDay[day] ?: emptyList(), - lang = lang, - onClick = { onDayClick(day) }, - onLongClick = { onDayLongPress(day) }, - onEventClick = onEventClick, - modifier = Modifier.weight(1f).fillMaxSize(), + val boundaryCol = (1 until 7).firstOrNull { days[it].dayOfMonth == 1 } + val rowStartsNewMonth = days[0].dayOfMonth == 1 + val cwLabel = tr("cal.cw") + + BoxWithConstraints(Modifier.fillMaxWidth().height(ROW_HEIGHT)) { + val cellW = maxWidth / 7 + Row(Modifier.fillMaxSize()) { + days.forEachIndexed { idx, day -> + val edge = when { + boundaryCol != null -> if (idx < boundaryCol) DividerEdge.BOTTOM else DividerEdge.TOP + rowStartsNewMonth -> DividerEdge.TOP + else -> DividerEdge.NONE + } + DayCell( + day = day, + isToday = day == today, + isMonday = day.dayOfWeek == DayOfWeek.MONDAY, + weekNumber = day.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR), + cwLabel = cwLabel, + events = eventsByDay[day] ?: emptyList(), + lang = lang, + edge = edge, + dividerColor = dividerColor, + gridColor = gridColor, + labelColor = labelColor, + secondaryText = secondaryText, + todayColor = todayColor, + onClick = { onDayClick(day) }, + onLongClick = { onDayLongPress(day) }, + onEventClick = onEventClick, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) + } + } + // Vertical connector at the month boundary column (the "step"). + if (boundaryCol != null) { + Box( + Modifier + .offset(x = cellW * boundaryCol) + .width(1.5.dp) + .fillMaxHeight() + .background(dividerColor), ) } } - Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)) } @OptIn(ExperimentalFoundationApi::class) @@ -172,54 +217,85 @@ private fun WeekRow( private fun DayCell( day: LocalDate, isToday: Boolean, + isMonday: Boolean, + weekNumber: Int, + cwLabel: String, events: List, lang: String, + edge: DividerEdge, + dividerColor: Color, + gridColor: Color, + labelColor: Color, + secondaryText: Color, + todayColor: Color, onClick: () -> Unit, onLongClick: () -> Unit, onEventClick: (CalEvent) -> Unit, modifier: Modifier = Modifier, ) { - val settings = LocalAppSettings.current - val todayColor = colorFromHex(settings.todayColor) - val monthLabelColor = colorFromHex(settings.monthLabelColor, MaterialTheme.colorScheme.onSurfaceVariant) val isFirst = day.dayOfMonth == 1 + val onBg = MaterialTheme.colorScheme.onBackground - Column( + Box( modifier = modifier - .combinedClickable(onClick = onClick, onLongClick = onLongClick) - .padding(horizontal = 1.dp, vertical = 2.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (isFirst) { - Text( - day.month.getDisplayName(TextStyle.SHORT, com.scarriffle.calendarr.ui.L10n.locale(lang)), - fontSize = 8.sp, - color = monthLabelColor, - modifier = Modifier.padding(end = 2.dp), - ) + .drawBehind { + val gridW = 0.5.dp.toPx() + val divW = 1.5.dp.toPx() + // top border (thick divider on a month boundary, otherwise thin grid) + if (edge == DividerEdge.TOP) { + drawLine(dividerColor, Offset(0f, 0f), Offset(size.width, 0f), divW) + } else { + drawLine(gridColor, Offset(0f, 0f), Offset(size.width, 0f), gridW) + } + // bottom border only when the month ends inside this row + if (edge == DividerEdge.BOTTOM) { + drawLine(dividerColor, Offset(0f, size.height), Offset(size.width, size.height), divW) + } + // right grid line + drawLine(gridColor, Offset(size.width, 0f), Offset(size.width, size.height), gridW) } - Box(contentAlignment = Alignment.Center) { - if (isToday) { - Box( - Modifier - .height(18.dp) - .width(18.dp) - .clip(RoundedCornerShape(9.dp)) - .background(todayColor), + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(horizontal = 1.dp), + ) { + Column( + Modifier.fillMaxSize().padding(top = 3.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (isFirst) { + Text( + day.month.getDisplayName(TextStyle.SHORT, com.scarriffle.calendarr.ui.L10n.locale(lang)).uppercase(), + fontSize = 8.sp, + fontWeight = FontWeight.SemiBold, + color = labelColor, + modifier = Modifier.padding(end = 2.dp), ) } - Text( - "${day.dayOfMonth}", - fontSize = 11.sp, - fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, - color = if (isToday) todayColor.contrastingTextColor() else MaterialTheme.colorScheme.onBackground, - ) + Box(contentAlignment = Alignment.Center) { + if (isToday) { + Box(Modifier.height(18.dp).width(18.dp).clip(RoundedCornerShape(9.dp)).background(todayColor)) + } + Text( + "${day.dayOfMonth}", + fontSize = 11.sp, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, + color = if (isToday) todayColor.contrastingTextColor() else onBg, + ) + } + } + events.take(3).forEach { ev -> EventChip(ev) { onEventClick(ev) } } + if (events.size > 3) { + Text("+${events.size - 3}", fontSize = 8.sp, color = secondaryText) } } - events.take(3).forEach { ev -> EventChip(ev) { onEventClick(ev) } } - if (events.size > 3) { - Text("+${events.size - 3}", fontSize = 8.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + // Calendar week number, bottom-right of the Monday cell. + if (isMonday) { + Text( + "$cwLabel $weekNumber", + fontSize = 8.sp, + color = secondaryText, + modifier = Modifier.align(Alignment.BottomEnd).padding(end = 3.dp, bottom = 2.dp), + ) } } } @@ -257,7 +333,6 @@ private fun buildEventsByDay(events: List): Map>() for (ev in events) { val first = localDate(ev.startDate) - // end is exclusive; the last covered day is the instant just before it val last = localDate(ev.endDate.minusSeconds(1)).coerceAtLeast(first) var d = first var guard = 0 diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/components/ColorPickerDialog.kt b/app/src/main/java/com/scarriffle/calendarr/ui/components/ColorPickerDialog.kt new file mode 100644 index 0000000..4132d12 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/components/ColorPickerDialog.kt @@ -0,0 +1,152 @@ +package com.scarriffle.calendarr.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.toHex + +/** + * Full HSV colour picker: saturation/value field + hue slider + hex input. + * Returns a "#RRGGBB" string via [onConfirm]. + */ +@Composable +fun ColorPickerDialog( + initial: String, + title: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + val initHsv = remember(initial) { + FloatArray(3).also { android.graphics.Color.colorToHSV(colorFromHex(initial).toArgb(), it) } + } + var hue by remember { mutableFloatStateOf(initHsv[0]) } + var sat by remember { mutableFloatStateOf(initHsv[1]) } + var value by remember { mutableFloatStateOf(initHsv[2]) } + var hexText by remember { mutableStateOf(colorFromHex(initial).toHex()) } + + val current = Color.hsv(hue, sat, value) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(Modifier.fillMaxWidth()) { + // Saturation / Value field + Box( + Modifier + .fillMaxWidth() + .height(170.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Brush.horizontalGradient(listOf(Color.White, Color.hsv(hue, 1f, 1f)))) + .background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black))) + .pointerInput(Unit) { + detectTapGestures { o -> + sat = (o.x / size.width).coerceIn(0f, 1f) + value = (1f - o.y / size.height).coerceIn(0f, 1f) + hexText = Color.hsv(hue, sat, value).toHex() + } + } + .pointerInput(Unit) { + detectDragGestures { change, _ -> + change.consume() + sat = (change.position.x / size.width).coerceIn(0f, 1f) + value = (1f - change.position.y / size.height).coerceIn(0f, 1f) + hexText = Color.hsv(hue, sat, value).toHex() + } + }, + ) { + Canvas(Modifier.matchParentSize()) { + val cx = sat * size.width + val cy = (1f - value) * size.height + drawCircle(Color.White, radius = 9f, center = Offset(cx, cy), style = Stroke(width = 3f)) + drawCircle(Color.Black, radius = 12f, center = Offset(cx, cy), style = Stroke(width = 1f)) + } + } + Spacer(Modifier.height(14.dp)) + + // Hue slider + Box( + Modifier + .fillMaxWidth() + .height(26.dp) + .clip(RoundedCornerShape(13.dp)) + .background(Brush.horizontalGradient((0..6).map { Color.hsv(it * 60f, 1f, 1f) })) + .pointerInput(Unit) { + detectTapGestures { o -> + hue = (o.x / size.width * 360f).coerceIn(0f, 360f) + hexText = Color.hsv(hue, sat, value).toHex() + } + } + .pointerInput(Unit) { + detectDragGestures { change, _ -> + change.consume() + hue = (change.position.x / size.width * 360f).coerceIn(0f, 360f) + hexText = Color.hsv(hue, sat, value).toHex() + } + }, + ) { + Canvas(Modifier.matchParentSize()) { + val cx = (hue / 360f) * size.width + drawCircle(Color.White, radius = 10f, center = Offset(cx, size.height / 2f), style = Stroke(width = 3f)) + } + } + Spacer(Modifier.height(14.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(40.dp).clip(CircleShape).background(current).border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)) + Spacer(Modifier.size(12.dp)) + OutlinedTextField( + value = hexText, + onValueChange = { raw -> + hexText = raw + val parsed = colorFromHex(raw, Color.Unspecified) + if (parsed != Color.Unspecified) { + val hsv = FloatArray(3) + android.graphics.Color.colorToHSV(parsed.toArgb(), hsv) + hue = hsv[0]; sat = hsv[1]; value = hsv[2] + } + }, + label = { Text("Hex") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + }, + confirmButton = { TextButton(onClick = { onConfirm(current.toHex()) }) { Text("OK") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Abbrechen") } }, + ) +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt new file mode 100644 index 0000000..584c6e8 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt @@ -0,0 +1,158 @@ +package com.scarriffle.calendarr.ui.event + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Notes +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +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.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.calendar.eventDateRange +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex + +/** Full-screen event detail (mirrors the iOS detail page). */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventDetailScreen( + event: CalEvent, + onClose: () -> Unit, + onEdit: () -> Unit, + onCopy: () -> Unit, + onDelete: () -> Unit, +) { + val lang = LocalLang.current + var confirmDelete by remember { mutableStateOf(false) } + val canEdit = event.source in setOf("local", "caldav", "homeassistant", "google") + val canDelete = canEdit + + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(tr("event.detail_title")) }, + navigationIcon = { + IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) } + }, + actions = { + if (canEdit) { + TextButton(onClick = onEdit) { Text(tr("event.edit_title")) } + } + }, + ) + }, + ) { padding -> + Column( + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(20.dp), + ) { + Row(verticalAlignment = Alignment.Top) { + Box( + Modifier.width(6.dp).height(46.dp).clip(RoundedCornerShape(3.dp)) + .background(colorFromHex(event.effectiveColor)), + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(event.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) + if (event.calendarName.isNotBlank()) { + Text(event.calendarName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + Spacer(Modifier.height(20.dp)) + + DetailRow(Icons.Filled.Schedule, eventDateRange(event, lang)) + if (event.location.isNotBlank()) DetailRow(Icons.Filled.LocationOn, event.location) + if (event.notes.isNotBlank()) DetailRow(Icons.Filled.Notes, event.notes) + if (event.calendarName.isNotBlank()) DetailRow(Icons.Filled.CalendarMonth, event.calendarName) + DetailRow(Icons.Filled.Dns, event.source.replaceFirstChar { it.uppercase() }) + + Spacer(Modifier.height(28.dp)) + OutlinedButton(onClick = onCopy, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Filled.ContentCopy, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(tr("event.copy_title")) + } + if (canDelete) { + Spacer(Modifier.height(12.dp)) + Button( + onClick = { confirmDelete = true }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer), + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Delete, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(tr("common.delete")) + } + } + Spacer(Modifier.height(24.dp)) + } + } + } + + if (confirmDelete) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + title = { Text(tr("common.delete")) }, + text = { Text(tr("event.delete_confirm")) }, + confirmButton = { + TextButton(onClick = { confirmDelete = false; onDelete() }) { + Text(tr("common.delete"), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text(tr("common.cancel")) } }, + ) + } +} + +@Composable +private fun DetailRow(icon: ImageVector, text: String) { + Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.Top) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp)) + Spacer(Modifier.width(14.dp)) + Text(text, style = MaterialTheme.typography.bodyLarge) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt deleted file mode 100644 index a54512d..0000000 --- a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.scarriffle.calendarr.ui.event - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CalendarMonth -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.LocationOn -import androidx.compose.material.icons.filled.Notes -import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -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.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.scarriffle.calendarr.domain.model.CalEvent -import com.scarriffle.calendarr.ui.LocalLang -import com.scarriffle.calendarr.ui.calendar.eventDateRange -import com.scarriffle.calendarr.ui.tr -import com.scarriffle.calendarr.util.colorFromHex - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) -@Composable -fun EventDetailSheet( - event: CalEvent, - onDismiss: () -> Unit, - onEdit: () -> Unit, - onDelete: () -> Unit, - onCopy: () -> Unit, -) { - val lang = LocalLang.current - var confirmDelete by remember { mutableStateOf(false) } - val canEdit = event.source == "local" || event.source == "caldav" - val canDelete = event.source == "local" || event.source == "caldav" || event.source == "homeassistant" - - ModalBottomSheet(onDismissRequest = onDismiss) { - Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.size(16.dp).clip(CircleShape).background(colorFromHex(event.effectiveColor))) - Spacer(Modifier.size(10.dp)) - Text( - event.title, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.SemiBold, - ) - } - Spacer(Modifier.size(16.dp)) - DetailRow(Icons.Filled.Schedule, eventDateRange(event, lang)) - if (event.calendarName.isNotBlank()) { - DetailRow(Icons.Filled.CalendarMonth, event.calendarName) - } - if (event.location.isNotBlank()) { - DetailRow(Icons.Filled.LocationOn, event.location) - } - if (event.notes.isNotBlank()) { - DetailRow(Icons.Filled.Notes, event.notes) - } - Spacer(Modifier.size(20.dp)) - FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - if (canEdit) { - OutlinedButton(onClick = onEdit) { - Icon(Icons.Filled.Edit, contentDescription = null) - Spacer(Modifier.size(6.dp)) - Text(tr("event.edit_title")) - } - } - OutlinedButton(onClick = onCopy) { - Icon(Icons.Filled.ContentCopy, contentDescription = null) - Spacer(Modifier.size(6.dp)) - Text(tr("event.copy_title")) - } - if (canDelete) { - OutlinedButton(onClick = { confirmDelete = true }) { - Icon(Icons.Filled.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) - Spacer(Modifier.size(6.dp)) - Text(tr("common.delete"), color = MaterialTheme.colorScheme.error) - } - } - } - } - } - - if (confirmDelete) { - AlertDialog( - onDismissRequest = { confirmDelete = false }, - title = { Text(tr("common.delete")) }, - text = { Text(tr("event.delete_confirm")) }, - confirmButton = { - TextButton(onClick = { confirmDelete = false; onDelete() }) { - Text(tr("common.delete"), color = MaterialTheme.colorScheme.error) - } - }, - dismissButton = { - TextButton(onClick = { confirmDelete = false }) { Text(tr("common.cancel")) } - }, - ) - } -} - -@Composable -private fun DetailRow(icon: ImageVector, text: String) { - Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.Top) { - Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp)) - Spacer(Modifier.size(12.dp)) - Text(text, style = MaterialTheme.typography.bodyLarge) - } -} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt index 46f4dc6..e03e8cb 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt @@ -46,9 +46,10 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.scarriffle.calendarr.domain.model.AppSettings import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.ui.LocalAppSettings +import com.scarriffle.calendarr.ui.components.ColorPickerDialog import com.scarriffle.calendarr.ui.tr import com.scarriffle.calendarr.util.colorFromHex -import com.scarriffle.calendarr.util.contrastingTextColor +import com.scarriffle.calendarr.util.toHex private val PALETTE = listOf( "#4285f4", "#ea4335", "#34a853", "#fbbc05", "#46bdc6", "#9c27b0", "#ff7043", "#7090c0", @@ -116,9 +117,11 @@ fun SettingsScreen( Divider(Modifier.padding(vertical = 16.dp)) Section(tr("settings.colors")) - ColorChooser(tr("settings.color.primary"), settings.primaryColor) { update(settings.copy(primaryColor = it)) } - ColorChooser(tr("settings.color.accent"), settings.accentColor) { update(settings.copy(accentColor = it)) } - ColorChooser(tr("settings.color.today"), settings.todayColor) { update(settings.copy(todayColor = it)) } + ColorRow(tr("settings.color.primary"), settings.primaryColor) { update(settings.copy(primaryColor = it)) } + ColorRow(tr("settings.color.accent"), settings.accentColor) { update(settings.copy(accentColor = it)) } + ColorRow(tr("settings.color.today"), settings.todayColor) { update(settings.copy(todayColor = it)) } + ColorRow(tr("settings.color.divider"), settings.monthDividerColor) { update(settings.copy(monthDividerColor = it)) } + ColorRow(tr("settings.color.label"), settings.monthLabelColor) { update(settings.copy(monthLabelColor = it)) } Divider(Modifier.padding(vertical = 16.dp)) Section(tr("settings.hourheight")) @@ -182,35 +185,24 @@ private fun ChipRow(options: List>, selected: String, onSel } } -@OptIn(ExperimentalLayoutApi::class) @Composable -private fun ColorChooser(label: String, current: String, onPick: (String) -> Unit) { - // Show the user's current colour even if it isn't one of the presets. - val swatches = remember(current) { - (if (PALETTE.any { it.equals(current, ignoreCase = true) }) PALETTE else listOf(current) + PALETTE) +private fun ColorRow(label: String, current: String, onPick: (String) -> Unit) { + var showPicker by remember { mutableStateOf(false) } + Row( + Modifier.fillMaxWidth().clickable { showPicker = true }.padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(28.dp).clip(CircleShape).background(colorFromHex(current)).border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)) + Spacer(Modifier.size(12.dp)) + Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Text(colorFromHex(current).toHex(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } - Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.size(18.dp).clip(CircleShape).background(colorFromHex(current))) - Spacer(Modifier.size(8.dp)) - Text(label, style = MaterialTheme.typography.bodyMedium) - } - FlowRow( - Modifier.fillMaxWidth().padding(top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - swatches.forEach { hex -> - val selected = hex.equals(current, ignoreCase = true) - Box( - Modifier.size(34.dp).clip(CircleShape).background(colorFromHex(hex)) - .then(if (selected) Modifier.border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape) else Modifier) - .clickable { onPick(hex) }, - contentAlignment = Alignment.Center, - ) { - if (selected) Icon(Icons.Filled.Check, contentDescription = null, tint = colorFromHex(hex).contrastingTextColor(), modifier = Modifier.size(18.dp)) - } - } - } + if (showPicker) { + ColorPickerDialog( + initial = current, + title = label, + onDismiss = { showPicker = false }, + onConfirm = { showPicker = false; onPick(it) }, + ) } } diff --git a/app/src/main/res/drawable-nodpi/ic_splash_logo.png b/app/src/main/res/drawable-nodpi/ic_splash_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a88fa5b32c2e7115677de60876983f7c8fb4d3cc GIT binary patch literal 22168 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 - + diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml index 70e8a9d..e46e021 100644 --- a/app/src/main/res/values-v31/themes.xml +++ b/app/src/main/res/values-v31/themes.xml @@ -1,10 +1,11 @@ diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 7b56210..4ff5eff 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,10 +1,11 @@ - #0B1220 + #000000 From 3734e17c3ff173a1342ac4a55fda794433aa4edf Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 14:40:23 +0200 Subject: [PATCH 08/59] feat: multi-day event bars, readiness-gated splash, top-bar spinner, 2-line titles, detail animation - Month view now draws multi-day events as continuous bars (lane packing, per-week column span clipped at week boundaries, +N overflow) instead of a chip on every day; events bucketed per week once + memoized packing for smooth scrolling - Startup: core-splashscreen covers the window from frame 0 (no warm-start flash); the in-app splash stays until the first events finish loading (loading starts behind the splash via an early-obtained CalendarViewModel + ready flow), so you no longer enter a laggy app - Background load shows a small spinner in the top bar (removed the linear bar) - Week/Day titles wrap to two smaller lines (no more truncation) - Event detail opens with a slide+fade animation Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 2 +- .../com/scarriffle/calendarr/MainActivity.kt | 4 + .../scarriffle/calendarr/ui/CalendarrRoot.kt | 21 +- .../ui/calendar/CalendarFormatting.kt | 10 +- .../calendarr/ui/calendar/CalendarScreen.kt | 68 ++++-- .../ui/calendar/CalendarViewModel.kt | 6 + .../calendarr/ui/calendar/MonthView.kt | 221 ++++++++++++------ app/src/main/res/values/themes.xml | 7 + 9 files changed, 232 insertions(+), 108 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f4833dc..f15059b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,6 +54,7 @@ android { dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.core:core-splashscreen:1.0.1") implementation("com.google.android.material:material:1.11.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b7303f7..b7fb240 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,7 @@ android:name=".MainActivity" android:exported="true" android:screenOrientation="portrait" - android:theme="@style/Theme.Calendarr"> + android:theme="@style/Theme.Calendarr.Starting"> diff --git a/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt b/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt index db5e8e7..a558078 100644 --- a/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt +++ b/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt @@ -3,12 +3,16 @@ package com.scarriffle.calendarr import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.scarriffle.calendarr.ui.CalendarrRoot import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { + // Covers the window from the first frame (no warm-start flash), then + // hands off to the in-app splash which stays until data is loaded. + installSplashScreen() super.onCreate(savedInstanceState) setContent { CalendarrRoot() diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt index c8e63fc..18e9afe 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt @@ -16,29 +16,37 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.scarriffle.calendarr.ui.auth.LoginScreen import com.scarriffle.calendarr.ui.auth.ServerSetupScreen import com.scarriffle.calendarr.ui.calendar.CalendarScreen +import com.scarriffle.calendarr.ui.calendar.CalendarViewModel import com.scarriffle.calendarr.ui.theme.CalendarrTheme +import kotlinx.coroutines.delay @Composable fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) { val route by vm.route.collectAsState() val settings by vm.settings.collectAsState() - var showSplash by remember { mutableStateOf(true) } - LaunchedEffect(Unit) { - kotlinx.coroutines.delay(1200) - showSplash = false - } - CalendarrTheme(settings) { CompositionLocalProvider( LocalLang provides L10n.resolved(settings.language), LocalAppSettings provides settings, ) { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + // Obtain the calendar VM early when logged in so events load *behind* the splash. + val calendarVm: CalendarViewModel? = + if (route == AppRoute.MAIN) hiltViewModel() else null + val dataReady = calendarVm?.ready?.collectAsState()?.value ?: true + + var minElapsed by remember { mutableStateOf(false) } + var timedOut by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { delay(450); minElapsed = true } + LaunchedEffect(Unit) { delay(6000); timedOut = true } + + val showSplash = !minElapsed || (!dataReady && !timedOut) if (showSplash) { SplashScreen() return@Surface } + when (route) { AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured) AppRoute.LOGIN -> LoginScreen( @@ -47,6 +55,7 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) { onBack = vm::switchServer, ) AppRoute.MAIN -> CalendarScreen( + vm = calendarVm!!, onLogout = vm::logout, onSwitchServer = vm::switchServer, onSettingsChanged = vm::applyLocalSettings, diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt index 71c016c..0429f36 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt @@ -26,10 +26,14 @@ fun titleForView(viewType: CalViewType, date: LocalDate, lang: String): String { val start = date val fmt = DateTimeFormatter.ofPattern("d. MMM", loc) val endFmt = DateTimeFormatter.ofPattern("d. MMM yyyy", loc) - "${fmt.format(start)} – ${endFmt.format(start.plusDays(6))}" + // Two lines for the compact top bar. + "${fmt.format(start)} –\n${endFmt.format(start.plusDays(6))}" + } + CalViewType.DAY -> { + val weekday = DateTimeFormatter.ofPattern("EEEE", loc).format(date) + val rest = DateTimeFormatter.ofPattern("d. MMMM yyyy", loc).format(date) + "$weekday\n$rest" } - CalViewType.DAY -> - DateTimeFormatter.ofPattern("EEEE, d. MMMM yyyy", loc).format(date) CalViewType.AGENDA -> L10n.t("view.agenda", lang) } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index 4dfe8f3..98d86f4 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -1,5 +1,10 @@ package com.scarriffle.calendarr.ui.calendar +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -39,6 +44,7 @@ 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.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -105,6 +111,7 @@ fun CalendarScreen( CompactTopBar( title = barTitle, viewType = state.viewType, + loading = state.isLoading || state.isBackgroundCaching, viewMenuOpen = viewMenuOpen, onMenu = { showMenu = true }, onPrev = { goPrev() }, @@ -126,9 +133,6 @@ fun CalendarScreen( }, ) { padding -> Column(Modifier.fillMaxSize().padding(padding)) { - if (state.isLoading || state.isBackgroundCaching) { - LinearProgressIndicator(Modifier.fillMaxWidth()) - } state.error?.let { err -> ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError) } @@ -172,23 +176,33 @@ fun CalendarScreen( ) } - detailEvent?.let { ev -> - EventDetailScreen( - event = ev, - onClose = { detailEvent = null }, - onEdit = { - detailEvent = null - editor = EditorRequest(ev, localDate(ev.startDate)) - }, - onCopy = { - detailEvent = null - editor = EditorRequest(existing = null, date = localDate(ev.startDate), prefill = ev) - }, - onDelete = { - vm.deleteEvent(ev) {} - detailEvent = null - }, - ) + // Keep the last event during the close animation. + var lastDetail by remember { mutableStateOf(null) } + detailEvent?.let { lastDetail = it } + AnimatedVisibility( + visible = detailEvent != null, + enter = slideInVertically(initialOffsetY = { it / 6 }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it / 6 }) + fadeOut(), + ) { + val ev = lastDetail + if (ev != null) { + EventDetailScreen( + event = ev, + onClose = { detailEvent = null }, + onEdit = { + detailEvent = null + editor = EditorRequest(ev, localDate(ev.startDate)) + }, + onCopy = { + detailEvent = null + editor = EditorRequest(existing = null, date = localDate(ev.startDate), prefill = ev) + }, + onDelete = { + vm.deleteEvent(ev) {} + detailEvent = null + }, + ) + } } editor?.let { req -> @@ -279,6 +293,7 @@ private fun loadingPlaceholder() { private fun CompactTopBar( title: String, viewType: CalViewType, + loading: Boolean, viewMenuOpen: Boolean, onMenu: () -> Unit, onPrev: () -> Unit, @@ -288,6 +303,7 @@ private fun CompactTopBar( onViewMenuToggle: (Boolean) -> Unit, onSelectView: (CalViewType) -> Unit, ) { + val twoLine = viewType == CalViewType.WEEK || viewType == CalViewType.DAY Surface(color = MaterialTheme.colorScheme.background) { Row( Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp), @@ -302,10 +318,18 @@ private fun CompactTopBar( title, modifier = Modifier.weight(1f).padding(horizontal = 4.dp), textAlign = TextAlign.Center, - maxLines = 1, + maxLines = 2, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleMedium, + fontSize = if (twoLine) 13.sp else 16.sp, + lineHeight = if (twoLine) 15.sp else 18.sp, + fontWeight = FontWeight.Medium, ) + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp).padding(end = 2.dp), + strokeWidth = 2.dp, + ) + } CompactIcon(Icons.Filled.FilterList, onFilter, tr("filter.button")) Box { CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change")) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index d48bb08..3d9b50e 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -49,6 +49,10 @@ class CalendarViewModel @Inject constructor( private val _state = MutableStateFlow(initialState()) val state: StateFlow = _state.asStateFlow() + /** True once the first event load has completed — used to gate the splash. */ + private val _ready = MutableStateFlow(false) + val ready: StateFlow = _ready.asStateFlow() + // Cache bookkeeping private var cachedStart: Instant? = null private var cachedEnd: Instant? = null @@ -143,6 +147,7 @@ class CalendarViewModel @Inject constructor( val (start, end) = rangeForCurrentView() if (!force && isCached(start, end)) { refreshFromCache() + _ready.value = true return } viewModelScope.launch { @@ -156,6 +161,7 @@ class CalendarViewModel @Inject constructor( .onFailure { e -> _state.update { it.copy(isLoading = false, error = e.message) } } + _ready.value = true } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt index 3210a80..e55831f 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -52,11 +51,18 @@ import kotlinx.coroutines.flow.map private const val MONTHS_BACK = 18L private const val MONTHS_AHEAD = 18L -private val ROW_HEIGHT = 82.dp +private const val MAX_LANES = 4 +private val DAY_NUM_H = 22.dp +private val LANE_H = 15.dp +private val LANE_SPACE = 2.dp +private val ROW_HEIGHT = DAY_NUM_H + (LANE_H + LANE_SPACE) * MAX_LANES + 6.dp private enum class DividerEdge { NONE, TOP, BOTTOM } -/** Continuous, vertically scrolling month calendar (matches the iOS app). */ +/** One placed event bar within a week: which lane and which columns it spans. */ +private data class PlacedBar(val event: CalEvent, val lane: Int, val startCol: Int, val span: Int) + +/** Continuous, vertically scrolling month calendar with multi-day event bars (iOS-style). */ @Composable fun MonthView( state: CalendarUiState, @@ -79,6 +85,7 @@ fun MonthView( val labelColor = colorFromHex(settings.monthLabelColor, MaterialTheme.colorScheme.onSurfaceVariant) val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = gridLineOpacity(settings.lineContrast)) val secondaryText = MaterialTheme.colorScheme.onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast)) + val todayColor = colorFromHex(settings.todayColor) val firstVisible = remember(mondayFirst) { startOfWeek(today.withDayOfMonth(1).minusMonths(MONTHS_BACK), mondayFirst) @@ -113,7 +120,8 @@ fun MonthView( } } - val eventsByDay = remember(state.events) { buildEventsByDay(state.events) } + // One pass: bucket events into the weeks they overlap (keyed by week-start). + val eventsByWeek = remember(state.events, mondayFirst) { buildEventsByWeek(state.events, mondayFirst) } Column(Modifier.fillMaxSize()) { Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) { @@ -130,16 +138,17 @@ fun MonthView( LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { items(weekCount) { index -> + val weekStart = firstVisible.plusWeeks(index.toLong()) WeekRow( - weekStart = firstVisible.plusWeeks(index.toLong()), + weekStart = weekStart, today = today, - eventsByDay = eventsByDay, + weekEvents = eventsByWeek[weekStart] ?: emptyList(), lang = lang, dividerColor = dividerColor, gridColor = gridColor, labelColor = labelColor, secondaryText = secondaryText, - todayColor = colorFromHex(settings.todayColor), + todayColor = todayColor, onDayClick = onDayClick, onDayLongPress = onDayLongPress, onEventClick = onEventClick, @@ -153,7 +162,7 @@ fun MonthView( private fun WeekRow( weekStart: LocalDate, today: LocalDate, - eventsByDay: Map>, + weekEvents: List, lang: String, dividerColor: Color, gridColor: Color, @@ -169,8 +178,13 @@ private fun WeekRow( val rowStartsNewMonth = days[0].dayOfMonth == 1 val cwLabel = tr("cal.cw") + // Greedy first-fit lane packing for this week (memoized). + val packed = remember(weekStart, weekEvents) { packEvents(weekStart, weekEvents) } + BoxWithConstraints(Modifier.fillMaxWidth().height(ROW_HEIGHT)) { val cellW = maxWidth / 7 + + // Layer 1: day cell backgrounds (borders, day number, KW, overflow count) Row(Modifier.fillMaxSize()) { days.forEachIndexed { idx, day -> val edge = when { @@ -178,13 +192,13 @@ private fun WeekRow( rowStartsNewMonth -> DividerEdge.TOP else -> DividerEdge.NONE } - DayCell( + DayCellBackground( day = day, isToday = day == today, isMonday = day.dayOfWeek == DayOfWeek.MONDAY, weekNumber = day.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR), cwLabel = cwLabel, - events = eventsByDay[day] ?: emptyList(), + overflow = packed.overflowPerCol[idx], lang = lang, edge = edge, dividerColor = dividerColor, @@ -194,11 +208,20 @@ private fun WeekRow( todayColor = todayColor, onClick = { onDayClick(day) }, onLongClick = { onDayLongPress(day) }, - onEventClick = onEventClick, modifier = Modifier.weight(1f).fillMaxHeight(), ) } } + + // Layer 2: event bars (absolute, span multiple columns) + packed.bars.forEach { bar -> + EventBar( + bar = bar, + cellW = cellW, + onClick = { onEventClick(bar.event) }, + ) + } + // Vertical connector at the month boundary column (the "step"). if (boundaryCol != null) { Box( @@ -212,15 +235,43 @@ private fun WeekRow( } } +@Composable +private fun EventBar(bar: PlacedBar, cellW: androidx.compose.ui.unit.Dp, onClick: () -> Unit) { + val color = colorFromHex(bar.event.effectiveColor) + Box( + Modifier + .offset( + x = cellW * bar.startCol + 1.dp, + y = DAY_NUM_H + (LANE_H + LANE_SPACE) * bar.lane, + ) + .width(cellW * bar.span - 2.dp) + .height(LANE_H) + .clip(RoundedCornerShape(3.dp)) + .background(color) + .clickable(onClick = onClick) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text( + bar.event.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + color = color.contrastingTextColor(), + ) + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable -private fun DayCell( +private fun DayCellBackground( day: LocalDate, isToday: Boolean, isMonday: Boolean, weekNumber: Int, cwLabel: String, - events: List, + overflow: Int, lang: String, edge: DividerEdge, dividerColor: Color, @@ -230,7 +281,6 @@ private fun DayCell( todayColor: Color, onClick: () -> Unit, onLongClick: () -> Unit, - onEventClick: (CalEvent) -> Unit, modifier: Modifier = Modifier, ) { val isFirst = day.dayOfMonth == 1 @@ -241,86 +291,65 @@ private fun DayCell( .drawBehind { val gridW = 0.5.dp.toPx() val divW = 1.5.dp.toPx() - // top border (thick divider on a month boundary, otherwise thin grid) if (edge == DividerEdge.TOP) { drawLine(dividerColor, Offset(0f, 0f), Offset(size.width, 0f), divW) } else { drawLine(gridColor, Offset(0f, 0f), Offset(size.width, 0f), gridW) } - // bottom border only when the month ends inside this row if (edge == DividerEdge.BOTTOM) { drawLine(dividerColor, Offset(0f, size.height), Offset(size.width, size.height), divW) } - // right grid line drawLine(gridColor, Offset(size.width, 0f), Offset(size.width, size.height), gridW) } .combinedClickable(onClick = onClick, onLongClick = onLongClick) .padding(horizontal = 1.dp), ) { - Column( - Modifier.fillMaxSize().padding(top = 3.dp), - horizontalAlignment = Alignment.CenterHorizontally, + Row( + Modifier.fillMaxWidth().height(DAY_NUM_H).padding(top = 3.dp), + horizontalArrangement = Arrangement_Center, + verticalAlignment = Alignment.CenterVertically, ) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (isFirst) { - Text( - day.month.getDisplayName(TextStyle.SHORT, com.scarriffle.calendarr.ui.L10n.locale(lang)).uppercase(), - fontSize = 8.sp, - fontWeight = FontWeight.SemiBold, - color = labelColor, - modifier = Modifier.padding(end = 2.dp), - ) - } - Box(contentAlignment = Alignment.Center) { - if (isToday) { - Box(Modifier.height(18.dp).width(18.dp).clip(RoundedCornerShape(9.dp)).background(todayColor)) - } - Text( - "${day.dayOfMonth}", - fontSize = 11.sp, - fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, - color = if (isToday) todayColor.contrastingTextColor() else onBg, - ) - } + if (isFirst) { + Text( + day.month.getDisplayName(TextStyle.SHORT, com.scarriffle.calendarr.ui.L10n.locale(lang)).uppercase(), + fontSize = 8.sp, + fontWeight = FontWeight.SemiBold, + color = labelColor, + modifier = Modifier.padding(end = 2.dp), + ) } - events.take(3).forEach { ev -> EventChip(ev) { onEventClick(ev) } } - if (events.size > 3) { - Text("+${events.size - 3}", fontSize = 8.sp, color = secondaryText) + Box(contentAlignment = Alignment.Center) { + if (isToday) { + Box(Modifier.height(18.dp).width(18.dp).clip(RoundedCornerShape(9.dp)).background(todayColor)) + } + Text( + "${day.dayOfMonth}", + fontSize = 11.sp, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, + color = if (isToday) todayColor.contrastingTextColor() else onBg, + ) } } - // Calendar week number, bottom-right of the Monday cell. + if (overflow > 0) { + Text( + "+$overflow", + fontSize = 8.sp, + color = secondaryText, + modifier = Modifier.align(Alignment.BottomStart).padding(start = 3.dp, bottom = 1.dp), + ) + } if (isMonday) { Text( "$cwLabel $weekNumber", fontSize = 8.sp, color = secondaryText, - modifier = Modifier.align(Alignment.BottomEnd).padding(end = 3.dp, bottom = 2.dp), + modifier = Modifier.align(Alignment.BottomEnd).padding(end = 3.dp, bottom = 1.dp), ) } } } -@Composable -private fun EventChip(event: CalEvent, onClick: () -> Unit) { - val color = colorFromHex(event.effectiveColor) - Surface( - color = color, - shape = RoundedCornerShape(3.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 1.dp, vertical = 0.5.dp) - .clickable(onClick = onClick), - ) { - Text( - event.title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontSize = 8.sp, - color = color.contrastingTextColor(), - modifier = Modifier.padding(horizontal = 3.dp, vertical = 1.dp), - ) - } -} +private val Arrangement_Center = androidx.compose.foundation.layout.Arrangement.Center private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate { val dow = date.dayOfWeek.value @@ -328,20 +357,60 @@ private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate { return date.minusDays(offset.toLong()) } -/** Index events by each local day they overlap, for O(1) lookup while scrolling. */ -private fun buildEventsByDay(events: List): Map> { +/** Result of packing one week: placed bars + per-column overflow count. */ +private class WeekLayout(val bars: List, val overflowPerCol: IntArray) + +/** Columns [startCol, startCol+span-1] this event occupies within the given week. */ +private fun columnRange(weekStart: LocalDate, ev: CalEvent): Pair { + val weekEnd = weekStart.plusDays(7) + val evStartDay = maxOf(localDate(ev.startDate), weekStart) + // last day the event covers (end is exclusive): the day before the end instant + val lastDay = localDate(ev.endDate.minusSeconds(1)) + val evEndDay = minOf(lastDay, weekEnd.minusDays(1)) + val sc = ChronoUnit.DAYS.between(weekStart, evStartDay).toInt().coerceIn(0, 6) + val ec = ChronoUnit.DAYS.between(weekStart, evEndDay).toInt().coerceIn(0, 6) + return sc to (ec - sc + 1).coerceAtLeast(1) +} + +/** Greedy first-fit lane packing (mirrors iOS packEvents). */ +private fun packEvents(weekStart: LocalDate, weekEvents: List): WeekLayout { + val sorted = weekEvents.sortedWith(compareBy { it.startDate }.thenByDescending { it.endDate }) + val laneLastEnd = ArrayList() + val bars = ArrayList() + val overflow = IntArray(7) + for (ev in sorted) { + val (sc, span) = columnRange(weekStart, ev) + val lastCol = (sc + span - 1).coerceAtMost(6) + var assigned = -1 + for (i in laneLastEnd.indices) { + if (laneLastEnd[i] < sc) { laneLastEnd[i] = lastCol; assigned = i; break } + } + if (assigned == -1 && laneLastEnd.size < MAX_LANES) { + laneLastEnd.add(lastCol); assigned = laneLastEnd.size - 1 + } + if (assigned >= 0) { + bars.add(PlacedBar(ev, assigned, sc, span)) + } else { + for (c in sc..lastCol) overflow[c]++ + } + } + return WeekLayout(bars, overflow) +} + +/** Bucket events into every week (week-start key) they overlap. */ +private fun buildEventsByWeek(events: List, mondayFirst: Boolean): Map> { val map = HashMap>() for (ev in events) { - val first = localDate(ev.startDate) - val last = localDate(ev.endDate.minusSeconds(1)).coerceAtLeast(first) - var d = first + val firstWeek = startOfWeek(localDate(ev.startDate), mondayFirst) + val lastDay = localDate(ev.endDate.minusSeconds(1)).let { if (it.isBefore(localDate(ev.startDate))) localDate(ev.startDate) else it } + val lastWeek = startOfWeek(lastDay, mondayFirst) + var w = firstWeek var guard = 0 - while (!d.isAfter(last) && guard < 400) { - map.getOrPut(d) { mutableListOf() }.add(ev) - d = d.plusDays(1) + while (!w.isAfter(lastWeek) && guard < 120) { + map.getOrPut(w) { mutableListOf() }.add(ev) + w = w.plusWeeks(1) guard++ } } - map.values.forEach { list -> list.sortBy { it.startDate } } return map } diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 4ff5eff..45c14a3 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -8,4 +8,11 @@ @android:color/black false + + + From ba7daf8559d2f73ce186af727ac17bb99f862d87 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 14:53:06 +0200 Subject: [PATCH 09/59] perf: unified splash held until loaded, smoother month scrolling, serialized loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Single system splash (core-splashscreen) kept on screen until the first events load (StartupState), then reveals the ready app — removes the two-stage 'icon then title' splash and the laggy half-loaded entry - Month scrolling: dropped per-row BoxWithConstraints (measured grid width once via onSizeChanged) and resolve bar colours once during packing instead of every recomposition → much smoother scroll - Serialize all event loads behind a Mutex and skip redundant state writes so scroll-triggered month loads no longer pile up / thrash the UI Co-Authored-By: Claude Opus 4.8 --- .../com/scarriffle/calendarr/MainActivity.kt | 25 +++++- .../scarriffle/calendarr/data/StartupState.kt | 15 ++++ .../scarriffle/calendarr/ui/CalendarrRoot.kt | 25 +----- .../scarriffle/calendarr/ui/SplashScreen.kt | 45 ----------- .../ui/calendar/CalendarViewModel.kt | 56 +++++++------- .../calendarr/ui/calendar/MonthView.kt | 76 +++++++++++-------- 6 files changed, 112 insertions(+), 130 deletions(-) create mode 100644 app/src/main/java/com/scarriffle/calendarr/data/StartupState.kt delete mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt diff --git a/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt b/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt index a558078..67cdd5b 100644 --- a/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt +++ b/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt @@ -4,16 +4,35 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.lifecycleScope +import com.scarriffle.calendarr.data.CredentialStore +import com.scarriffle.calendarr.data.StartupState import com.scarriffle.calendarr.ui.CalendarrRoot import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { + + @Inject lateinit var startupState: StartupState + @Inject lateinit var credentialStore: CredentialStore + override fun onCreate(savedInstanceState: Bundle?) { - // Covers the window from the first frame (no warm-start flash), then - // hands off to the in-app splash which stays until data is loaded. - installSplashScreen() + // Single, uniform splash: keep the system splash on screen until the + // first events have loaded (or a timeout), so there is no two-stage + // splash and no entering a half-loaded, janky app. + val splash = installSplashScreen() + splash.setKeepOnScreenCondition { !startupState.ready.value } + super.onCreate(savedInstanceState) + + // Nothing to load before login → reveal immediately. + if (!credentialStore.isLoggedIn) startupState.markReady() + // Safety timeout so the splash can never hang. + lifecycleScope.launch { delay(6000); startupState.markReady() } + setContent { CalendarrRoot() } diff --git a/app/src/main/java/com/scarriffle/calendarr/data/StartupState.kt b/app/src/main/java/com/scarriffle/calendarr/data/StartupState.kt new file mode 100644 index 0000000..8d9b4e4 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/StartupState.kt @@ -0,0 +1,15 @@ +package com.scarriffle.calendarr.data + +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Shared startup readiness flag used to keep the system splash screen on screen + * until the first events have loaded (so the app never appears mid-load). + */ +@Singleton +class StartupState @Inject constructor() { + val ready = MutableStateFlow(false) + fun markReady() { ready.value = true } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt index 18e9afe..3ef403e 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt @@ -5,20 +5,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.hilt.navigation.compose.hiltViewModel import com.scarriffle.calendarr.ui.auth.LoginScreen import com.scarriffle.calendarr.ui.auth.ServerSetupScreen import com.scarriffle.calendarr.ui.calendar.CalendarScreen -import com.scarriffle.calendarr.ui.calendar.CalendarViewModel import com.scarriffle.calendarr.ui.theme.CalendarrTheme -import kotlinx.coroutines.delay @Composable fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) { @@ -30,23 +24,9 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) { LocalLang provides L10n.resolved(settings.language), LocalAppSettings provides settings, ) { + // The system splash (installSplashScreen) covers startup until the + // first events are loaded, so there's no in-app splash here. Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - // Obtain the calendar VM early when logged in so events load *behind* the splash. - val calendarVm: CalendarViewModel? = - if (route == AppRoute.MAIN) hiltViewModel() else null - val dataReady = calendarVm?.ready?.collectAsState()?.value ?: true - - var minElapsed by remember { mutableStateOf(false) } - var timedOut by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { delay(450); minElapsed = true } - LaunchedEffect(Unit) { delay(6000); timedOut = true } - - val showSplash = !minElapsed || (!dataReady && !timedOut) - if (showSplash) { - SplashScreen() - return@Surface - } - when (route) { AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured) AppRoute.LOGIN -> LoginScreen( @@ -55,7 +35,6 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) { onBack = vm::switchServer, ) AppRoute.MAIN -> CalendarScreen( - vm = calendarVm!!, onLogout = vm::logout, onSwitchServer = vm::switchServer, onSettingsChanged = vm::applyLocalSettings, diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt deleted file mode 100644 index 9bf141a..0000000 --- a/app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.scarriffle.calendarr.ui - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.scarriffle.calendarr.R - -/** Custom startup screen: app name on top, crisp icon centered, copyright at the bottom. */ -@Composable -fun SplashScreen() { - Box(Modifier.fillMaxSize().background(Color.Black)) { - Text( - "Calendarr", - modifier = Modifier.align(Alignment.TopCenter).padding(top = 96.dp), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.SemiBold, - color = Color.White, - ) - Image( - painter = painterResource(R.drawable.ic_splash_logo), - contentDescription = null, - modifier = Modifier.align(Alignment.Center).size(148.dp).clip(RoundedCornerShape(32.dp)), - ) - Text( - "© Scarriffle", - modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 40.dp), - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.6f), - ) - } -} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index 3d9b50e..9cc8767 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.scarriffle.calendarr.data.CalendarRepository import com.scarriffle.calendarr.data.SettingsStore +import com.scarriffle.calendarr.data.StartupState import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.domain.model.WritableCalendar @@ -13,6 +14,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.time.Instant import java.time.LocalDate import java.time.ZoneId @@ -42,10 +45,14 @@ data class CalendarUiState( class CalendarViewModel @Inject constructor( private val repository: CalendarRepository, private val settingsStore: SettingsStore, + private val startupState: StartupState, ) : ViewModel() { private val zone: ZoneId = ZoneId.systemDefault() + /** Serializes network loads so overlapping fetches don't thrash the UI. */ + private val loadMutex = Mutex() + private val _state = MutableStateFlow(initialState()) val state: StateFlow = _state.asStateFlow() @@ -147,21 +154,12 @@ class CalendarViewModel @Inject constructor( val (start, end) = rangeForCurrentView() if (!force && isCached(start, end)) { refreshFromCache() - _ready.value = true + markReady() return } viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - runCatching { repository.fetchEvents(start, end) } - .onSuccess { fetched -> - mergeIntoCache(fetched, start, end) - refreshFromCache() - _state.update { it.copy(isLoading = false) } - } - .onFailure { e -> - _state.update { it.copy(isLoading = false, error = e.message) } - } - _ready.value = true + loadRange(start, end, background = false) + markReady() } } @@ -171,30 +169,35 @@ class CalendarViewModel @Inject constructor( val start = instant(first.minusMonths(1)) val end = instant(first.plusMonths(2)) if (isCached(start, end)) return - viewModelScope.launch { - _state.update { it.copy(isLoading = true) } + viewModelScope.launch { loadRange(start, end, background = false) } + } + + /** Single serialized loader: avoids overlapping fetches that cause scroll jank. */ + private suspend fun loadRange(start: Instant, end: Instant, background: Boolean) { + loadMutex.withLock { + // Another load (e.g. the background prefetch) may have covered this range. + if (isCached(start, end)) return + val flag = if (background) "bg" else "fg" + _state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) } runCatching { repository.fetchEvents(start, end) } .onSuccess { mergeIntoCache(it, start, end); refreshFromCache() } - .onFailure { e -> _state.update { it.copy(error = e.message) } } - _state.update { it.copy(isLoading = false) } + .onFailure { e -> if (!background) _state.update { it.copy(error = e.message) } } + _state.update { it.copy(isLoading = false, isBackgroundCaching = false) } } } + private fun markReady() { + _ready.value = true + startupState.markReady() + } + private fun prefetchBackground() { val months = settingsStore.cacheMonths val today = LocalDate.now().withDayOfMonth(1) val start = instant(today.minusMonths(months.toLong())) val end = instant(today.plusMonths((months + 1).toLong())) if (isCached(start, end)) return - viewModelScope.launch { - _state.update { it.copy(isBackgroundCaching = true) } - runCatching { repository.fetchEvents(start, end) } - .onSuccess { fetched -> - mergeIntoCache(fetched, start, end) - refreshFromCache() - } - _state.update { it.copy(isBackgroundCaching = false) } - } + viewModelScope.launch { loadRange(start, end, background = true) } } private fun isCached(start: Instant, end: Instant): Boolean { @@ -219,7 +222,8 @@ class CalendarViewModel @Inject constructor( val key = calendarKey(ev.source, ev.calendarId) key !in hidden && key !in banished } - _state.update { it.copy(events = visible) } + // Skip the state write (and resulting recomposition) when nothing changed. + _state.update { if (it.events == visible) it else it.copy(events = visible) } } private fun invalidateCache() { diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt index e55831f..c4ecb00 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -22,7 +21,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -30,9 +32,13 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.scarriffle.calendarr.domain.model.CalEvent @@ -59,8 +65,17 @@ private val ROW_HEIGHT = DAY_NUM_H + (LANE_H + LANE_SPACE) * MAX_LANES + 6.dp private enum class DividerEdge { NONE, TOP, BOTTOM } -/** One placed event bar within a week: which lane and which columns it spans. */ -private data class PlacedBar(val event: CalEvent, val lane: Int, val startCol: Int, val span: Int) +/** A placed event bar within a week (with colours resolved once, not per frame). */ +private data class PlacedBar( + val event: CalEvent, + val lane: Int, + val startCol: Int, + val span: Int, + val color: Color, + val textColor: Color, +) + +private class WeekLayout(val bars: List, val overflowPerCol: IntArray) /** Continuous, vertically scrolling month calendar with multi-day event bars (iOS-style). */ @Composable @@ -87,6 +102,12 @@ fun MonthView( val secondaryText = MaterialTheme.colorScheme.onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast)) val todayColor = colorFromHex(settings.todayColor) + // Column width measured once → no per-row BoxWithConstraints (smooth scrolling). + val density = LocalDensity.current + val fallbackCellW = LocalConfiguration.current.screenWidthDp.dp / 7 + var gridWidthPx by remember { mutableIntStateOf(0) } + val cellW: Dp = if (gridWidthPx > 0) with(density) { (gridWidthPx / 7f).toDp() } else fallbackCellW + val firstVisible = remember(mondayFirst) { startOfWeek(today.withDayOfMonth(1).minusMonths(MONTHS_BACK), mondayFirst) } @@ -120,7 +141,6 @@ fun MonthView( } } - // One pass: bucket events into the weeks they overlap (keyed by week-start). val eventsByWeek = remember(state.events, mondayFirst) { buildEventsByWeek(state.events, mondayFirst) } Column(Modifier.fillMaxSize()) { @@ -136,13 +156,17 @@ fun MonthView( } } - LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width }, + ) { items(weekCount) { index -> val weekStart = firstVisible.plusWeeks(index.toLong()) WeekRow( weekStart = weekStart, today = today, weekEvents = eventsByWeek[weekStart] ?: emptyList(), + cellW = cellW, lang = lang, dividerColor = dividerColor, gridColor = gridColor, @@ -163,6 +187,7 @@ private fun WeekRow( weekStart: LocalDate, today: LocalDate, weekEvents: List, + cellW: Dp, lang: String, dividerColor: Color, gridColor: Color, @@ -173,18 +198,14 @@ private fun WeekRow( onDayLongPress: (LocalDate) -> Unit, onEventClick: (CalEvent) -> Unit, ) { - val days = (0 until 7).map { weekStart.plusDays(it.toLong()) } + val days = remember(weekStart) { (0 until 7).map { weekStart.plusDays(it.toLong()) } } val boundaryCol = (1 until 7).firstOrNull { days[it].dayOfMonth == 1 } val rowStartsNewMonth = days[0].dayOfMonth == 1 val cwLabel = tr("cal.cw") - // Greedy first-fit lane packing for this week (memoized). val packed = remember(weekStart, weekEvents) { packEvents(weekStart, weekEvents) } - BoxWithConstraints(Modifier.fillMaxWidth().height(ROW_HEIGHT)) { - val cellW = maxWidth / 7 - - // Layer 1: day cell backgrounds (borders, day number, KW, overflow count) + Box(Modifier.fillMaxWidth().height(ROW_HEIGHT)) { Row(Modifier.fillMaxSize()) { days.forEachIndexed { idx, day -> val edge = when { @@ -213,16 +234,10 @@ private fun WeekRow( } } - // Layer 2: event bars (absolute, span multiple columns) packed.bars.forEach { bar -> - EventBar( - bar = bar, - cellW = cellW, - onClick = { onEventClick(bar.event) }, - ) + EventBar(bar = bar, cellW = cellW, onClick = { onEventClick(bar.event) }) } - // Vertical connector at the month boundary column (the "step"). if (boundaryCol != null) { Box( Modifier @@ -236,8 +251,7 @@ private fun WeekRow( } @Composable -private fun EventBar(bar: PlacedBar, cellW: androidx.compose.ui.unit.Dp, onClick: () -> Unit) { - val color = colorFromHex(bar.event.effectiveColor) +private fun EventBar(bar: PlacedBar, cellW: Dp, onClick: () -> Unit) { Box( Modifier .offset( @@ -247,7 +261,7 @@ private fun EventBar(bar: PlacedBar, cellW: androidx.compose.ui.unit.Dp, onClick .width(cellW * bar.span - 2.dp) .height(LANE_H) .clip(RoundedCornerShape(3.dp)) - .background(color) + .background(bar.color) .clickable(onClick = onClick) .padding(horizontal = 4.dp), contentAlignment = Alignment.CenterStart, @@ -258,7 +272,7 @@ private fun EventBar(bar: PlacedBar, cellW: androidx.compose.ui.unit.Dp, onClick overflow = TextOverflow.Ellipsis, fontSize = 10.sp, fontWeight = FontWeight.Medium, - color = color.contrastingTextColor(), + color = bar.textColor, ) } } @@ -306,7 +320,7 @@ private fun DayCellBackground( ) { Row( Modifier.fillMaxWidth().height(DAY_NUM_H).padding(top = 3.dp), - horizontalArrangement = Arrangement_Center, + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { if (isFirst) { @@ -349,22 +363,16 @@ private fun DayCellBackground( } } -private val Arrangement_Center = androidx.compose.foundation.layout.Arrangement.Center - private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate { val dow = date.dayOfWeek.value val offset = if (mondayFirst) dow - 1 else dow % 7 return date.minusDays(offset.toLong()) } -/** Result of packing one week: placed bars + per-column overflow count. */ -private class WeekLayout(val bars: List, val overflowPerCol: IntArray) - /** Columns [startCol, startCol+span-1] this event occupies within the given week. */ private fun columnRange(weekStart: LocalDate, ev: CalEvent): Pair { val weekEnd = weekStart.plusDays(7) val evStartDay = maxOf(localDate(ev.startDate), weekStart) - // last day the event covers (end is exclusive): the day before the end instant val lastDay = localDate(ev.endDate.minusSeconds(1)) val evEndDay = minOf(lastDay, weekEnd.minusDays(1)) val sc = ChronoUnit.DAYS.between(weekStart, evStartDay).toInt().coerceIn(0, 6) @@ -372,7 +380,7 @@ private fun columnRange(weekStart: LocalDate, ev: CalEvent): Pair { return sc to (ec - sc + 1).coerceAtLeast(1) } -/** Greedy first-fit lane packing (mirrors iOS packEvents). */ +/** Greedy first-fit lane packing (mirrors iOS packEvents); colours resolved here. */ private fun packEvents(weekStart: LocalDate, weekEvents: List): WeekLayout { val sorted = weekEvents.sortedWith(compareBy { it.startDate }.thenByDescending { it.endDate }) val laneLastEnd = ArrayList() @@ -389,7 +397,8 @@ private fun packEvents(weekStart: LocalDate, weekEvents: List): WeekLa laneLastEnd.add(lastCol); assigned = laneLastEnd.size - 1 } if (assigned >= 0) { - bars.add(PlacedBar(ev, assigned, sc, span)) + val color = colorFromHex(ev.effectiveColor) + bars.add(PlacedBar(ev, assigned, sc, span, color, color.contrastingTextColor())) } else { for (c in sc..lastCol) overflow[c]++ } @@ -401,8 +410,9 @@ private fun packEvents(weekStart: LocalDate, weekEvents: List): WeekLa private fun buildEventsByWeek(events: List, mondayFirst: Boolean): Map> { val map = HashMap>() for (ev in events) { - val firstWeek = startOfWeek(localDate(ev.startDate), mondayFirst) - val lastDay = localDate(ev.endDate.minusSeconds(1)).let { if (it.isBefore(localDate(ev.startDate))) localDate(ev.startDate) else it } + val startD = localDate(ev.startDate) + val firstWeek = startOfWeek(startD, mondayFirst) + val lastDay = localDate(ev.endDate.minusSeconds(1)).let { if (it.isBefore(startD)) startD else it } val lastWeek = startOfWeek(lastDay, mondayFirst) var w = firstWeek var guard = 0 From cb51284befd303ddd3a2a627722ea1a51adb22ab Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 15:07:37 +0200 Subject: [PATCH 10/59] fix: branded splash back, wider month range, December title bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore the branded in-app splash (Calendarr name + icon + © Scarriffle), held until the first events load; logo sized to match the system splash so it doesn't visibly jump, name/copyright fade in - Month calendar now scrolls 10 years back / 50 years ahead (was ±18 months) - Fix month/quarter title: use Month.getDisplayName instead of the 'LLLL' pattern, which dropped the month name (e.g. December showed only '2026') Co-Authored-By: Claude Opus 4.8 --- .../com/scarriffle/calendarr/MainActivity.kt | 25 +------- .../scarriffle/calendarr/data/StartupState.kt | 15 ----- .../scarriffle/calendarr/ui/CalendarrRoot.kt | 25 +++++++- .../scarriffle/calendarr/ui/SplashScreen.kt | 57 +++++++++++++++++++ .../ui/calendar/CalendarFormatting.kt | 14 +++-- .../ui/calendar/CalendarViewModel.kt | 3 - .../calendarr/ui/calendar/MonthView.kt | 4 +- 7 files changed, 95 insertions(+), 48 deletions(-) delete mode 100644 app/src/main/java/com/scarriffle/calendarr/data/StartupState.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt diff --git a/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt b/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt index 67cdd5b..8af7e9e 100644 --- a/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt +++ b/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt @@ -4,35 +4,16 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.lifecycle.lifecycleScope -import com.scarriffle.calendarr.data.CredentialStore -import com.scarriffle.calendarr.data.StartupState import com.scarriffle.calendarr.ui.CalendarrRoot import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { - - @Inject lateinit var startupState: StartupState - @Inject lateinit var credentialStore: CredentialStore - override fun onCreate(savedInstanceState: Bundle?) { - // Single, uniform splash: keep the system splash on screen until the - // first events have loaded (or a timeout), so there is no two-stage - // splash and no entering a half-loaded, janky app. - val splash = installSplashScreen() - splash.setKeepOnScreenCondition { !startupState.ready.value } - + // Covers the window from the first frame (incl. warm-start), then hands + // off to the in-app branded splash which stays until data is loaded. + installSplashScreen() super.onCreate(savedInstanceState) - - // Nothing to load before login → reveal immediately. - if (!credentialStore.isLoggedIn) startupState.markReady() - // Safety timeout so the splash can never hang. - lifecycleScope.launch { delay(6000); startupState.markReady() } - setContent { CalendarrRoot() } diff --git a/app/src/main/java/com/scarriffle/calendarr/data/StartupState.kt b/app/src/main/java/com/scarriffle/calendarr/data/StartupState.kt deleted file mode 100644 index 8d9b4e4..0000000 --- a/app/src/main/java/com/scarriffle/calendarr/data/StartupState.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.scarriffle.calendarr.data - -import kotlinx.coroutines.flow.MutableStateFlow -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Shared startup readiness flag used to keep the system splash screen on screen - * until the first events have loaded (so the app never appears mid-load). - */ -@Singleton -class StartupState @Inject constructor() { - val ready = MutableStateFlow(false) - fun markReady() { ready.value = true } -} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt index 3ef403e..9a53551 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt @@ -5,14 +5,20 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.hilt.navigation.compose.hiltViewModel import com.scarriffle.calendarr.ui.auth.LoginScreen import com.scarriffle.calendarr.ui.auth.ServerSetupScreen import com.scarriffle.calendarr.ui.calendar.CalendarScreen +import com.scarriffle.calendarr.ui.calendar.CalendarViewModel import com.scarriffle.calendarr.ui.theme.CalendarrTheme +import kotlinx.coroutines.delay @Composable fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) { @@ -24,9 +30,23 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) { LocalLang provides L10n.resolved(settings.language), LocalAppSettings provides settings, ) { - // The system splash (installSplashScreen) covers startup until the - // first events are loaded, so there's no in-app splash here. Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + // Obtain the calendar VM early when logged in so events load + // *behind* the splash; the branded splash stays until ready. + val calendarVm: CalendarViewModel? = + if (route == AppRoute.MAIN) hiltViewModel() else null + val dataReady = calendarVm?.ready?.collectAsState()?.value ?: true + + var minElapsed by remember { mutableStateOf(false) } + var timedOut by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { delay(500); minElapsed = true } + LaunchedEffect(Unit) { delay(6000); timedOut = true } + + if (!minElapsed || (!dataReady && !timedOut)) { + SplashScreen() + return@Surface + } + when (route) { AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured) AppRoute.LOGIN -> LoginScreen( @@ -35,6 +55,7 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) { onBack = vm::switchServer, ) AppRoute.MAIN -> CalendarScreen( + vm = calendarVm!!, onLogout = vm::logout, onSwitchServer = vm::switchServer, onSettingsChanged = vm::applyLocalSettings, diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt new file mode 100644 index 0000000..d1615c1 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt @@ -0,0 +1,57 @@ +package com.scarriffle.calendarr.ui + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.R + +/** + * Branded startup screen shown right after the system splash hands off (same + * black background + same centred logo, so the logo doesn't jump); the app name + * and copyright fade in. Stays until the first events have loaded. + */ +@Composable +fun SplashScreen() { + // Fade the texts in so the handoff from the (text-less) system splash is smooth. + val textAlpha by animateFloatAsState(targetValue = 1f, animationSpec = tween(350), label = "splashText") + + Box(Modifier.fillMaxSize().background(Color.Black)) { + Text( + "Calendarr", + modifier = Modifier.align(Alignment.TopCenter).padding(top = 110.dp).alpha(textAlpha), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + color = Color.White, + ) + // Match the system splash icon size (~150dp) so it stays put across the handoff. + Image( + painter = painterResource(R.drawable.ic_splash_logo), + contentDescription = null, + modifier = Modifier.align(Alignment.Center).size(150.dp).clip(RoundedCornerShape(34.dp)), + ) + Text( + "© Scarriffle", + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 40.dp).alpha(textAlpha), + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.6f), + ) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt index 0429f36..bbdd129 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt @@ -15,12 +15,18 @@ private val zone: ZoneId = ZoneId.systemDefault() fun titleForView(viewType: CalViewType, date: LocalDate, lang: String): String { val loc = L10n.locale(lang) return when (viewType) { - CalViewType.MONTH -> - DateTimeFormatter.ofPattern("LLLL yyyy", loc).format(date) + CalViewType.MONTH -> { + // Use Month.getDisplayName (robust) instead of the "LLLL" pattern, + // which could drop the month name under desugared java.time. + val month = date.month.getDisplayName(TextStyle.FULL_STANDALONE, loc) .replaceFirstChar { it.uppercase(loc) } + "$month ${date.year}" + } CalViewType.QUARTER -> { - val fmt = DateTimeFormatter.ofPattern("LLL yyyy", loc) - "${fmt.format(date)} – ${fmt.format(date.plusMonths(2))}" + val endM = date.plusMonths(2) + val m1 = date.month.getDisplayName(TextStyle.SHORT_STANDALONE, loc).replaceFirstChar { it.uppercase(loc) } + val m2 = endM.month.getDisplayName(TextStyle.SHORT_STANDALONE, loc).replaceFirstChar { it.uppercase(loc) } + "$m1 ${date.year} – $m2 ${endM.year}" } CalViewType.WEEK -> { val start = date diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index 9cc8767..7c62517 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.scarriffle.calendarr.data.CalendarRepository import com.scarriffle.calendarr.data.SettingsStore -import com.scarriffle.calendarr.data.StartupState import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.domain.model.WritableCalendar @@ -45,7 +44,6 @@ data class CalendarUiState( class CalendarViewModel @Inject constructor( private val repository: CalendarRepository, private val settingsStore: SettingsStore, - private val startupState: StartupState, ) : ViewModel() { private val zone: ZoneId = ZoneId.systemDefault() @@ -188,7 +186,6 @@ class CalendarViewModel @Inject constructor( private fun markReady() { _ready.value = true - startupState.markReady() } private fun prefetchBackground() { diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt index c4ecb00..bb6642c 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt @@ -55,8 +55,8 @@ import java.time.temporal.IsoFields import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -private const val MONTHS_BACK = 18L -private const val MONTHS_AHEAD = 18L +private const val MONTHS_BACK = 120L // 10 years back +private const val MONTHS_AHEAD = 600L // 50 years ahead private const val MAX_LANES = 4 private val DAY_NUM_H = 22.dp private val LANE_H = 15.dp From bc93964e05f9c261bff491534dc8d441d8e920df Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 15:17:01 +0200 Subject: [PATCH 11/59] fix: single branded splash (invisible system icon), December title, first-open lag - System splash now uses a transparent icon, so there is no system icon that shrinks into the in-app splash: just black, then the branded screen (icon + Calendarr name + copyright), held until data is loaded - Month/quarter title uses TextStyle.FULL (format style) with a hard fallback; FULL_STANDALONE had the same desugar bug as the LLLL pattern (December showed only the year) - First open: load the full +/- cacheMonths window behind the splash and only then reveal, so the app no longer enters mid-load and stutters Co-Authored-By: Claude Opus 4.8 --- .../ui/calendar/CalendarFormatting.kt | 23 +++++++++---- .../ui/calendar/CalendarViewModel.kt | 34 +++++++++++-------- .../main/res/drawable/splash_transparent.xml | 7 ++++ app/src/main/res/values-v31/themes.xml | 3 -- app/src/main/res/values/themes.xml | 5 +-- 5 files changed, 45 insertions(+), 27 deletions(-) create mode 100644 app/src/main/res/drawable/splash_transparent.xml diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt index bbdd129..81f17d9 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt @@ -12,21 +12,30 @@ import java.time.format.TextStyle private val zone: ZoneId = ZoneId.systemDefault() +/** Localized month name with a hard fallback (guards against empty/numeric results). */ +private fun monthName(date: LocalDate, style: TextStyle, loc: java.util.Locale): String { + val name = date.month.getDisplayName(style, loc) + val safe = if (name.isBlank() || name.all { it.isDigit() }) { + date.month.getDisplayName(TextStyle.FULL, java.util.Locale.ENGLISH) + } else { + name + } + return safe.replaceFirstChar { it.uppercase(loc) } +} + fun titleForView(viewType: CalViewType, date: LocalDate, lang: String): String { val loc = L10n.locale(lang) return when (viewType) { CalViewType.MONTH -> { - // Use Month.getDisplayName (robust) instead of the "LLLL" pattern, - // which could drop the month name under desugared java.time. - val month = date.month.getDisplayName(TextStyle.FULL_STANDALONE, loc) - .replaceFirstChar { it.uppercase(loc) } + // Use the FORMAT month style (TextStyle.FULL). The standalone style + // (FULL_STANDALONE / the "LLLL" pattern) drops the name for some + // months under desugared java.time (e.g. December showed only "2026"). + val month = monthName(date, TextStyle.FULL, loc) "$month ${date.year}" } CalViewType.QUARTER -> { val endM = date.plusMonths(2) - val m1 = date.month.getDisplayName(TextStyle.SHORT_STANDALONE, loc).replaceFirstChar { it.uppercase(loc) } - val m2 = endM.month.getDisplayName(TextStyle.SHORT_STANDALONE, loc).replaceFirstChar { it.uppercase(loc) } - "$m1 ${date.year} – $m2 ${endM.year}" + "${monthName(date, TextStyle.SHORT, loc)} ${date.year} – ${monthName(endM, TextStyle.SHORT, loc)} ${endM.year}" } CalViewType.WEEK -> { val start = date diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index 7c62517..b669a69 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -64,9 +64,24 @@ class CalendarViewModel @Inject constructor( private var allCachedEvents: List = emptyList() init { - loadVisible() loadWritableCalendars() - prefetchBackground() + initialLoad() + } + + /** + * Load the full ±cacheMonths window once, behind the splash, and only then + * mark ready. Entering the app fully-loaded avoids the first-open jank of + * loading a big batch while the user is already scrolling. + */ + private fun initialLoad() { + val months = settingsStore.cacheMonths.toLong() + val today = LocalDate.now().withDayOfMonth(1) + val start = instant(today.minusMonths(months)) + val end = instant(today.plusMonths(months + 1)) + viewModelScope.launch { + loadRange(start, end, background = false) + markReady() + } } private fun initialState(): CalendarUiState { @@ -188,15 +203,6 @@ class CalendarViewModel @Inject constructor( _ready.value = true } - private fun prefetchBackground() { - val months = settingsStore.cacheMonths - val today = LocalDate.now().withDayOfMonth(1) - val start = instant(today.minusMonths(months.toLong())) - val end = instant(today.plusMonths((months + 1).toLong())) - if (isCached(start, end)) return - viewModelScope.launch { loadRange(start, end, background = true) } - } - private fun isCached(start: Instant, end: Instant): Boolean { val cs = cachedStart ?: return false val ce = cachedEnd ?: return false @@ -231,8 +237,7 @@ class CalendarViewModel @Inject constructor( fun syncWithServer() { invalidateCache() - loadVisible(force = true) - prefetchBackground() + initialLoad() } fun clearError() = _state.update { it.copy(error = null) } @@ -280,8 +285,7 @@ class CalendarViewModel @Inject constructor( private fun afterMutation() { invalidateCache() - loadVisible(force = true) - prefetchBackground() + initialLoad() } fun saveEvent( diff --git a/app/src/main/res/drawable/splash_transparent.xml b/app/src/main/res/drawable/splash_transparent.xml new file mode 100644 index 0000000..73ad064 --- /dev/null +++ b/app/src/main/res/drawable/splash_transparent.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml index e46e021..a5de45d 100644 --- a/app/src/main/res/values-v31/themes.xml +++ b/app/src/main/res/values-v31/themes.xml @@ -4,8 +4,5 @@ @android:color/black @android:color/black false - - @android:color/black - @drawable/splash_icon diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 45c14a3..938a0b2 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -9,10 +9,11 @@ false - + From 9467fe7bb6adfb7d513f8df56fa88ae1df91a7b3 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 15:26:05 +0200 Subject: [PATCH 12/59] feat: dim past events when enabled (month bars, week/day grid, agenda) Past events (endDate before now) are drawn at reduced opacity across the month bars, the week/day time grid (timed + all-day) and the agenda list when the 'dim past events' setting is on. Co-Authored-By: Claude Opus 4.8 --- .../scarriffle/calendarr/ui/calendar/AgendaView.kt | 8 ++++++-- .../scarriffle/calendarr/ui/calendar/MonthView.kt | 13 +++++++++++-- .../calendarr/ui/calendar/TimeGridView.kt | 11 +++++++++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt index c2c2562..6c3cfe4 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/AgendaView.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -38,6 +39,8 @@ fun AgendaView( onEventClick: (CalEvent) -> Unit, ) { val lang = LocalLang.current + val dimPast = com.scarriffle.calendarr.ui.LocalAppSettings.current.dimPastEvents + val now = java.time.Instant.now() val today = LocalDate.now() val days = (0 until 90).map { today.plusDays(it.toLong()) } .map { it to vm.eventsOn(it, state.events) } @@ -72,7 +75,7 @@ fun AgendaView( ) } items(events, key = { "${date}-${it.id}" }) { ev -> - AgendaRow(ev, lang, onClick = { onEventClick(ev) }) + AgendaRow(ev, lang, dimmed = dimPast && ev.endDate.isBefore(now), onClick = { onEventClick(ev) }) } } item { Spacer(Modifier.height(80.dp)) } @@ -80,11 +83,12 @@ fun AgendaView( } @Composable -private fun AgendaRow(event: CalEvent, lang: String, onClick: () -> Unit) { +private fun AgendaRow(event: CalEvent, lang: String, dimmed: Boolean, onClick: () -> Unit) { Row( Modifier .fillMaxWidth() .padding(vertical = 4.dp) + .alpha(if (dimmed) 0.45f else 1f) .clip(RoundedCornerShape(10.dp)) .background(MaterialTheme.colorScheme.surface) .clickable(onClick = onClick) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt index bb6642c..c25055c 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset @@ -142,6 +143,8 @@ fun MonthView( } val eventsByWeek = remember(state.events, mondayFirst) { buildEventsByWeek(state.events, mondayFirst) } + val dimPast = settings.dimPastEvents + val now = java.time.Instant.now() Column(Modifier.fillMaxSize()) { Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) { @@ -167,6 +170,8 @@ fun MonthView( today = today, weekEvents = eventsByWeek[weekStart] ?: emptyList(), cellW = cellW, + dimPast = dimPast, + now = now, lang = lang, dividerColor = dividerColor, gridColor = gridColor, @@ -188,6 +193,8 @@ private fun WeekRow( today: LocalDate, weekEvents: List, cellW: Dp, + dimPast: Boolean, + now: java.time.Instant, lang: String, dividerColor: Color, gridColor: Color, @@ -235,7 +242,8 @@ private fun WeekRow( } packed.bars.forEach { bar -> - EventBar(bar = bar, cellW = cellW, onClick = { onEventClick(bar.event) }) + val past = dimPast && bar.event.endDate.isBefore(now) + EventBar(bar = bar, cellW = cellW, dimmed = past, onClick = { onEventClick(bar.event) }) } if (boundaryCol != null) { @@ -251,7 +259,7 @@ private fun WeekRow( } @Composable -private fun EventBar(bar: PlacedBar, cellW: Dp, onClick: () -> Unit) { +private fun EventBar(bar: PlacedBar, cellW: Dp, dimmed: Boolean, onClick: () -> Unit) { Box( Modifier .offset( @@ -260,6 +268,7 @@ private fun EventBar(bar: PlacedBar, cellW: Dp, onClick: () -> Unit) { ) .width(cellW * bar.span - 2.dp) .height(LANE_H) + .alpha(if (dimmed) 0.45f else 1f) .clip(RoundedCornerShape(3.dp)) .background(bar.color) .clickable(onClick = onClick) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt index 3ea85bb..027e33c 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/TimeGridView.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.layout import androidx.compose.ui.text.font.FontWeight @@ -45,6 +46,8 @@ fun TimeGridView( onEventClick: (CalEvent) -> Unit, ) { val hourHeight = LocalAppSettings.current.hourHeight.coerceIn(28, 100).dp + val dimPast = LocalAppSettings.current.dimPastEvents + val now = java.time.Instant.now() val lang = LocalLang.current val today = LocalDate.now() @@ -76,7 +79,7 @@ fun TimeGridView( } allDay.forEach { (_, evs) -> Column(Modifier.weight(1f).padding(horizontal = 1.dp)) { - evs.forEach { ev -> AllDayChip(ev, onClick = { onEventClick(ev) }) } + evs.forEach { ev -> AllDayChip(ev, dimmed = dimPast && ev.endDate.isBefore(now), onClick = { onEventClick(ev) }) } } } } @@ -117,6 +120,7 @@ fun TimeGridView( index = index, dayCount = days.size, hourHeight = hourHeight, + dimmed = dimPast && ev.endDate.isBefore(now), onClick = { onEventClick(ev) }, ) } @@ -133,6 +137,7 @@ private fun TimedEvent( index: Int, dayCount: Int, hourHeight: androidx.compose.ui.unit.Dp, + dimmed: Boolean, onClick: () -> Unit, ) { val startMin = if (localDate(event.startDate).isBefore(day)) 0 else minutesOfDay(event.startDate) @@ -149,6 +154,7 @@ private fun TimedEvent( .columnSlot(index, dayCount) .height(height) .padding(horizontal = 1.dp, vertical = 0.5.dp) + .alpha(if (dimmed) 0.45f else 1f) .clip(RoundedCornerShape(4.dp)) .background(color) .clickable(onClick = onClick) @@ -188,12 +194,13 @@ private fun Modifier.columnSlot(index: Int, dayCount: Int): Modifier = layout { } @Composable -private fun AllDayChip(event: CalEvent, onClick: () -> Unit) { +private fun AllDayChip(event: CalEvent, dimmed: Boolean, onClick: () -> Unit) { val color = colorFromHex(event.effectiveColor) Box( Modifier .fillMaxWidth() .padding(vertical = 1.dp) + .alpha(if (dimmed) 0.45f else 1f) .clip(RoundedCornerShape(3.dp)) .background(color) .clickable(onClick = onClick) From d10a4dc79ff581b1c308937c4d852719d360f467 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 21:35:54 +0200 Subject: [PATCH 13/59] =?UTF-8?q?feat:=20Android=20Datenebene=20f=C3=BCr?= =?UTF-8?q?=20Sharing/Gruppen/Import-Export/Ersteller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modelle: CalEvent (creator/isPrivate/owner/isGroupEvent/displayColor), LocalCalendar (owned/sharedBy/permission/group), AppSettings (privateEventVisibility/groupVisibleCalendarId), UserProfile (displayName); neue Modelle Group/GroupMember/DirectoryUser/CalendarShareEntry. - API (Retrofit): Profil-Update, Sharing-CRUD, Gruppen-CRUD + combined, Mitglieder-Farbe, iCal Import (multipart)/Export, Kalenderfarbe pro Quelle, gezielte Settings-PUTs (private_visibility/group_visible). - Repository: passende Methoden inkl. private-Flag bei lokalen Events. Co-Authored-By: Claude Opus 4.8 --- .../calendarr/data/CalendarRepository.kt | 168 +++++++++++++++++- .../calendarr/data/remote/CalendarrApi.kt | 62 +++++++ .../calendarr/domain/model/Accounts.kt | 6 + .../calendarr/domain/model/AppSettings.kt | 3 + .../calendarr/domain/model/CalEvent.kt | 22 +++ .../calendarr/domain/model/Groups.kt | 27 +++ 6 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/scarriffle/calendarr/domain/model/Groups.kt 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 3cb56e6..59e76cf 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -10,6 +10,10 @@ import com.scarriffle.calendarr.data.remote.jsonBody import com.scarriffle.calendarr.domain.model.AppSettings import com.scarriffle.calendarr.domain.model.CalDAVAccount import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.domain.model.CalendarShareEntry +import com.scarriffle.calendarr.domain.model.DirectoryUser +import com.scarriffle.calendarr.domain.model.Group +import com.scarriffle.calendarr.domain.model.GroupMember import com.scarriffle.calendarr.domain.model.GoogleAccount import com.scarriffle.calendarr.domain.model.HomeAssistantAccount import com.scarriffle.calendarr.domain.model.ICalSubscription @@ -115,6 +119,7 @@ class CalendarRepository @Inject constructor( "language" to s.language, "month_divider_color" to s.monthDividerColor, "month_label_color" to s.monthLabelColor, + "private_event_visibility" to s.privateEventVisibility, ) ).ensureSuccess() } @@ -265,19 +270,176 @@ class CalendarRepository @Inject constructor( suspend fun createLocalEvent( calendarId: Int, title: String, start: Instant, end: Instant, isAllDay: Boolean, location: String, description: String, color: String?, + isPrivate: Boolean = false, ) = guarded { - api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color)) + api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate)) .ensureSuccess() } suspend fun updateLocalEvent( uid: String, title: String, start: Instant, end: Instant, isAllDay: Boolean, location: String, description: String, color: String?, + isPrivate: Boolean = false, ) = guarded { - api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color)) + api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate)) .ensureSuccess() } + // ---- Sharing ---- + + suspend fun getUserDirectory(): List = guarded { + val resp = api.getUserDirectory() + resp.ensureSuccess() + val arr = org.json.JSONArray(resp.body()?.string() ?: "[]") + buildList { + for (i in 0 until arr.length()) { + val o = arr.optJSONObject(i) ?: continue + add(DirectoryUser(o.optInt("id"), o.optString("display_name"))) + } + } + } + + suspend fun getShares(calendarId: Int): List = guarded { + val resp = api.getShares(calendarId) + resp.ensureSuccess() + val arr = org.json.JSONArray(resp.body()?.string() ?: "[]") + buildList { + for (i in 0 until arr.length()) { + val o = arr.optJSONObject(i) ?: continue + add(CalendarShareEntry(o.optInt("user_id"), o.optString("display_name"), o.optString("permission"))) + } + } + } + + suspend fun addShare(calendarId: Int, userId: Int, permission: String) = guarded { + api.addShare(calendarId, jsonBody("user_id" to userId, "permission" to permission)).ensureSuccess() + } + + suspend fun removeShare(calendarId: Int, userId: Int) = guarded { + api.removeShare(calendarId, userId).ensureSuccess() + } + + // ---- iCal import/export ---- + + suspend fun importIcs(calendarId: Int, part: okhttp3.MultipartBody.Part): Triple> = guarded { + val resp = api.importCalendar(calendarId, part) + resp.ensureSuccess() + val o = JSONObject(resp.body()?.string() ?: "{}") + val errors = o.optJSONArray("errors") + val errList = buildList { if (errors != null) for (i in 0 until errors.length()) add(errors.optString(i)) } + Triple(o.optInt("imported"), o.optInt("skipped"), errList) + } + + suspend fun exportIcs(calendarId: Int): ByteArray = guarded { + val resp = api.exportCalendar(calendarId) + resp.ensureSuccess() + resp.body()?.bytes() ?: ByteArray(0) + } + + // ---- Groups ---- + + suspend fun getGroups(): List = guarded { + val resp = api.getGroups() + resp.ensureSuccess() + val arr = org.json.JSONArray(resp.body()?.string() ?: "[]") + buildList { + for (i in 0 until arr.length()) { + val o = arr.optJSONObject(i) ?: continue + add(Group( + id = o.optInt("id"), + name = o.optString("name"), + icon = if (o.isNull("icon")) null else o.optString("icon").ifBlank { null }, + role = o.optString("role", "member"), + memberCount = o.optInt("member_count", 0), + groupCalendarId = if (o.isNull("group_calendar_id")) null else o.optInt("group_calendar_id"), + groupCalendarColor = if (o.isNull("group_calendar_color")) null else o.optString("group_calendar_color").ifBlank { null }, + members = emptyList(), + )) + } + } + } + + suspend fun getGroup(id: Int): Group = guarded { + val resp = api.getGroup(id) + resp.ensureSuccess() + val o = JSONObject(resp.body()?.string() ?: "{}") + val membersArr = o.optJSONArray("members") + val members = buildList { + if (membersArr != null) for (i in 0 until membersArr.length()) { + val m = membersArr.optJSONObject(i) ?: continue + add(GroupMember( + m.optInt("id"), m.optString("display_name"), m.optString("role", "member"), + if (m.isNull("color")) null else m.optString("color").ifBlank { null }, + )) + } + } + Group( + id = o.optInt("id"), + name = o.optString("name"), + icon = if (o.isNull("icon")) null else o.optString("icon").ifBlank { null }, + role = "", + memberCount = members.size, + groupCalendarId = if (o.isNull("group_calendar_id")) null else o.optInt("group_calendar_id"), + groupCalendarColor = if (o.isNull("group_calendar_color")) null else o.optString("group_calendar_color").ifBlank { null }, + members = members, + ) + } + + suspend fun createGroup(name: String, memberIds: List, icon: String?) = guarded { + val arr = org.json.JSONArray() + memberIds.forEach { arr.put(it) } + api.createGroup(jsonBody("name" to name, "member_ids" to arr, "icon" to icon)).ensureSuccess() + } + + suspend fun updateGroup(id: Int, name: String?, icon: String?) = guarded { + api.updateGroup(id, jsonBody("name" to name, "icon" to icon)).ensureSuccess() + } + + suspend fun addGroupMember(groupId: Int, userId: Int) = guarded { + api.addGroupMember(groupId, jsonBody("user_id" to userId)).ensureSuccess() + } + + suspend fun removeGroupMember(groupId: Int, userId: Int) = guarded { + api.removeGroupMember(groupId, userId).ensureSuccess() + } + + suspend fun setGroupMemberColor(groupId: Int, userId: Int, color: String) = guarded { + api.setGroupMemberColor(groupId, userId, jsonBody("color" to color)).ensureSuccess() + } + + suspend fun deleteGroup(id: Int) = guarded { api.deleteGroup(id).ensureSuccess() } + + // ---- Profile & targeted settings ---- + + suspend fun updateProfile(displayName: String?, username: String?, email: String?): String? = guarded { + val resp = api.updateProfile(jsonBody("display_name" to displayName, "username" to username, "email" to email)) + resp.ensureSuccess() + runCatching { JSONObject(resp.body()?.string() ?: "{}").optString("access_token").ifBlank { null } }.getOrNull() + } + + suspend fun updatePrivateVisibility(value: String) = guarded { + api.updateSettings(jsonBody("private_event_visibility" to value)).ensureSuccess() + } + + suspend fun updateGroupVisibleCalendar(calendarId: Int?) = guarded { + // Send explicit JSON null to clear (jsonBody drops Kotlin nulls). + api.updateSettings(jsonBody("group_visible_calendar_id" to (calendarId ?: org.json.JSONObject.NULL))) + .ensureSuccess() + } + + suspend fun fetchGroupCombined(groupId: Int, start: Instant, end: Instant): List = withContext(Dispatchers.IO) { + val resp = api.fetchGroupCombined(groupId, Dates.isoUtc(start), Dates.isoUtc(end)) + resp.ensureSuccess() + val root = JSONObject(resp.body()?.string() ?: "{}") + val arr = root.optJSONArray("events") ?: return@withContext emptyList() + buildList { + for (i in 0 until arr.length()) { + val obj = arr.optJSONObject(i) ?: continue + CalEvent.fromJson(obj)?.let { add(it) } + } + } + } + suspend fun deleteLocalEvent(uid: String) = guarded { api.deleteLocalEvent(uid).ensureSuccess() } suspend fun createCalDAVEvent( @@ -358,6 +520,7 @@ class CalendarRepository @Inject constructor( private fun eventBody( calendarId: Int?, title: String, start: Instant, end: Instant, isAllDay: Boolean, location: String, description: String, color: String?, + isPrivate: Boolean = false, ) = jsonBody( buildMap { calendarId?.let { put("calendar_id", it) } @@ -368,6 +531,7 @@ class CalendarRepository @Inject constructor( put("location", location) put("description", description) if (!color.isNullOrBlank()) put("color", color) + put("private", isPrivate) } ) } diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt index d831a0d..0927da0 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt @@ -7,6 +7,7 @@ import com.scarriffle.calendarr.domain.model.HomeAssistantAccount import com.scarriffle.calendarr.domain.model.ICalSubscription import com.scarriffle.calendarr.domain.model.LocalCalendar import com.scarriffle.calendarr.domain.model.UserProfile +import okhttp3.MultipartBody import okhttp3.RequestBody import okhttp3.ResponseBody import retrofit2.Response @@ -14,9 +15,11 @@ import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.HTTP +import retrofit2.http.Multipart import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.PUT +import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query @@ -48,6 +51,65 @@ interface CalendarrApi { @PUT("api/settings/") suspend fun updateSettings(@Body body: RequestBody): Response + @PUT("api/profile/") + suspend fun updateProfile(@Body body: RequestBody): Response + + // ---- Sharing ---- + + @GET("api/users/directory") + suspend fun getUserDirectory(): Response + + @GET("api/local/calendars/{id}/shares") + suspend fun getShares(@Path("id") id: Int): Response + + @POST("api/local/calendars/{id}/shares") + suspend fun addShare(@Path("id") id: Int, @Body body: RequestBody): Response + + @DELETE("api/local/calendars/{id}/shares/{userId}") + suspend fun removeShare(@Path("id") id: Int, @Path("userId") userId: Int): Response + + // ---- iCal import/export ---- + + @Multipart + @POST("api/local/calendars/{id}/import") + suspend fun importCalendar(@Path("id") id: Int, @Part part: MultipartBody.Part): Response + + @GET("api/local/calendars/{id}/export") + suspend fun exportCalendar(@Path("id") id: Int): Response + + // ---- Groups ---- + + @GET("api/groups/") + suspend fun getGroups(): Response + + @GET("api/groups/{id}") + suspend fun getGroup(@Path("id") id: Int): Response + + @POST("api/groups/") + suspend fun createGroup(@Body body: RequestBody): Response + + @PUT("api/groups/{id}") + suspend fun updateGroup(@Path("id") id: Int, @Body body: RequestBody): Response + + @DELETE("api/groups/{id}") + suspend fun deleteGroup(@Path("id") id: Int): Response + + @POST("api/groups/{id}/members") + suspend fun addGroupMember(@Path("id") id: Int, @Body body: RequestBody): Response + + @DELETE("api/groups/{id}/members/{userId}") + suspend fun removeGroupMember(@Path("id") id: Int, @Path("userId") userId: Int): Response + + @PUT("api/groups/{id}/members/{userId}/color") + suspend fun setGroupMemberColor(@Path("id") id: Int, @Path("userId") userId: Int, @Body body: RequestBody): Response + + @GET("api/groups/{id}/combined") + suspend fun fetchGroupCombined( + @Path("id") id: Int, + @Query("start") start: String, + @Query("end") end: String, + ): Response + // ---- Profile ---- @PATCH("api/profile/") diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt index aa41878..07ad36f 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt @@ -29,6 +29,11 @@ data class LocalCalendar( val name: String = "", val color: String = "#34a853", val enabled: Boolean = true, + val type: String = "local", + val owned: Boolean = true, + @Json(name = "shared_by") val sharedBy: String? = null, + val permission: String? = null, + val group: Boolean = false, ) @JsonClass(generateAdapter = false) @@ -81,6 +86,7 @@ data class HACalendar( data class UserProfile( val id: Int, val username: String = "", + @Json(name = "display_name") val displayName: String? = null, val email: String? = null, @Json(name = "is_admin") val isAdmin: Boolean = false, @Json(name = "has_avatar") val hasAvatar: Boolean = false, diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt index 2e5f5d0..7f6aad5 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt @@ -22,6 +22,9 @@ data class AppSettings( @Json(name = "language") val language: String = "de", @Json(name = "month_divider_color") val monthDividerColor: String = "#7090c0", @Json(name = "month_label_color") val monthLabelColor: String = "#7090c0", + // How this user's private events appear to other group members: 'hidden' | 'busy'. + @Json(name = "private_event_visibility") val privateEventVisibility: String = "busy", + @Json(name = "group_visible_calendar_id") val groupVisibleCalendarId: Int? = null, ) { val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday" } diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt index 5a3e0b2..77d9ccb 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt @@ -8,6 +8,9 @@ import java.time.Instant * A unified calendar event, blended from all server sources * (local, caldav, google, ical, homeassistant). Mirrors iOS `CalEvent`. */ +/** Creator (or owner, in the group combined view) of an event. id is null for imported events. */ +data class EventPerson(val id: Int?, val displayName: String) + data class CalEvent( val id: String, val url: String, @@ -22,6 +25,11 @@ data class CalEvent( val calendarName: String, val calendarColor: String, val source: String, + val creator: EventPerson? = null, + val isPrivate: Boolean = false, + // Only set in the group combined view: + val owner: EventPerson? = null, + val isGroupEvent: Boolean = false, ) { /** * Per-event override colour, then the calendar's colour, then a stable @@ -54,6 +62,16 @@ data class CalEvent( return optString(key, "").takeIf { it.isNotBlank() && it != "null" } } + /** Parse a {id, display_name} person object (creator/owner). */ + private fun personFrom(json: JSONObject?, key: String): EventPerson? { + val obj = json?.optJSONObject(key) ?: return null + val name = obj.strOrNull("display_name") ?: return null + val id = if (obj.isNull("id")) null else obj.opt("id")?.let { + when (it) { is Number -> it.toInt(); is String -> it.toIntOrNull(); else -> null } + } + return EventPerson(id, name) + } + /** Parse one event object from the `/api/caldav/events` aggregate response. */ fun fromJson(json: JSONObject): CalEvent? { val title = json.strOrNull("title") ?: return null @@ -91,6 +109,10 @@ data class CalEvent( calendarName = json.strOrNull("calendar_name") ?: "", calendarColor = json.strOrNull("calendarColor") ?: "", source = json.strOrNull("source") ?: "local", + creator = personFrom(json, "creator"), + isPrivate = json.optBoolean("private", false), + owner = personFrom(json, "owner"), + isGroupEvent = json.optBoolean("is_group_event", false), ) } } diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/Groups.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/Groups.kt new file mode 100644 index 0000000..a0468b0 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/Groups.kt @@ -0,0 +1,27 @@ +package com.scarriffle.calendarr.domain.model + +/** Lightweight user from /api/users/directory (for sharing/group pickers). */ +data class DirectoryUser(val id: Int, val displayName: String) + +/** A share entry on a local calendar. */ +data class CalendarShareEntry(val userId: Int, val displayName: String, val permission: String) + +/** A member of a group, with their server-defined colour. */ +data class GroupMember( + val id: Int, + val displayName: String, + val role: String, + val color: String? = null, +) + +/** A user group + its shared group calendar. */ +data class Group( + val id: Int, + val name: String, + val icon: String? = null, + val role: String, + val memberCount: Int, + val groupCalendarId: Int?, + val groupCalendarColor: String? = null, + val members: List, +) From 4a44c20b1258e1435405cd11470ed8413130479d Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 21:54:19 +0200 Subject: [PATCH 14/59] feat: Android Ersteller-Anzeige + Privat-Flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Event-Detail zeigt "Erstellt von" (wenn != ich) + Privat-Hinweis; Editor hat Privat-Toggle (nur lokale Kalender, durch saveEvent/Repo durchgereicht). Login speichert userId + displayName (CredentialStore) für Vergleiche. Co-Authored-By: Claude Opus 4.8 --- .../calendarr/data/CalendarRepository.kt | 7 ++++++- .../scarriffle/calendarr/data/CredentialStore.kt | 14 +++++++++++++- .../scarriffle/calendarr/domain/model/CalEvent.kt | 8 ++++++-- .../main/java/com/scarriffle/calendarr/ui/L10n.kt | 4 ++-- .../calendarr/ui/calendar/CalendarScreen.kt | 5 +++-- .../calendarr/ui/calendar/CalendarViewModel.kt | 14 +++++++++----- .../calendarr/ui/event/EventDetailScreen.kt | 9 +++++++++ .../calendarr/ui/event/EventEditorSheet.kt | 14 ++++++++++++-- 8 files changed, 60 insertions(+), 15 deletions(-) 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 59e76cf..0073599 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -46,6 +46,9 @@ class CalendarRepository @Inject constructor( ) { private val api get() = apiProvider.api() + /** Current logged-in user id (0 if unknown) — for creator/owner comparisons. */ + val currentUserId: Int get() = credentialStore.userId + private suspend fun guarded(block: suspend () -> T): T = withContext(Dispatchers.IO) { try { block() @@ -93,9 +96,11 @@ class CalendarRepository @Inject constructor( val user = json.optJSONObject("user") val uname = user?.optString("username") ?: username val isAdmin = user?.optBoolean("is_admin", false) ?: false + val uid = user?.optInt("id", 0) ?: 0 + val displayName = user?.optString("display_name")?.takeIf { it.isNotBlank() } ?: uname credentialStore.serverUrl = ApiProvider.normalize(baseUrl) - credentialStore.saveLogin(token, uname, isAdmin) + credentialStore.saveLogin(token, uname, isAdmin, uid, displayName) apiProvider.invalidate() LoginResult(token, uname, isAdmin) } diff --git a/app/src/main/java/com/scarriffle/calendarr/data/CredentialStore.kt b/app/src/main/java/com/scarriffle/calendarr/data/CredentialStore.kt index d9c5ab6..07207b4 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/CredentialStore.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/CredentialStore.kt @@ -46,17 +46,27 @@ class CredentialStore @Inject constructor( get() = prefs.getBoolean(KEY_IS_ADMIN, false) set(value) = prefs.edit().putBoolean(KEY_IS_ADMIN, value).apply() + var userId: Int + get() = prefs.getInt(KEY_USER_ID, 0) + set(value) = prefs.edit().putInt(KEY_USER_ID, value).apply() + + var displayName: String? + get() = prefs.getString(KEY_DISPLAY_NAME, null) + set(value) = prefs.edit().putString(KEY_DISPLAY_NAME, value).apply() + /** True once a server URL has been entered (setup step complete). */ val isConfigured: Boolean get() = !serverUrl.isNullOrBlank() /** True once we hold an auth token (logged in). */ val isLoggedIn: Boolean get() = !token.isNullOrBlank() - fun saveLogin(token: String, username: String, isAdmin: Boolean) { + fun saveLogin(token: String, username: String, isAdmin: Boolean, userId: Int = 0, displayName: String? = null) { prefs.edit() .putString(KEY_TOKEN, token) .putString(KEY_USERNAME, username) .putBoolean(KEY_IS_ADMIN, isAdmin) + .putInt(KEY_USER_ID, userId) + .putString(KEY_DISPLAY_NAME, displayName ?: username) .apply() } @@ -79,5 +89,7 @@ class CredentialStore @Inject constructor( const val KEY_TOKEN = "auth_token" const val KEY_USERNAME = "username" const val KEY_IS_ADMIN = "is_admin" + const val KEY_USER_ID = "user_id" + const val KEY_DISPLAY_NAME = "display_name" } } diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt index 77d9ccb..e5ccd8c 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt @@ -30,13 +30,16 @@ data class CalEvent( // Only set in the group combined view: val owner: EventPerson? = null, val isGroupEvent: Boolean = false, + val displayColor: String? = null, ) { /** - * Per-event override colour, then the calendar's colour, then a stable + * Group view supplies a server-resolved colour (display_color); otherwise + * per-event override colour, then the calendar's colour, then a stable * per-calendar palette colour (so events never collapse to one default). */ val effectiveColor: String - get() = color?.takeIf { it.isNotBlank() } + get() = displayColor?.takeIf { it.isNotBlank() } + ?: color?.takeIf { it.isNotBlank() } ?: calendarColor.takeIf { it.isNotBlank() } ?: fallbackColorFor("$source:$calendarId") @@ -113,6 +116,7 @@ data class CalEvent( isPrivate = json.optBoolean("private", false), owner = personFrom(json, "owner"), isGroupEvent = json.optBoolean("is_group_event", false), + displayColor = json.strOrNull("display_color"), ) } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt index 30de0b8..8efdb94 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -87,7 +87,7 @@ object L10n { "twofa.code_placeholder" to "6-stelliger Code", "twofa.activate" to "Aktivieren", "twofa.disable_title" to "2FA deaktivieren", "twofa.password_placeholder" to "Passwort", "twofa.disable" to "Deaktivieren", - "event.title_placeholder" to "Titel", "event.allday" to "Ganztägig", + "event.title_placeholder" to "Titel", "event.allday" to "Ganztägig", "event.private" to "Privat", "event.created_by" to "Erstellt von", "event.start" to "Start", "event.end" to "Ende", "event.location" to "Ort", "event.description" to "Beschreibung", "event.calendar_section" to "Kalender", "event.no_writable" to "Keine beschreibbaren Kalender vorhanden", @@ -188,7 +188,7 @@ object L10n { "twofa.code_placeholder" to "6-digit code", "twofa.activate" to "Activate", "twofa.disable_title" to "Disable 2FA", "twofa.password_placeholder" to "Password", "twofa.disable" to "Disable", - "event.title_placeholder" to "Title", "event.allday" to "All-day", + "event.title_placeholder" to "Title", "event.allday" to "All-day", "event.private" to "Private", "event.created_by" to "Created by", "event.start" to "Start", "event.end" to "End", "event.location" to "Location", "event.description" to "Description", "event.calendar_section" to "Calendar", "event.no_writable" to "No writable calendars available", diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index 98d86f4..e43f207 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -188,6 +188,7 @@ fun CalendarScreen( if (ev != null) { EventDetailScreen( event = ev, + currentUserId = vm.currentUserId, onClose = { detailEvent = null }, onEdit = { detailEvent = null @@ -210,8 +211,8 @@ fun CalendarScreen( request = req, writableCalendars = state.writableCalendars, onDismiss = { editor = null }, - onSave = { cal, title, start, end, allDay, location, desc, color -> - vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color) { error -> + onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate -> + vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate) { error -> if (error == null) editor = null } }, diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index b669a69..17507ab 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -48,6 +48,9 @@ class CalendarViewModel @Inject constructor( private val zone: ZoneId = ZoneId.systemDefault() + /** Current user id (for creator/owner comparisons in the UI). */ + val currentUserId: Int get() = repository.currentUserId + /** Serializes network loads so overlapping fetches don't thrash the UI. */ private val loadMutex = Mutex() @@ -298,20 +301,21 @@ class CalendarViewModel @Inject constructor( location: String, description: String, color: String?, + isPrivate: Boolean, onResult: (String?) -> Unit, ) { viewModelScope.launch { val result = runCatching { if (existing != null && existing.source == calendar.source) { when (existing.source) { - "local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color) + "local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate) "caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color) "homeassistant" -> repository.updateHAEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description) "google" -> repository.updateGoogleEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description) - else -> createForSource(calendar, title, start, end, isAllDay, location, description, color) + else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate) } } else { - createForSource(calendar, title, start, end, isAllDay, location, description, color) + createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate) } } result.onSuccess { afterMutation(); onResult(null) } @@ -321,10 +325,10 @@ class CalendarViewModel @Inject constructor( private suspend fun createForSource( calendar: WritableCalendar, title: String, start: Instant, end: Instant, - isAllDay: Boolean, location: String, description: String, color: String?, + isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean, ) { when (calendar.source) { - "local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color) + "local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate) "caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color) "google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description) "homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt index 584c6e8..59fdb9d 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt @@ -23,7 +23,9 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Notes +import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Schedule import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -60,6 +62,7 @@ import com.scarriffle.calendarr.util.colorFromHex @Composable fun EventDetailScreen( event: CalEvent, + currentUserId: Int = 0, onClose: () -> Unit, onEdit: () -> Unit, onCopy: () -> Unit, @@ -109,6 +112,12 @@ fun EventDetailScreen( if (event.notes.isNotBlank()) DetailRow(Icons.Filled.Notes, event.notes) if (event.calendarName.isNotBlank()) DetailRow(Icons.Filled.CalendarMonth, event.calendarName) DetailRow(Icons.Filled.Dns, event.source.replaceFirstChar { it.uppercase() }) + event.creator?.let { c -> + if (c.id != currentUserId) { + DetailRow(Icons.Filled.Person, "${tr("event.created_by")}: ${c.displayName}") + } + } + if (event.isPrivate) DetailRow(Icons.Filled.Lock, tr("event.private")) Spacer(Modifier.height(28.dp)) OutlinedButton(onClick = onCopy, modifier = Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt index a510d32..758e01c 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt @@ -69,7 +69,7 @@ fun EventEditorSheet( request: EditorRequest, writableCalendars: List, onDismiss: () -> Unit, - onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?) -> Unit, + onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean) -> Unit, ) { val zone = ZoneId.systemDefault() val context = LocalContext.current @@ -85,6 +85,7 @@ fun EventEditorSheet( var location by remember { mutableStateOf(template?.location ?: "") } var description by remember { mutableStateOf(template?.notes ?: "") } var color by remember { mutableStateOf(template?.color) } + var isPrivate by remember { mutableStateOf(template?.isPrivate ?: false) } val initialStart = template?.startDate val initialEnd = template?.endDate @@ -230,6 +231,15 @@ fun EventEditorSheet( } Spacer(Modifier.size(12.dp)) + // Private (local calendars only) + if (calendar?.source == "local") { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(tr("event.private"), style = MaterialTheme.typography.bodyLarge) + Switch(checked = isPrivate, onCheckedChange = { isPrivate = it }) + } + Spacer(Modifier.size(12.dp)) + } + // Color Text(tr("event.color_section"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Row(Modifier.padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { @@ -270,7 +280,7 @@ fun EventEditorSheet( start = startDate.atTime(startTime).atZone(zone).toInstant() end = endDate.atTime(endTime).atZone(zone).toInstant() } - onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color) + onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local") }, enabled = writableCalendars.isNotEmpty(), modifier = Modifier.fillMaxWidth(), From 716dd01abbde81a66f4fc607430ac4b3660b3b95 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 22:07:06 +0200 Subject: [PATCH 15/59] feat: Android Profil-Kapitel in Einstellungen + Kalenderfarb-Endpunkte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Einstellungen zeigen jetzt ein Profil-Kapitel: Anzeigename (editierbar), Anmeldename (read-only), E-Mail + Speichern; Privatsphäre (private Termine beschäftigt/verbergen) und geteilter Kalender (Dropdown der eigenen lokalen Kalender) — serverseitig geladen und gezielt gespeichert. Datenebene um Farb-Endpunkte ergänzt (lokal/iCal PUT + setCalendarColor für caldav/google/ha) als Basis für die Farbbearbeitung. Co-Authored-By: Claude Opus 4.8 --- .../calendarr/data/CalendarRepository.kt | 21 +++++ .../calendarr/data/remote/CalendarrApi.kt | 6 ++ .../java/com/scarriffle/calendarr/ui/L10n.kt | 52 ++++++++++++ .../calendarr/ui/settings/SettingsScreen.kt | 82 +++++++++++++++++++ .../ui/settings/SettingsViewModel.kt | 65 +++++++++++++++ 5 files changed, 226 insertions(+) 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 0073599..c1a5e69 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -210,6 +210,27 @@ class CalendarRepository @Inject constructor( suspend fun deleteHomeAssistantAccount(id: Int) = guarded { api.deleteHomeAssistantAccount(id).ensureSuccess() } + /** Change a local calendar's colour. */ + suspend fun updateLocalCalendarColor(id: Int, color: String) = guarded { + api.updateLocalCalendar(id, jsonBody("color" to color)).ensureSuccess() + } + + /** Change an iCal subscription's colour. */ + suspend fun updateICalColor(id: Int, color: String) = guarded { + api.updateICalSubscription(id, jsonBody("color" to color)).ensureSuccess() + } + + /** Set a per-calendar colour for server-managed sources (caldav/google/homeassistant). */ + suspend fun setCalendarColor(source: String, calendarId: Int, color: String) = guarded { + val body = jsonBody("color" to color) + when (source) { + "caldav" -> api.updateCalDAVCalendar(calendarId, body).ensureSuccess() + "google" -> api.updateGoogleCalendar(calendarId, body).ensureSuccess() + "homeassistant" -> api.updateHACalendar(calendarId, body).ensureSuccess() + else -> Unit + } + } + /** Toggle a calendar's server-side visibility (caldav/google/homeassistant only). */ suspend fun setCalendarSidebarHidden(source: String, calendarId: Int, hidden: Boolean) = guarded { val body = jsonBody("enabled" to !hidden, "sidebar_hidden" to hidden) diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt index 0927da0..d246d47 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt @@ -152,6 +152,9 @@ interface CalendarrApi { @POST("api/local/calendars") suspend fun addLocalCalendar(@Body body: RequestBody): LocalCalendar + @PUT("api/local/calendars/{id}") + suspend fun updateLocalCalendar(@Path("id") id: Int, @Body body: RequestBody): Response + @DELETE("api/local/calendars/{id}") suspend fun deleteLocalCalendar(@Path("id") id: Int): Response @@ -163,6 +166,9 @@ interface CalendarrApi { @POST("api/ical/subscriptions") suspend fun addICalSubscription(@Body body: RequestBody): ICalSubscription + @PUT("api/ical/subscriptions/{id}") + suspend fun updateICalSubscription(@Path("id") id: Int, @Body body: RequestBody): Response + @DELETE("api/ical/subscriptions/{id}") suspend fun deleteICalSubscription(@Path("id") id: Int): Response diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt index 8efdb94..5ce56ce 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -122,6 +122,32 @@ object L10n { "ical.refresh.1h" to "Stündlich", "ical.refresh.6h" to "Alle 6 Std.", "ical.refresh.1d" to "Täglich", "ha.display_name" to "Anzeigename", "ha.url_placeholder" to "URL (z.B. http://homeassistant.local:8123)", "ha.token" to "Long-Lived Access Token", "ha.connect" to "Verbinden", + // Collaboration: profile / privacy / sharing / groups / import-export + "settings.nav.profile" to "Profil", + "profile.display_name" to "Anzeigename", "profile.login_name" to "Anmeldename", + "settings.privacy" to "Privatsphäre", "settings.private_visibility" to "Private Termine", + "settings.private.busy" to "Als beschäftigt zeigen", "settings.private.hidden" to "Verbergen", + "settings.private_visibility.desc" to "Legt fest, wie deine privaten Termine anderen Gruppenmitgliedern erscheinen.", + "settings.group_visible" to "Geteilter Kalender", + "settings.group_visible.desc" to "Dieser Kalender wird in der Gruppenansicht für andere sichtbar.", + "group.visible.none" to "Keiner", + "share.title" to "Teilen", "share.with" to "Geteilt mit", "share.add" to "Person hinzufügen", + "share.permission.read" to "Lesen", "share.permission.read_write" to "Bearbeiten", + "share.none" to "Noch nicht geteilt", "share.search" to "Benutzer suchen", + "accounts.shared.header" to "Kalender von anderen", "accounts.shared_by" to "geteilt von %@", + "accounts.color" to "Farbe ändern", "accounts.action.share" to "Teilen", + "accounts.action.import" to "Importieren", "accounts.action.export" to "Exportieren", + "import.title" to "iCal importieren", "import.result" to "%d importiert, %d übersprungen", + "import.failed" to "Import fehlgeschlagen", "export.failed" to "Export fehlgeschlagen", + "menu.groups" to "Gruppen", "groups.title" to "Gruppen", "groups.empty" to "Noch keine Gruppen", + "groups.create" to "Gruppe erstellen", "groups.name" to "Gruppenname", + "groups.members" to "Mitglieder", "groups.member_count" to "%d Mitglieder", + "groups.icon" to "Symbol", "groups.manage" to "Verwalten", + "groups.delete" to "Gruppe löschen", "groups.delete_confirm" to "Diese Gruppe löschen?", + "groups.leave" to "Gruppe verlassen", "groups.member_color" to "Farbe", + "groups.add_member" to "Mitglied hinzufügen", "groups.view" to "Gruppenansicht", + "groups.calendar" to "Gruppenkalender", "group.event" to "Gruppentermin", + "group.switch.personal" to "Mein Kalender", // Auth screens "auth.server_title" to "Server verbinden", "auth.server_url" to "Server-URL", "auth.server_hint" to "Gib die Adresse deines Calendarr-Servers ein.", @@ -223,6 +249,32 @@ object L10n { "ical.refresh.1h" to "Hourly", "ical.refresh.6h" to "Every 6 hours", "ical.refresh.1d" to "Daily", "ha.display_name" to "Display name", "ha.url_placeholder" to "URL (e.g. http://homeassistant.local:8123)", "ha.token" to "Long-Lived Access Token", "ha.connect" to "Connect", + // Collaboration: profile / privacy / sharing / groups / import-export + "settings.nav.profile" to "Profile", + "profile.display_name" to "Display name", "profile.login_name" to "Login name", + "settings.privacy" to "Privacy", "settings.private_visibility" to "Private events", + "settings.private.busy" to "Show as busy", "settings.private.hidden" to "Hidden", + "settings.private_visibility.desc" to "Controls how your private events appear to other group members.", + "settings.group_visible" to "Shared calendar", + "settings.group_visible.desc" to "This calendar is shown to others in the group view.", + "group.visible.none" to "None", + "share.title" to "Share", "share.with" to "Shared with", "share.add" to "Add person", + "share.permission.read" to "Read", "share.permission.read_write" to "Read & write", + "share.none" to "Not shared yet", "share.search" to "Search users", + "accounts.shared.header" to "Calendars from others", "accounts.shared_by" to "shared by %@", + "accounts.color" to "Change color", "accounts.action.share" to "Share", + "accounts.action.import" to "Import", "accounts.action.export" to "Export", + "import.title" to "Import iCal", "import.result" to "%d imported, %d skipped", + "import.failed" to "Import failed", "export.failed" to "Export failed", + "menu.groups" to "Groups", "groups.title" to "Groups", "groups.empty" to "No groups yet", + "groups.create" to "Create group", "groups.name" to "Group name", + "groups.members" to "Members", "groups.member_count" to "%d members", + "groups.icon" to "Icon", "groups.manage" to "Manage", + "groups.delete" to "Delete group", "groups.delete_confirm" to "Delete this group?", + "groups.leave" to "Leave group", "groups.member_color" to "Color", + "groups.add_member" to "Add member", "groups.view" to "Group view", + "groups.calendar" to "Group calendar", "group.event" to "Group event", + "group.switch.personal" to "My calendar", // Auth screens "auth.server_title" to "Connect server", "auth.server_url" to "Server URL", "auth.server_hint" to "Enter the address of your Calendarr server.", diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt index e03e8cb..cae525f 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt @@ -21,12 +21,17 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button import androidx.compose.material3.Divider +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch @@ -86,6 +91,9 @@ fun SettingsScreen( ) { padding -> Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) { + ProfileChapter(vm) + Divider(Modifier.padding(vertical = 16.dp)) + Section(tr("settings.calview")) ChipRow( options = CalViewType.entries.map { it.key to tr("view.${it.key}") }, @@ -175,6 +183,80 @@ private fun Section(title: String) { Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp)) } +/** Server-backed "Profil" chapter: display name, login name, email, privacy, shared calendar. */ +@Composable +private fun ProfileChapter(vm: SettingsViewModel) { + val savedLabel = tr("settings.saved") + + Section(tr("settings.nav.profile")) + OutlinedTextField( + value = vm.displayName, + onValueChange = vm::onDisplayNameChange, + label = { Text(tr("profile.display_name")) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(8.dp)) + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text(tr("profile.login_name"), modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(vm.loginName, fontWeight = FontWeight.Medium) + } + Spacer(Modifier.size(8.dp)) + 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.saveProfile(savedLabel) }) { Text(tr("event.save")) } + vm.profileMessage?.let { + Spacer(Modifier.size(8.dp)) + Text(it, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall) + } + Divider(Modifier.padding(vertical = 16.dp)) + + Section(tr("settings.privacy")) + ChipRow( + options = listOf("busy" to tr("settings.private.busy"), "hidden" to tr("settings.private.hidden")), + selected = vm.privateVisibility, + onSelect = vm::changePrivateVisibility, + ) + Spacer(Modifier.size(6.dp)) + Text(tr("settings.private_visibility.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Divider(Modifier.padding(vertical = 16.dp)) + + Section(tr("settings.group_visible")) + CalendarDropdown(vm) + Spacer(Modifier.size(6.dp)) + Text(tr("settings.group_visible.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CalendarDropdown(vm: SettingsViewModel) { + var expanded by remember { mutableStateOf(false) } + val noneLabel = tr("group.visible.none") + val selectedLabel = vm.ownLocalCalendars.firstOrNull { it.id == vm.groupVisibleId }?.name ?: noneLabel + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = selectedLabel, + onValueChange = {}, + readOnly = true, + label = { Text(tr("settings.group_visible")) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor().fillMaxWidth(), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem(text = { Text(noneLabel) }, onClick = { vm.changeGroupVisible(0); expanded = false }) + vm.ownLocalCalendars.forEach { cal -> + DropdownMenuItem(text = { Text(cal.name) }, onClick = { vm.changeGroupVisible(cal.id); expanded = false }) + } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChipRow(options: List>, selected: String, onSelect: (String) -> Unit) { diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt index 18f507d..159cf40 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt @@ -1,10 +1,14 @@ package com.scarriffle.calendarr.ui.settings +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.SettingsStore import com.scarriffle.calendarr.domain.model.AppSettings +import com.scarriffle.calendarr.domain.model.LocalCalendar import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -27,4 +31,65 @@ class SettingsViewModel @Inject constructor( var cacheMonths: Int get() = settingsStore.cacheMonths set(value) { settingsStore.cacheMonths = value } + + // ---- Profile chapter (server-backed) ---- + + var displayName by mutableStateOf("") + var loginName by mutableStateOf("") + var email by mutableStateOf("") + private set + var privateVisibility by mutableStateOf("busy") + private set + var groupVisibleId by mutableStateOf(0) // 0 = none + private set + var ownLocalCalendars by mutableStateOf>(emptyList()) + private set + var profileMessage by mutableStateOf(null) + private set + + init { loadProfile() } + + fun onDisplayNameChange(v: String) { displayName = v } + fun onEmailChange(v: String) { email = v } + + private fun loadProfile() { + viewModelScope.launch { + runCatching { repository.getProfile() }.onSuccess { p -> + displayName = p.displayName ?: p.username + loginName = p.username + email = p.email ?: "" + } + runCatching { repository.getSettings() }.onSuccess { s -> + privateVisibility = s.privateEventVisibility + groupVisibleId = s.groupVisibleCalendarId ?: 0 + } + runCatching { repository.getLocalCalendars() }.onSuccess { cals -> + ownLocalCalendars = cals.filter { it.owned && !it.group } + } + } + } + + fun saveProfile(savedLabel: String) { + viewModelScope.launch { + runCatching { + repository.updateProfile( + displayName = displayName.trim().ifEmpty { null }, + username = null, + email = email.trim(), + ) + } + .onSuccess { profileMessage = savedLabel } + .onFailure { profileMessage = it.message } + } + } + + fun changePrivateVisibility(value: String) { + privateVisibility = value + viewModelScope.launch { runCatching { repository.updatePrivateVisibility(value) } } + } + + fun changeGroupVisible(id: Int) { + groupVisibleId = id + viewModelScope.launch { runCatching { repository.updateGroupVisibleCalendar(id.takeIf { it != 0 }) } } + } } From e0d1b24afc5e9d1ebd3ea50904e40f86c0bb9bab Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 22:17:53 +0200 Subject: [PATCH 16/59] feat: Android Kalenderfarben, Sharing & iCal-Import/Export (lokale Kalender) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AccountsScreen überarbeitet: editierbare Farb-Punkte (lokal, iCal, sowie Unterkalender von CalDAV/Google/HA via Farbwähler-Dialog); lokale Kalender zeigen Gruppen-Marker + "geteilt von" und ein Aktionsmenü (Teilen/Importieren/ Exportieren/Löschen je nach Besitz & Berechtigung). Teilen-Sheet mit Benutzersuche + Berechtigung; Import via OpenDocument, Export via CreateDocument. Repository: importIcsFile(bytes) + Farb-Update-Methoden; ViewModel um Farb-, Sharing- und Import/Export-Logik erweitert. Co-Authored-By: Claude Opus 4.8 --- .../calendarr/data/CalendarRepository.kt | 9 + .../calendarr/ui/accounts/AccountsScreen.kt | 281 +++++++++++++++++- .../ui/accounts/AccountsViewModel.kt | 67 +++++ 3 files changed, 349 insertions(+), 8 deletions(-) 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 c1a5e69..97d8672 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -23,6 +23,8 @@ import com.scarriffle.calendarr.domain.model.WritableCalendar import com.scarriffle.calendarr.util.Dates import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject import retrofit2.HttpException import java.time.Instant @@ -356,6 +358,13 @@ class CalendarRepository @Inject constructor( Triple(o.optInt("imported"), o.optInt("skipped"), errList) } + /** Build the multipart body from raw bytes and import (server form field: "file"). */ + suspend fun importIcsFile(calendarId: Int, bytes: ByteArray, filename: String): Triple> { + val body = bytes.toRequestBody("text/calendar".toMediaTypeOrNull()) + val part = okhttp3.MultipartBody.Part.createFormData("file", filename, body) + return importIcs(calendarId, part) + } + suspend fun exportIcs(calendarId: Int): ByteArray = guarded { val resp = api.exportCalendar(calendarId) resp.ensureSuccess() 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 3ee3902..c3bfa14 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 @@ -1,5 +1,7 @@ package com.scarriffle.calendarr.ui.accounts +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -13,23 +15,33 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.People import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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 @@ -37,15 +49,33 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.scarriffle.calendarr.domain.model.LocalCalendar +import com.scarriffle.calendarr.ui.L10n +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.components.ColorPickerDialog import com.scarriffle.calendarr.ui.components.PasswordField import com.scarriffle.calendarr.ui.tr import com.scarriffle.calendarr.util.colorFromHex private enum class AddType { LOCAL, CALDAV, ICAL, HA } +/** What colour the picker dialog is currently editing. */ +private sealed interface ColorTarget { + val current: String + data class Local(val id: Int, override val current: String) : ColorTarget + data class ICal(val id: Int, override val current: String) : ColorTarget + data class Source(val source: String, val id: Int, override val current: String) : ColorTarget +} + +private fun safeFileName(name: String): String { + val cleaned = name.map { if (it.isLetterOrDigit()) it else '_' }.joinToString("") + return (cleaned.ifBlank { "calendar" }) + ".ics" +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun AccountsScreen( @@ -53,7 +83,38 @@ fun AccountsScreen( onChanged: () -> Unit, vm: AccountsViewModel = hiltViewModel(), ) { + val context = LocalContext.current + val lang = LocalLang.current var addDialog by remember { mutableStateOf(null) } + var editingColor by remember { mutableStateOf(null) } + var shareCalId by remember { mutableStateOf(null) } + + // Import: pick a document, read its bytes, upload to importTarget. + var importTarget by remember { mutableStateOf(null) } + val openDoc = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + val calId = importTarget + importTarget = null + if (uri != null && calId != null) { + val bytes = runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull() + if (bytes != null) { + vm.importIcs( + calId, bytes, "import.ics", + result = { imported, skipped -> L10n.t("import.result", lang, imported, skipped) }, + onChanged = onChanged, + ) + } + } + } + + // Export: fetch bytes first, then let the user save them to a chosen location. + var pendingExport by remember { mutableStateOf(null) } + val createDoc = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/calendar")) { uri -> + val bytes = pendingExport + pendingExport = null + if (uri != null && bytes != null) { + runCatching { context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) } } + } + } Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Scaffold( @@ -71,39 +132,69 @@ fun AccountsScreen( return@Scaffold } LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { - // Local + // Local (incl. shared-with-me and group calendars) item { SectionHeader(tr("accounts.local.header"), tr("accounts.local.add")) { addDialog = AddType.LOCAL } } if (vm.local.isEmpty()) item { EmptyRow(tr("accounts.local.empty")) } items(vm.local, key = { "l${it.id}" }) { cal -> - AccountRow(cal.name, cal.color) { vm.deleteLocal(cal.id, onChanged) } + LocalCalendarRow( + cal = cal, + onColor = { editingColor = ColorTarget.Local(cal.id, cal.color) }, + onShare = { shareCalId = cal.id }, + onImport = { importTarget = cal.id; openDoc.launch(arrayOf("*/*")) }, + onExport = { vm.exportIcs(cal.id) { bytes -> pendingExport = bytes; createDoc.launch(safeFileName(cal.name)) } }, + onDelete = { vm.deleteLocal(cal.id, onChanged) }, + ) } // CalDAV item { SectionHeader(tr("accounts.caldav.header"), tr("accounts.caldav.add")) { addDialog = AddType.CALDAV } } if (vm.caldav.isEmpty()) item { EmptyRow(tr("accounts.caldav.empty")) } items(vm.caldav, key = { "c${it.id}" }) { acc -> - AccountRow("${acc.name} (${acc.username})", acc.color) { vm.deleteCalDAV(acc.id, onChanged) } + Column { + AccountHeaderRow("${acc.name} (${acc.username})", acc.color) { vm.deleteCalDAV(acc.id, onChanged) } + acc.calendars.orEmpty().forEach { cal -> + ChildCalendarRow(cal.name, cal.color ?: acc.color) { + editingColor = ColorTarget.Source("caldav", cal.id, cal.color ?: acc.color) + } + } + } } // iCal item { SectionHeader(tr("accounts.ical.header"), tr("accounts.ical.add")) { addDialog = AddType.ICAL } } if (vm.ical.isEmpty()) item { EmptyRow(tr("accounts.ical.empty")) } items(vm.ical, key = { "i${it.id}" }) { sub -> - AccountRow(sub.name, sub.color) { vm.deleteICal(sub.id, onChanged) } + EditableColorRow(sub.name, sub.color, onColor = { editingColor = ColorTarget.ICal(sub.id, sub.color) }) { + vm.deleteICal(sub.id, onChanged) + } } // Google item { SectionHeaderNoAdd(tr("accounts.google.header")) } if (vm.google.isEmpty()) item { EmptyRow(tr("accounts.google.hint")) } items(vm.google, key = { "g${it.id}" }) { acc -> - AccountRow(acc.email, "#4285f4") { vm.deleteGoogle(acc.id, onChanged) } + Column { + AccountHeaderRow(acc.email, "#4285f4") { vm.deleteGoogle(acc.id, onChanged) } + acc.calendars.orEmpty().forEach { cal -> + ChildCalendarRow(cal.name, cal.color ?: "#4285f4") { + editingColor = ColorTarget.Source("google", cal.id, cal.color ?: "#4285f4") + } + } + } } // Home Assistant item { SectionHeader(tr("accounts.ha.header"), tr("accounts.ha.add")) { addDialog = AddType.HA } } if (vm.homeAssistant.isEmpty()) item { EmptyRow(tr("accounts.ha.empty")) } items(vm.homeAssistant, key = { "h${it.id}" }) { acc -> - AccountRow(acc.name, "#46bdc6") { vm.deleteHA(acc.id, onChanged) } + Column { + AccountHeaderRow(acc.name, "#46bdc6") { vm.deleteHA(acc.id, onChanged) } + acc.calendars.orEmpty().forEach { cal -> + ChildCalendarRow(cal.name, cal.color ?: "#46bdc6") { + editingColor = ColorTarget.Source("homeassistant", cal.id, cal.color ?: "#46bdc6") + } + } + } } vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } } @@ -127,6 +218,34 @@ fun AccountsScreen( } null -> Unit } + + editingColor?.let { target -> + ColorPickerDialog( + initial = target.current, + title = tr("accounts.color"), + onDismiss = { editingColor = null }, + onConfirm = { hex -> + when (target) { + is ColorTarget.Local -> vm.setLocalColor(target.id, hex, onChanged) + is ColorTarget.ICal -> vm.setICalColor(target.id, hex, onChanged) + is ColorTarget.Source -> vm.setSourceColor(target.source, target.id, hex, onChanged) + } + editingColor = null + }, + ) + } + + shareCalId?.let { id -> + SharingSheet(vm = vm, calendarId = id, onDismiss = { shareCalId = null }) + } + + vm.infoMessage?.let { msg -> + AlertDialog( + onDismissRequest = { vm.clearInfo() }, + confirmButton = { TextButton(onClick = { vm.clearInfo() }) { Text(tr("common.ok")) } }, + text = { Text(msg) }, + ) + } } @Composable @@ -155,13 +274,45 @@ private fun EmptyRow(text: String) { Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 6.dp)) } +/** Tappable colour swatch. */ @Composable -private fun AccountRow(name: String, color: String, onDelete: () -> Unit) { +private fun ColorDot(hex: String, editable: Boolean, onClick: () -> Unit) { + val base = Modifier.size(if (editable) 18.dp else 14.dp).clip(CircleShape).background(colorFromHex(hex)) + Box(if (editable) base.clickable(onClick = onClick) else base) +} + +/** Account header (CalDAV/Google/HA) with a delete button; colour edited on the child rows. */ +@Composable +private fun AccountHeaderRow(name: String, color: String, onDelete: () -> Unit) { Row( - Modifier.fillMaxWidth().padding(vertical = 6.dp), + Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(color))) + Text(name, modifier = Modifier.weight(1f).padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + IconButton(onClick = onDelete) { + Icon(Icons.Filled.Delete, contentDescription = tr("common.delete"), tint = MaterialTheme.colorScheme.error) + } + } +} + +/** A child calendar of a server-managed account: editable colour + name. */ +@Composable +private fun ChildCalendarRow(name: String, color: String, onColor: () -> Unit) { + Row( + Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ColorDot(color, editable = true, onClick = onColor) + Text(name, modifier = Modifier.padding(start = 12.dp), style = MaterialTheme.typography.bodyMedium) + } +} + +/** A simple row (iCal) with editable colour + delete. */ +@Composable +private fun EditableColorRow(name: String, color: String, onColor: () -> Unit, onDelete: () -> Unit) { + Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { + ColorDot(color, editable = true, onClick = onColor) Text(name, modifier = Modifier.weight(1f).padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge) IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, contentDescription = tr("common.delete"), tint = MaterialTheme.colorScheme.error) @@ -169,6 +320,120 @@ private fun AccountRow(name: String, color: String, onDelete: () -> Unit) { } } +/** Local calendar row: colour, group marker, "shared by", and an actions menu. */ +@Composable +private fun LocalCalendarRow( + cal: LocalCalendar, + onColor: () -> Unit, + onShare: () -> Unit, + onImport: () -> Unit, + onExport: () -> Unit, + onDelete: () -> Unit, +) { + var menu by remember { mutableStateOf(false) } + Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { + ColorDot(cal.color, editable = cal.owned, onClick = onColor) + Column(Modifier.weight(1f).padding(start = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(cal.name, style = MaterialTheme.typography.bodyLarge) + if (cal.group) { + Spacer(Modifier.size(6.dp)) + Icon(Icons.Filled.People, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(16.dp)) + } + } + val by = cal.sharedBy + if (!cal.owned && by != null) { + Text(tr("accounts.shared_by", by), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Box { + IconButton(onClick = { menu = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = tr("accounts.action.share"), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + if (cal.owned && !cal.group) { + DropdownMenuItem(text = { Text(tr("accounts.action.share")) }, onClick = { menu = false; onShare() }) + } + if (cal.owned || cal.permission == "read_write") { + DropdownMenuItem(text = { Text(tr("accounts.action.import")) }, onClick = { menu = false; onImport() }) + } + DropdownMenuItem(text = { Text(tr("accounts.action.export")) }, onClick = { menu = false; onExport() }) + if (cal.owned) { + DropdownMenuItem(text = { Text(tr("common.delete")) }, onClick = { menu = false; onDelete() }) + } + } + } + } +} + +/** Manage who a local calendar is shared with (owner only). */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SharingSheet(vm: AccountsViewModel, calendarId: Int, onDismiss: () -> Unit) { + LaunchedEffect(calendarId) { vm.loadSharing(calendarId) } + var permission by remember { mutableStateOf("read") } + var search by remember { mutableStateOf("") } + val sharedIds = vm.shares.map { it.userId }.toSet() + val candidates = vm.directory.filter { + it.id !in sharedIds && (search.isBlank() || it.displayName.contains(search, ignoreCase = true)) + } + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState()), + ) { + Text(tr("share.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.size(16.dp)) + + Text(tr("share.with"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + if (vm.shares.isEmpty()) { + Text(tr("share.none"), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 6.dp)) + } else { + vm.shares.forEach { s -> + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Text(s.displayName, modifier = Modifier.weight(1f)) + Text( + if (s.permission == "read_write") tr("share.permission.read_write") else tr("share.permission.read"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + IconButton(onClick = { vm.removeShare(calendarId, s.userId) }) { + Icon(Icons.Filled.Delete, contentDescription = tr("common.delete"), tint = MaterialTheme.colorScheme.error) + } + } + } + } + + Divider(Modifier.padding(vertical = 16.dp)) + + Text(tr("share.add"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.size(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = permission == "read", onClick = { permission = "read" }, label = { Text(tr("share.permission.read")) }) + FilterChip(selected = permission == "read_write", onClick = { permission = "read_write" }, label = { Text(tr("share.permission.read_write")) }) + } + Spacer(Modifier.size(8.dp)) + OutlinedTextField( + value = search, + onValueChange = { search = it }, + label = { Text(tr("share.search")) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(4.dp)) + candidates.forEach { u -> + Row( + Modifier.fillMaxWidth().clickable { vm.addShare(calendarId, u.id, permission) }.padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(u.displayName, modifier = Modifier.weight(1f)) + Icon(Icons.Filled.Add, contentDescription = tr("share.add"), tint = MaterialTheme.colorScheme.primary) + } + } + } + } +} + // ---- Add dialogs ---- @Composable diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt index 3ced76d..357faa9 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsViewModel.kt @@ -7,6 +7,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.scarriffle.calendarr.data.CalendarRepository import com.scarriffle.calendarr.domain.model.CalDAVAccount +import com.scarriffle.calendarr.domain.model.CalendarShareEntry +import com.scarriffle.calendarr.domain.model.DirectoryUser import com.scarriffle.calendarr.domain.model.GoogleAccount import com.scarriffle.calendarr.domain.model.HomeAssistantAccount import com.scarriffle.calendarr.domain.model.ICalSubscription @@ -85,4 +87,69 @@ class AccountsViewModel @Inject constructor( fun deleteGoogle(id: Int, onChanged: () -> Unit) = mutate(onChanged) { repository.deleteGoogleAccount(id) } + + // ---- Calendar colour ---- + + fun setLocalColor(id: Int, color: String, onChanged: () -> Unit) = + mutate(onChanged) { repository.updateLocalCalendarColor(id, color) } + + fun setICalColor(id: Int, color: String, onChanged: () -> Unit) = + mutate(onChanged) { repository.updateICalColor(id, color) } + + fun setSourceColor(source: String, calendarId: Int, color: String, onChanged: () -> Unit) = + mutate(onChanged) { repository.setCalendarColor(source, calendarId, color) } + + // ---- Sharing ---- + + var shares by mutableStateOf>(emptyList()) + private set + var directory by mutableStateOf>(emptyList()) + private set + + fun loadSharing(calendarId: Int) { + viewModelScope.launch { + shares = runCatching { repository.getShares(calendarId) }.getOrDefault(emptyList()) + directory = runCatching { repository.getUserDirectory() }.getOrDefault(emptyList()) + } + } + + fun addShare(calendarId: Int, userId: Int, permission: String) { + viewModelScope.launch { + runCatching { repository.addShare(calendarId, userId, permission) } + .onSuccess { loadSharing(calendarId) } + .onFailure { error = it.message } + } + } + + fun removeShare(calendarId: Int, userId: Int) { + viewModelScope.launch { + runCatching { repository.removeShare(calendarId, userId) } + .onSuccess { loadSharing(calendarId) } + .onFailure { error = it.message } + } + } + + // ---- Import / export ---- + + var infoMessage by mutableStateOf(null) + private set + + fun clearInfo() { infoMessage = null } + fun clearError() { error = null } + + fun importIcs(calendarId: Int, bytes: ByteArray, filename: String, result: (Int, Int) -> String, onChanged: () -> Unit) { + viewModelScope.launch { + runCatching { repository.importIcsFile(calendarId, bytes, filename) } + .onSuccess { (imported, skipped, _) -> infoMessage = result(imported, skipped); load(); onChanged() } + .onFailure { error = it.message } + } + } + + fun exportIcs(calendarId: Int, onBytes: (ByteArray) -> Unit) { + viewModelScope.launch { + runCatching { repository.exportIcs(calendarId) } + .onSuccess { onBytes(it) } + .onFailure { error = it.message } + } + } } From 3352aa3be7be2650f3aa01ca8a99881f36ac976f Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 22:36:26 +0200 Subject: [PATCH 17/59] feat: Android Gruppen + kombinierte Ansicht direkt im Kalender MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Gruppen-Verwaltungsscreen (Liste, Erstellen, Verwalten: Name, wählbares Emoji-Icon, Mitglieder, server-definierte Mitgliederfarben, Löschen) über das Menü. Top-Bar-Umschalter (Mein Kalender / Gruppen) + Akzent-Banner schalten den Kalender in die kombinierte Gruppenansicht: Events laufen durch die normale Cache-/Render-Pipeline (Monat/Woche/Tag/Termine), mit Namens-Präfix der Besitzer und 👥 für Gruppentermine; im Gruppenmodus kein Sichtbarkeitsfilter. Co-Authored-By: Claude Opus 4.8 --- .../calendarr/ui/calendar/CalendarScreen.kt | 69 +++- .../ui/calendar/CalendarViewModel.kt | 67 +++- .../calendarr/ui/groups/GroupsScreen.kt | 298 ++++++++++++++++++ .../calendarr/ui/groups/GroupsViewModel.kt | 77 +++++ .../scarriffle/calendarr/ui/menu/MenuSheet.kt | 3 + 5 files changed, 507 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsViewModel.kt diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index e43f207..797e527 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.Today import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu @@ -55,15 +56,17 @@ import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.ui.LocalLang import com.scarriffle.calendarr.ui.accounts.AccountsScreen +import com.scarriffle.calendarr.domain.model.Group import com.scarriffle.calendarr.ui.event.EventDetailScreen import com.scarriffle.calendarr.ui.event.EventEditorSheet +import com.scarriffle.calendarr.ui.groups.GroupsScreen import com.scarriffle.calendarr.ui.menu.MenuSheet import com.scarriffle.calendarr.ui.profile.ProfileScreen import com.scarriffle.calendarr.ui.settings.SettingsScreen import com.scarriffle.calendarr.ui.tr import java.time.LocalDate -private enum class Overlay { NONE, PROFILE, SETTINGS, ACCOUNTS } +private enum class Overlay { NONE, PROFILE, SETTINGS, ACCOUNTS, GROUPS } data class EditorRequest(val existing: CalEvent?, val date: LocalDate, val prefill: CalEvent? = null) @@ -113,6 +116,9 @@ fun CalendarScreen( viewType = state.viewType, loading = state.isLoading || state.isBackgroundCaching, viewMenuOpen = viewMenuOpen, + groups = state.groups, + activeGroup = state.activeGroup, + onSwitchGroup = { vm.switchGroup(it) }, onMenu = { showMenu = true }, onPrev = { goPrev() }, onToday = { goToday() }, @@ -136,6 +142,9 @@ fun CalendarScreen( state.error?.let { err -> ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError) } + state.activeGroup?.let { g -> + GroupBanner(group = g, onExit = { vm.switchGroup(null) }) + } Box(Modifier.fillMaxSize()) { CalendarBody( state = state, @@ -162,6 +171,7 @@ fun CalendarScreen( onProfile = { showMenu = false; overlay = Overlay.PROFILE }, onAppearance = { showMenu = false; overlay = Overlay.SETTINGS }, onAccounts = { showMenu = false; overlay = Overlay.ACCOUNTS }, + onGroups = { showMenu = false; overlay = Overlay.GROUPS }, onSync = { showMenu = false; vm.syncWithServer() }, onLogout = { showMenu = false; onLogout() }, onSwitchServer = { showMenu = false; onSwitchServer() }, @@ -230,6 +240,10 @@ fun CalendarScreen( onClose = { overlay = Overlay.NONE }, onChanged = { vm.loadWritableCalendars(); vm.syncWithServer() }, ) + Overlay.GROUPS -> GroupsScreen( + onClose = { overlay = Overlay.NONE }, + onChanged = { vm.loadGroups(); vm.loadWritableCalendars() }, + ) Overlay.NONE -> Unit } } @@ -296,6 +310,9 @@ private fun CompactTopBar( viewType: CalViewType, loading: Boolean, viewMenuOpen: Boolean, + groups: List, + activeGroup: Group?, + onSwitchGroup: (Group?) -> Unit, onMenu: () -> Unit, onPrev: () -> Unit, onToday: () -> Unit, @@ -331,6 +348,9 @@ private fun CompactTopBar( strokeWidth = 2.dp, ) } + if (groups.isNotEmpty()) { + GroupSwitcher(groups = groups, activeGroup = activeGroup, onSwitchGroup = onSwitchGroup) + } CompactIcon(Icons.Filled.FilterList, onFilter, tr("filter.button")) Box { CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change")) @@ -360,6 +380,53 @@ private fun CompactIcon( } } +/** Top-bar switcher: "My calendar" + each group; flips the calendar into the group overlay. */ +@Composable +private fun GroupSwitcher(groups: List, activeGroup: Group?, onSwitchGroup: (Group?) -> Unit) { + var open by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { open = true }, modifier = Modifier.size(40.dp)) { + Icon( + Icons.Filled.People, + contentDescription = tr("groups.title"), + modifier = Modifier.size(22.dp), + tint = if (activeGroup != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + ) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + DropdownMenuItem( + text = { Text(tr("group.switch.personal")) }, + onClick = { open = false; onSwitchGroup(null) }, + ) + groups.forEach { g -> + DropdownMenuItem( + text = { Text("${g.icon ?: "👥"} ${g.name}") }, + onClick = { open = false; onSwitchGroup(g) }, + ) + } + } + } +} + +@Composable +private fun GroupBanner(group: Group, onExit: () -> Unit) { + Surface(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), modifier = Modifier.fillMaxWidth()) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "${tr("groups.view")}: ${group.icon ?: "👥"} ${group.name}", + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + TextButton(onClick = onExit) { Text(tr("group.switch.personal")) } + } + } +} + /** Distinct calendars currently present in the cache, for the filter sheet. */ private fun allKnownCalendars(vm: CalendarViewModel): List { val st = vm.state.value diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index 17507ab..dbc215a 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -6,6 +6,7 @@ import com.scarriffle.calendarr.data.CalendarRepository import com.scarriffle.calendarr.data.SettingsStore import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.domain.model.CalViewType +import com.scarriffle.calendarr.domain.model.Group import com.scarriffle.calendarr.domain.model.WritableCalendar import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -38,6 +39,9 @@ data class CalendarUiState( val writableCalendars: List = emptyList(), val hiddenKeys: Set = emptySet(), val banishedKeys: Set = emptySet(), + // Group overlay: when non-null the calendar shows the group's combined view. + val groups: List = emptyList(), + val activeGroup: Group? = null, ) @HiltViewModel @@ -68,6 +72,7 @@ class CalendarViewModel @Inject constructor( init { loadWritableCalendars() + loadGroups() initialLoad() } @@ -193,15 +198,35 @@ class CalendarViewModel @Inject constructor( loadMutex.withLock { // Another load (e.g. the background prefetch) may have covered this range. if (isCached(start, end)) return + val group = _state.value.activeGroup val flag = if (background) "bg" else "fg" _state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) } - runCatching { repository.fetchEvents(start, end) } + runCatching { + if (group != null) decorateGroup(repository.fetchGroupCombined(group.id, start, end)) + else repository.fetchEvents(start, end) + } .onSuccess { mergeIntoCache(it, start, end); refreshFromCache() } .onFailure { e -> if (!background) _state.update { it.copy(error = e.message) } } _state.update { it.copy(isLoading = false, isBackgroundCaching = false) } } } + /** Prefix combined-view events with the owner's / creator's first name (and 👥 for group events). */ + private fun decorateGroup(events: List): List { + val me = currentUserId + return events.map { ev -> + val prefix = when { + ev.isGroupEvent && ev.creator != null && ev.creator.id != me -> "👥 ${firstName(ev.creator.displayName)}: " + ev.isGroupEvent -> "👥 " + ev.owner != null && ev.owner.id != me -> "${firstName(ev.owner.displayName)}: " + else -> "" + } + if (prefix.isEmpty()) ev else ev.copy(title = prefix + ev.title) + } + } + + private fun firstName(s: String): String = s.trim().substringBefore(' ').ifBlank { s } + private fun markReady() { _ready.value = true } @@ -222,11 +247,17 @@ class CalendarViewModel @Inject constructor( } private fun refreshFromCache() { - val hidden = _state.value.hiddenKeys - val banished = _state.value.banishedKeys - val visible = allCachedEvents.filter { ev -> - val key = calendarKey(ev.source, ev.calendarId) - key !in hidden && key !in banished + val st = _state.value + // In group mode the server already scopes + filters; show everything. + val visible = if (st.activeGroup != null) { + allCachedEvents + } else { + val hidden = st.hiddenKeys + val banished = st.banishedKeys + allCachedEvents.filter { ev -> + val key = calendarKey(ev.source, ev.calendarId) + key !in hidden && key !in banished + } } // Skip the state write (and resulting recomposition) when nothing changed. _state.update { if (it.events == visible) it else it.copy(events = visible) } @@ -240,6 +271,7 @@ class CalendarViewModel @Inject constructor( fun syncWithServer() { invalidateCache() + loadGroups() initialLoad() } @@ -275,6 +307,29 @@ class CalendarViewModel @Inject constructor( refreshFromCache() } + // ---- Groups ---- + + fun loadGroups() { + viewModelScope.launch { + runCatching { repository.getGroups() } + .onSuccess { gs -> + _state.update { st -> + // If the active group was deleted elsewhere, drop back to personal. + val stillActive = st.activeGroup?.let { a -> gs.firstOrNull { it.id == a.id } } + st.copy(groups = gs, activeGroup = stillActive) + } + } + } + } + + /** Flip between personal and a group's combined overlay; reloads the wide window. */ + fun switchGroup(group: Group?) { + if (_state.value.activeGroup?.id == group?.id) return + _state.update { it.copy(activeGroup = group) } + invalidateCache() + initialLoad() + } + // ---- Writable calendars ---- fun loadWritableCalendars() { diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt new file mode 100644 index 0000000..69f2c99 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt @@ -0,0 +1,298 @@ +package com.scarriffle.calendarr.ui.groups + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +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.ModalBottomSheet +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.TextButton +import androidx.compose.material3.TopAppBar +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.Alignment +import androidx.compose.ui.Modifier +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.domain.model.Group +import com.scarriffle.calendarr.ui.components.ColorPickerDialog +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex + +private val GROUP_ICONS = listOf( + "👥", "👨‍👩‍👧", "🏠", "❤️", "🧑‍🤝‍🧑", "⚽", "🎓", "💼", "🎉", "🐶", "✈️", "🎵", "🍕", "📚", "🌳", "⭐", +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupsScreen( + onClose: () -> Unit, + onChanged: () -> Unit, + vm: GroupsViewModel = hiltViewModel(), +) { + var createOpen by remember { mutableStateOf(false) } + var manageId by remember { mutableStateOf(null) } + + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(tr("groups.title")) }, + navigationIcon = { + IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) } + }, + actions = { + IconButton(onClick = { createOpen = true }) { Icon(Icons.Filled.Add, contentDescription = tr("groups.create")) } + }, + ) + }, + ) { padding -> + if (vm.loading) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + return@Scaffold + } + LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { + if (vm.groups.isEmpty()) { + item { + Text(tr("groups.empty"), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp)) + } + } + items(vm.groups, key = { it.id }) { g -> + Row( + Modifier.fillMaxWidth().clickable { manageId = g.id }.padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(g.icon ?: "👥", style = MaterialTheme.typography.titleMedium) + Text(g.name, modifier = Modifier.weight(1f).padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge) + Text( + tr("groups.member_count", g.memberCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Divider() + } + vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } } + } + } + } + + if (createOpen) { + GroupEditSheet( + vm = vm, + existing = null, + onDismiss = { createOpen = false }, + onSaved = { createOpen = false; onChanged() }, + ) + } + + manageId?.let { id -> + val existing = vm.groups.firstOrNull { it.id == id } + GroupEditSheet( + vm = vm, + existing = existing, + onDismiss = { manageId = null }, + onSaved = { manageId = null; onChanged() }, + ) + } +} + +/** Create (existing == null) or manage (existing != null) a group. */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun GroupEditSheet( + vm: GroupsViewModel, + existing: Group?, + onDismiss: () -> Unit, + onSaved: () -> Unit, +) { + val me = vm.currentUserId + var name by remember { mutableStateOf(existing?.name ?: "") } + var icon by remember { mutableStateOf(existing?.icon ?: "👥") } + var selected by remember { mutableStateOf(setOf()) } + var existingMembers by remember { mutableStateOf(setOf()) } + var detail by remember { mutableStateOf(null) } + var loaded by remember { mutableStateOf(existing == null) } + var confirmDelete by remember { mutableStateOf(false) } + var memberColorTarget by remember { mutableStateOf?>(null) } // userId, current hex + + // Manage: load full details (members + colours) and pre-fill selection. + LaunchedEffect(existing?.id) { + val id = existing?.id ?: return@LaunchedEffect + val g = vm.groupDetail(id) + detail = g + if (g != null) { + name = g.name + icon = g.icon ?: "👥" + val members = g.members.map { it.id }.filter { it != me }.toSet() + existingMembers = members + selected = members + } + loaded = true + } + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState())) { + Text( + if (existing == null) tr("groups.create") else tr("groups.manage"), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.size(16.dp)) + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(tr("groups.name")) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(16.dp)) + + Text(tr("groups.icon"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.size(8.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + GROUP_ICONS.forEach { ic -> + val sel = ic == icon + Box( + Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)) + .background(if (sel) MaterialTheme.colorScheme.primary.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceVariant) + .clickable { icon = ic }, + contentAlignment = Alignment.Center, + ) { + Text(ic, style = MaterialTheme.typography.titleLarge) + } + } + } + Spacer(Modifier.size(16.dp)) + + Text(tr("groups.members"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + if (!loaded) { + Box(Modifier.fillMaxWidth().padding(12.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(22.dp), strokeWidth = 2.dp) } + } else { + vm.directory.forEach { u -> + Row( + Modifier.fillMaxWidth().clickable { + selected = if (u.id in selected) selected - u.id else selected + u.id + }.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = u.id in selected, onCheckedChange = { + selected = if (u.id in selected) selected - u.id else selected + u.id + }) + Text(u.displayName, modifier = Modifier.padding(start = 4.dp)) + } + } + } + + // Member colours (manage only) + detail?.members?.takeIf { it.isNotEmpty() }?.let { members -> + Spacer(Modifier.size(8.dp)) + Divider() + Spacer(Modifier.size(8.dp)) + Text(tr("groups.member_color"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + members.forEach { m -> + val hex = m.color ?: "#4285f4" + Row( + Modifier.fillMaxWidth().clickable { memberColorTarget = m.id to hex }.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(20.dp).clip(CircleShape).background(colorFromHex(hex))) + Text(m.displayName, modifier = Modifier.weight(1f).padding(start = 12.dp)) + } + } + } + + Spacer(Modifier.size(20.dp)) + Button(onClick = { + val n = name.trim() + if (n.isEmpty()) return@Button + if (existing == null) { + vm.createGroup(n, icon, selected.toList()) { onSaved() } + } else { + vm.saveGroup(existing.id, n, icon, selected, existingMembers) { onSaved() } + } + }, enabled = name.isNotBlank(), modifier = Modifier.fillMaxWidth()) { + Text(tr("event.save")) + } + + if (existing != null) { + Spacer(Modifier.size(8.dp)) + OutlinedButton( + onClick = { confirmDelete = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) + Spacer(Modifier.size(8.dp)) + Text(tr("groups.delete"), color = MaterialTheme.colorScheme.error) + } + } + } + } + + memberColorTarget?.let { (userId, hex) -> + ColorPickerDialog( + initial = hex, + title = tr("groups.member_color"), + onDismiss = { memberColorTarget = null }, + onConfirm = { picked -> + existing?.let { vm.setMemberColor(it.id, userId, picked) } + // reflect locally + detail = detail?.let { d -> d.copy(members = d.members.map { if (it.id == userId) it.copy(color = picked) else it }) } + memberColorTarget = null + }, + ) + } + + if (confirmDelete && existing != null) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + title = { Text(tr("groups.delete")) }, + text = { Text(tr("groups.delete_confirm")) }, + confirmButton = { + TextButton(onClick = { confirmDelete = false; vm.deleteGroup(existing.id) { onSaved() } }) { + Text(tr("groups.delete"), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text(tr("common.cancel")) } }, + ) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsViewModel.kt new file mode 100644 index 0000000..895005c --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsViewModel.kt @@ -0,0 +1,77 @@ +package com.scarriffle.calendarr.ui.groups + +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.domain.model.DirectoryUser +import com.scarriffle.calendarr.domain.model.Group +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class GroupsViewModel @Inject constructor( + private val repository: CalendarRepository, +) : ViewModel() { + + var loading by mutableStateOf(true) + private set + var groups by mutableStateOf>(emptyList()) + private set + var directory by mutableStateOf>(emptyList()) + private set + var error by mutableStateOf(null) + private set + + val currentUserId: Int get() = repository.currentUserId + + init { load() } + + fun load() { + viewModelScope.launch { + loading = true + error = null + groups = runCatching { repository.getGroups() }.getOrDefault(emptyList()) + directory = runCatching { repository.getUserDirectory() }.getOrDefault(emptyList()) + loading = false + } + } + + suspend fun groupDetail(id: Int): Group? = runCatching { repository.getGroup(id) }.getOrNull() + + fun createGroup(name: String, icon: String, memberIds: List, onDone: () -> Unit) { + viewModelScope.launch { + runCatching { repository.createGroup(name, memberIds, icon) } + .onSuccess { load(); onDone() } + .onFailure { error = it.message } + } + } + + /** Save name/icon, then reconcile membership against [existingMemberIds]. */ + fun saveGroup(id: Int, name: String, icon: String, desired: Set, existing: Set, onDone: () -> Unit) { + viewModelScope.launch { + runCatching { + repository.updateGroup(id, name, icon) + for (uid in desired - existing) repository.addGroupMember(id, uid) + for (uid in existing - desired) repository.removeGroupMember(id, uid) + } + .onSuccess { load(); onDone() } + .onFailure { error = it.message } + } + } + + fun setMemberColor(groupId: Int, userId: Int, color: String) { + viewModelScope.launch { runCatching { repository.setGroupMemberColor(groupId, userId, color) } } + } + + fun deleteGroup(id: Int, onDone: () -> Unit) { + viewModelScope.launch { + runCatching { repository.deleteGroup(id) } + .onSuccess { load(); onDone() } + .onFailure { error = it.message } + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt index 2f05481..20f6f16 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt @@ -13,6 +13,7 @@ import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Logout import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material3.Divider @@ -37,6 +38,7 @@ fun MenuSheet( onProfile: () -> Unit, onAppearance: () -> Unit, onAccounts: () -> Unit, + onGroups: () -> Unit, onSync: () -> Unit, onLogout: () -> Unit, onSwitchServer: () -> Unit, @@ -52,6 +54,7 @@ fun MenuSheet( MenuRow(Icons.Filled.AccountCircle, tr("menu.profile"), onProfile) MenuRow(Icons.Filled.Palette, tr("menu.appearance"), onAppearance) MenuRow(Icons.Filled.CalendarMonth, tr("menu.accounts"), onAccounts) + MenuRow(Icons.Filled.People, tr("menu.groups"), onGroups) Divider(Modifier.padding(vertical = 4.dp)) MenuRow(Icons.Filled.Sync, tr("menu.sync"), onSync) Divider(Modifier.padding(vertical = 4.dp)) From 7a9bdba5570db062fad172a7bbd076243bb35f68 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 23:08:42 +0200 Subject: [PATCH 18/59] =?UTF-8?q?fix:=20Android=20Gruppen-Umschalter=20sic?= =?UTF-8?q?htbar=20+=20Men=C3=BC=20=C3=BCberlappt=20Navigationsleiste=20ni?= =?UTF-8?q?cht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-Bar-Gruppenumschalter ist jetzt eine tonale Pille (Akzentfarbe im Gruppenmodus) statt eines flachen Icons – klar als Button erkennbar; das Dropdown markiert die aktive Auswahl. Gruppen-Verwaltung: Tipp auf eine Gruppe öffnet direkt deren Ansicht, Verwalten liegt aufs Zahnrad. Menü-Bottom-Sheet respektiert jetzt das Navigationsleisten-Inset (+ Scroll-Fallback), sodass Abmelden/Server wechseln nicht mehr hinter den Android-Buttons liegen. Co-Authored-By: Claude Opus 4.8 --- .../calendarr/ui/calendar/CalendarScreen.kt | 29 ++++++++++++++++--- .../calendarr/ui/groups/GroupsScreen.kt | 23 ++++++++++----- .../scarriffle/calendarr/ui/menu/MenuSheet.kt | 12 +++++++- 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index 797e527..4e930f1 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -5,6 +5,8 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -16,6 +18,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.FilterList @@ -36,6 +39,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -45,6 +49,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -243,6 +248,7 @@ fun CalendarScreen( Overlay.GROUPS -> GroupsScreen( onClose = { overlay = Overlay.NONE }, onChanged = { vm.loadGroups(); vm.loadWritableCalendars() }, + onOpenGroupView = { g -> overlay = Overlay.NONE; vm.switchGroup(g) }, ) Overlay.NONE -> Unit } @@ -380,27 +386,42 @@ private fun CompactIcon( } } -/** Top-bar switcher: "My calendar" + each group; flips the calendar into the group overlay. */ +/** + * Top-bar switcher: "My calendar" + each group; flips the calendar into the + * group overlay. Rendered as a tonal pill so it stands out from the flat icons + * (filled in the accent colour while a group overlay is active). + */ @Composable private fun GroupSwitcher(groups: List, activeGroup: Group?, onSwitchGroup: (Group?) -> Unit) { var open by remember { mutableStateOf(false) } + val active = activeGroup != null Box { - IconButton(onClick = { open = true }, modifier = Modifier.size(40.dp)) { + Box( + Modifier + .padding(horizontal = 2.dp) + .size(38.dp) + .clip(CircleShape) + .background(if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant) + .clickable { open = true }, + contentAlignment = Alignment.Center, + ) { Icon( Icons.Filled.People, contentDescription = tr("groups.title"), - modifier = Modifier.size(22.dp), - tint = if (activeGroup != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(21.dp), + tint = if (active) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, ) } DropdownMenu(expanded = open, onDismissRequest = { open = false }) { DropdownMenuItem( text = { Text(tr("group.switch.personal")) }, + trailingIcon = { if (!active) Icon(Icons.Filled.Check, contentDescription = null) }, onClick = { open = false; onSwitchGroup(null) }, ) groups.forEach { g -> DropdownMenuItem( text = { Text("${g.icon ?: "👥"} ${g.name}") }, + trailingIcon = { if (activeGroup?.id == g.id) Icon(Icons.Filled.Check, contentDescription = null) }, onClick = { open = false; onSwitchGroup(g) }, ) } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt index 69f2c99..75b9c33 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt @@ -21,8 +21,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Checkbox @@ -66,6 +68,7 @@ private val GROUP_ICONS = listOf( fun GroupsScreen( onClose: () -> Unit, onChanged: () -> Unit, + onOpenGroupView: (Group) -> Unit = {}, vm: GroupsViewModel = hiltViewModel(), ) { var createOpen by remember { mutableStateOf(false) } @@ -97,16 +100,22 @@ fun GroupsScreen( } items(vm.groups, key = { it.id }) { g -> Row( - Modifier.fillMaxWidth().clickable { manageId = g.id }.padding(vertical = 12.dp), + Modifier.fillMaxWidth().clickable { onOpenGroupView(g) }.padding(vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { Text(g.icon ?: "👥", style = MaterialTheme.typography.titleMedium) - Text(g.name, modifier = Modifier.weight(1f).padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge) - Text( - tr("groups.member_count", g.memberCount), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Column(Modifier.weight(1f).padding(start = 12.dp)) { + Text(g.name, style = MaterialTheme.typography.bodyLarge) + Text( + tr("groups.member_count", g.memberCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = { manageId = g.id }) { + Icon(Icons.Filled.Tune, contentDescription = tr("groups.manage"), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + Icon(Icons.Filled.ChevronRight, contentDescription = tr("groups.view"), tint = MaterialTheme.colorScheme.onSurfaceVariant) } Divider() } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt index 20f6f16..a95b242 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt @@ -6,8 +6,11 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Dns @@ -44,7 +47,14 @@ fun MenuSheet( onSwitchServer: () -> Unit, ) { ModalBottomSheet(onDismissRequest = onDismiss) { - Column(Modifier.fillMaxWidth().padding(bottom = 24.dp)) { + // Inset for the system navigation bar so the last rows aren't hidden + // behind the Android nav buttons; scroll as a fallback on short screens. + Column( + Modifier.fillMaxWidth() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(bottom = 12.dp), + ) { Text( "Calendarr", style = MaterialTheme.typography.titleLarge, From 87fc8df1462b9bcc0b6b991dd6f16b014e3ca1e4 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Mon, 1 Jun 2026 17:54:23 +0200 Subject: [PATCH 19/59] feat: render server display_title for group events (consistent across clients) CalEvent parses display_title; decorateGroup uses it (group icon + owner prefix from the server) instead of the hardcoded glyph, with a fallback for older servers. Raw title kept for editing. Co-Authored-By: Claude Opus 4.8 --- .../java/com/scarriffle/calendarr/domain/model/CalEvent.kt | 4 ++++ .../com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt index e5ccd8c..f90d9a2 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt @@ -31,6 +31,9 @@ data class CalEvent( val owner: EventPerson? = null, val isGroupEvent: Boolean = false, val displayColor: String? = null, + // Server-decorated title for the group combined view (group icon / owner + // prefix); rendered in group mode while `title` stays raw for editing. + val displayTitle: String? = null, ) { /** * Group view supplies a server-resolved colour (display_color); otherwise @@ -117,6 +120,7 @@ data class CalEvent( owner = personFrom(json, "owner"), isGroupEvent = json.optBoolean("is_group_event", false), displayColor = json.strOrNull("display_color"), + displayTitle = json.strOrNull("display_title"), ) } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index dbc215a..9133813 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -215,6 +215,10 @@ class CalendarViewModel @Inject constructor( private fun decorateGroup(events: List): List { val me = currentUserId return events.map { ev -> + // Prefer the server-decorated title (group icon + owner prefix) so + // web, iOS and Android render identically; fall back for old servers. + val serverTitle = ev.displayTitle?.takeIf { it.isNotEmpty() } + if (serverTitle != null) return@map ev.copy(title = serverTitle) val prefix = when { ev.isGroupEvent && ev.creator != null && ev.creator.id != me -> "👥 ${firstName(ev.creator.displayName)}: " ev.isGroupEvent -> "👥 " From 644b53210498937f21fa0c1cdba4e1454d291903 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Mon, 1 Jun 2026 18:10:11 +0200 Subject: [PATCH 20/59] feat: hide individual member calendars in the group view (Android) The calendar filter, in group mode, lists the group's members (+ the shared group calendar) with toggles to hide each individually. Filtering is client-side via CalendarViewModel.hiddenGroupKeys (gm: / gc), reset on group switch; members + colours loaded from the group detail. Co-Authored-By: Claude Opus 4.8 --- .../ui/calendar/CalendarFilterSheet.kt | 32 ++++++++++--- .../ui/calendar/CalendarViewModel.kt | 47 +++++++++++++++++-- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt index b84a831..3a7df34 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt @@ -36,6 +36,20 @@ fun CalendarFilterSheet( onDismiss: () -> Unit, ) { val state by vm.state.collectAsState() + val groupMode = state.activeGroup != null + + // In group mode the filter lists members (+ the group calendar) so they can + // be hidden individually, Outlook-style; otherwise the normal calendars. + val groupEntries: List = if (groupMode) { + buildList { + state.activeGroupMembers.forEach { m -> + add(CalendarFilterEntry(groupMemberKey(m.id), m.displayName, m.color ?: "#4285f4")) + } + add(CalendarFilterEntry(GROUP_CALENDAR_KEY, tr("groups.calendar"), state.activeGroup?.groupCalendarColor ?: "#4285f4")) + } + } else emptyList() + val rows = if (groupMode) groupEntries else events + val hiddenSet = if (groupMode) state.hiddenGroupKeys else state.hiddenKeys ModalBottomSheet(onDismissRequest = onDismiss) { Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { @@ -46,21 +60,24 @@ fun CalendarFilterSheet( ) { Text(tr("filter.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) Row { - TextButton(onClick = { vm.setHiddenCalendars(emptySet()) }) { Text(tr("filter.show_all")) } TextButton(onClick = { - vm.setHiddenCalendars(events.map { it.key }.toSet()) + if (groupMode) vm.setHiddenGroupKeys(emptySet()) else vm.setHiddenCalendars(emptySet()) + }) { Text(tr("filter.show_all")) } + TextButton(onClick = { + if (groupMode) vm.setHiddenGroupKeys(rows.map { it.key }.toSet()) + else vm.setHiddenCalendars(rows.map { it.key }.toSet()) }) { Text(tr("filter.hide_all")) } } } - if (events.isEmpty()) { + if (rows.isEmpty()) { Text( tr("filter.empty"), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 24.dp), ) } - events.forEach { entry -> - val visible = entry.key !in state.hiddenKeys + rows.forEach { entry -> + val visible = entry.key !in hiddenSet Row( Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, @@ -73,7 +90,10 @@ fun CalendarFilterSheet( ) Switch( checked = visible, - onCheckedChange = { vm.setCalendarHidden(entry.key, hidden = !it) }, + onCheckedChange = { + if (groupMode) vm.setGroupKeyHidden(entry.key, hidden = !it) + else vm.setCalendarHidden(entry.key, hidden = !it) + }, ) } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index 9133813..bfc4daa 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -7,6 +7,7 @@ import com.scarriffle.calendarr.data.SettingsStore import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.domain.model.Group +import com.scarriffle.calendarr.domain.model.GroupMember import com.scarriffle.calendarr.domain.model.WritableCalendar import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -42,8 +43,15 @@ data class CalendarUiState( // Group overlay: when non-null the calendar shows the group's combined view. val groups: List = emptyList(), val activeGroup: Group? = null, + // Group overlay: full member list (for the filter) + per-member / group-cal + // hidden keys ("gm:" / "gc"). In-memory; reset when switching group. + val activeGroupMembers: List = emptyList(), + val hiddenGroupKeys: Set = emptySet(), ) +fun groupMemberKey(ownerId: Int): String = "gm:$ownerId" +const val GROUP_CALENDAR_KEY = "gc" + @HiltViewModel class CalendarViewModel @Inject constructor( private val repository: CalendarRepository, @@ -252,9 +260,18 @@ class CalendarViewModel @Inject constructor( private fun refreshFromCache() { val st = _state.value - // In group mode the server already scopes + filters; show everything. + // In group mode: server scopes/filters by privacy; locally honour the + // per-member / group-calendar hide toggles (hiddenGroupKeys). val visible = if (st.activeGroup != null) { - allCachedEvents + val hg = st.hiddenGroupKeys + if (hg.isEmpty()) allCachedEvents + else allCachedEvents.filter { ev -> + when { + ev.isGroupEvent -> GROUP_CALENDAR_KEY !in hg + ev.owner != null -> groupMemberKey(ev.owner.id ?: -1) !in hg + else -> true + } + } } else { val hidden = st.hiddenKeys val banished = st.banishedKeys @@ -329,9 +346,33 @@ class CalendarViewModel @Inject constructor( /** Flip between personal and a group's combined overlay; reloads the wide window. */ fun switchGroup(group: Group?) { if (_state.value.activeGroup?.id == group?.id) return - _state.update { it.copy(activeGroup = group) } + _state.update { + it.copy(activeGroup = group, hiddenGroupKeys = emptySet(), activeGroupMembers = emptyList()) + } invalidateCache() initialLoad() + // Load the full member list (with server colours) for the filter sheet. + if (group != null) { + viewModelScope.launch { + runCatching { repository.getGroup(group.id) } + .onSuccess { g -> _state.update { it.copy(activeGroupMembers = g.members) } } + } + } + } + + /** Toggle a single member's calendar / the group calendar in the overlay. */ + fun setGroupKeyHidden(key: String, hidden: Boolean) { + _state.update { + val next = it.hiddenGroupKeys.toMutableSet().apply { if (hidden) add(key) else remove(key) } + it.copy(hiddenGroupKeys = next) + } + refreshFromCache() + } + + /** Replace the group-overlay hidden set (bulk show/hide all). */ + fun setHiddenGroupKeys(keys: Set) { + _state.update { it.copy(hiddenGroupKeys = keys) } + refreshFromCache() } // ---- Writable calendars ---- From 807db6a57bc0b5e0fb0d93a7e637763489245f57 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Mon, 1 Jun 2026 20:24:48 +0200 Subject: [PATCH 21/59] feat: non-emoji group icons (Material icons) for consistent look (Android) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group icons are semantic keys rendered as native Material icons (GroupIcons) in the picker, group list, top-bar switcher and banner — mirroring iOS/web — instead of OS emoji. Legacy emoji values render as a fallback. decorateGroup fallback no longer prepends a glyph (server display_title is authoritative). Co-Authored-By: Claude Opus 4.8 --- .../calendarr/ui/calendar/CalendarScreen.kt | 7 +- .../ui/calendar/CalendarViewModel.kt | 3 +- .../calendarr/ui/groups/GroupsScreen.kt | 73 +++++++++++++++++-- 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index 4e930f1..b0af4a9 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -64,6 +64,7 @@ import com.scarriffle.calendarr.ui.accounts.AccountsScreen import com.scarriffle.calendarr.domain.model.Group import com.scarriffle.calendarr.ui.event.EventDetailScreen import com.scarriffle.calendarr.ui.event.EventEditorSheet +import com.scarriffle.calendarr.ui.groups.GroupIcon import com.scarriffle.calendarr.ui.groups.GroupsScreen import com.scarriffle.calendarr.ui.menu.MenuSheet import com.scarriffle.calendarr.ui.profile.ProfileScreen @@ -420,7 +421,8 @@ private fun GroupSwitcher(groups: List, activeGroup: Group?, onSwitchGrou ) groups.forEach { g -> DropdownMenuItem( - text = { Text("${g.icon ?: "👥"} ${g.name}") }, + text = { Text(g.name) }, + leadingIcon = { GroupIcon(g.icon) }, trailingIcon = { if (activeGroup?.id == g.id) Icon(Icons.Filled.Check, contentDescription = null) }, onClick = { open = false; onSwitchGroup(g) }, ) @@ -436,8 +438,9 @@ private fun GroupBanner(group: Group, onExit: () -> Unit) { Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { + GroupIcon(group.icon, modifier = Modifier.padding(end = 6.dp)) Text( - "${tr("groups.view")}: ${group.icon ?: "👥"} ${group.name}", + "${tr("groups.view")}: ${group.name}", modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index bfc4daa..eb4b5fe 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -228,8 +228,7 @@ class CalendarViewModel @Inject constructor( val serverTitle = ev.displayTitle?.takeIf { it.isNotEmpty() } if (serverTitle != null) return@map ev.copy(title = serverTitle) val prefix = when { - ev.isGroupEvent && ev.creator != null && ev.creator.id != me -> "👥 ${firstName(ev.creator.displayName)}: " - ev.isGroupEvent -> "👥 " + ev.isGroupEvent && ev.creator != null && ev.creator.id != me -> "${firstName(ev.creator.displayName)}: " ev.owner != null && ev.owner.id != me -> "${firstName(ev.owner.displayName)}: " else -> "" } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt index 75b9c33..52f4503 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt @@ -21,10 +21,22 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Celebration import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DirectionsRun +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Flight +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.Pets +import androidx.compose.material.icons.filled.Restaurant +import androidx.compose.material.icons.filled.School +import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.Work import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Checkbox @@ -51,6 +63,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -59,9 +72,49 @@ import com.scarriffle.calendarr.ui.components.ColorPickerDialog import com.scarriffle.calendarr.ui.tr import com.scarriffle.calendarr.util.colorFromHex -private val GROUP_ICONS = listOf( - "👥", "👨‍👩‍👧", "🏠", "❤️", "🧑‍🤝‍🧑", "⚽", "🎓", "💼", "🎉", "🐶", "✈️", "🎵", "🍕", "📚", "🌳", "⭐", -) +/** + * Cross-platform group-icon keys (stored server-side) rendered as native + * Material icons — consistent everywhere instead of OS-emoji that vary by + * platform. Mirrors iOS GroupIcons / the web SVG set. + */ +object GroupIcons { + val keys = listOf( + "people", "home", "heart", "work", "school", "sports", + "party", "pet", "travel", "music", "food", "star", + ) + + fun vector(key: String?): ImageVector = when (key) { + "people" -> Icons.Filled.People + "home" -> Icons.Filled.Home + "heart" -> Icons.Filled.Favorite + "work" -> Icons.Filled.Work + "school" -> Icons.Filled.School + "sports" -> Icons.Filled.DirectionsRun + "party" -> Icons.Filled.Celebration + "pet" -> Icons.Filled.Pets + "travel" -> Icons.Filled.Flight + "music" -> Icons.Filled.MusicNote + "food" -> Icons.Filled.Restaurant + "star" -> Icons.Filled.Star + else -> Icons.Filled.People + } + + fun isKey(s: String?): Boolean = s != null && s in keys +} + +/** Render a group's icon: native Material icon for keys, legacy emoji fallback. */ +@Composable +fun GroupIcon(icon: String?, modifier: Modifier = Modifier, tint: androidx.compose.ui.graphics.Color? = null) { + if (GroupIcons.isKey(icon)) { + Icon(GroupIcons.vector(icon), contentDescription = null, modifier = modifier, + tint = tint ?: androidx.compose.material3.LocalContentColor.current) + } else if (!icon.isNullOrEmpty()) { + Text(icon, modifier = modifier) + } else { + Icon(Icons.Filled.People, contentDescription = null, modifier = modifier, + tint = tint ?: androidx.compose.material3.LocalContentColor.current) + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -103,7 +156,7 @@ fun GroupsScreen( Modifier.fillMaxWidth().clickable { onOpenGroupView(g) }.padding(vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text(g.icon ?: "👥", style = MaterialTheme.typography.titleMedium) + GroupIcon(g.icon, tint = MaterialTheme.colorScheme.onSurface) Column(Modifier.weight(1f).padding(start = 12.dp)) { Text(g.name, style = MaterialTheme.typography.bodyLarge) Text( @@ -155,7 +208,7 @@ private fun GroupEditSheet( ) { val me = vm.currentUserId var name by remember { mutableStateOf(existing?.name ?: "") } - var icon by remember { mutableStateOf(existing?.icon ?: "👥") } + var icon by remember { mutableStateOf(if (GroupIcons.isKey(existing?.icon)) existing!!.icon!! else "people") } var selected by remember { mutableStateOf(setOf()) } var existingMembers by remember { mutableStateOf(setOf()) } var detail by remember { mutableStateOf(null) } @@ -170,7 +223,7 @@ private fun GroupEditSheet( detail = g if (g != null) { name = g.name - icon = g.icon ?: "👥" + icon = if (GroupIcons.isKey(g.icon)) g.icon!! else "people" val members = g.members.map { it.id }.filter { it != me }.toSet() existingMembers = members selected = members @@ -199,7 +252,7 @@ private fun GroupEditSheet( Text(tr("groups.icon"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) Spacer(Modifier.size(8.dp)) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - GROUP_ICONS.forEach { ic -> + GroupIcons.keys.forEach { ic -> val sel = ic == icon Box( Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)) @@ -207,7 +260,11 @@ private fun GroupEditSheet( .clickable { icon = ic }, contentAlignment = Alignment.Center, ) { - Text(ic, style = MaterialTheme.typography.titleLarge) + Icon( + GroupIcons.vector(ic), + contentDescription = ic, + tint = if (sel) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } From 6654012cbb350fcb37de88075294eb69de5ebed1 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sat, 6 Jun 2026 16:31:32 +0200 Subject: [PATCH 22/59] =?UTF-8?q?feat:=20reminders=20data=20layer=20(Andro?= =?UTF-8?q?id)=20=E2=80=94=20model,=20settings,=20repository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CalEvent gains `reminders` (parsed from the server); AppSettings/SettingsStore gain `defaultReminderMinutes` (null = off) and it's sent via updateSettings; createLocalEvent/updateLocalEvent/eventBody and CalendarViewModel.saveEvent thread `reminders` through. UI (editor + settings picker) and the local notification scheduling follow next. Co-Authored-By: Claude Opus 4.8 --- .../scarriffle/calendarr/data/CalendarRepository.kt | 13 ++++++++----- .../com/scarriffle/calendarr/data/SettingsStore.kt | 3 +++ .../calendarr/domain/model/AppSettings.kt | 2 ++ .../scarriffle/calendarr/domain/model/CalEvent.kt | 5 +++++ .../calendarr/ui/calendar/CalendarViewModel.kt | 10 ++++++---- 5 files changed, 24 insertions(+), 9 deletions(-) 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 97d8672..7bd60b6 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -127,6 +127,8 @@ class CalendarRepository @Inject constructor( "month_divider_color" to s.monthDividerColor, "month_label_color" to s.monthLabelColor, "private_event_visibility" to s.privateEventVisibility, + // Explicit JSON null clears it (off); jsonBody drops Kotlin nulls. + "default_reminder_minutes" to (s.defaultReminderMinutes ?: org.json.JSONObject.NULL), ) ).ensureSuccess() } @@ -298,18 +300,18 @@ class CalendarRepository @Inject constructor( suspend fun createLocalEvent( calendarId: Int, title: String, start: Instant, end: Instant, isAllDay: Boolean, location: String, description: String, color: String?, - isPrivate: Boolean = false, + isPrivate: Boolean = false, reminders: List? = null, ) = guarded { - api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate)) + api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders)) .ensureSuccess() } suspend fun updateLocalEvent( uid: String, title: String, start: Instant, end: Instant, isAllDay: Boolean, location: String, description: String, color: String?, - isPrivate: Boolean = false, + isPrivate: Boolean = false, reminders: List? = null, ) = guarded { - api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate)) + api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate, reminders)) .ensureSuccess() } @@ -555,7 +557,7 @@ class CalendarRepository @Inject constructor( private fun eventBody( calendarId: Int?, title: String, start: Instant, end: Instant, isAllDay: Boolean, location: String, description: String, color: String?, - isPrivate: Boolean = false, + isPrivate: Boolean = false, reminders: List? = null, ) = jsonBody( buildMap { calendarId?.let { put("calendar_id", it) } @@ -567,6 +569,7 @@ class CalendarRepository @Inject constructor( put("description", description) if (!color.isNullOrBlank()) put("color", color) put("private", isPrivate) + if (reminders != null) put("reminders", org.json.JSONArray(reminders)) } ) } diff --git a/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt b/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt index cf7ab91..1198309 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt @@ -34,6 +34,7 @@ class SettingsStore @Inject constructor( language = prefs.getString(K_LANGUAGE, null) ?: "de", monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0", monthLabelColor = prefs.getString(K_LABEL, null) ?: "#7090c0", + defaultReminderMinutes = prefs.getInt(K_DEFAULT_REMINDER, -1).takeIf { it >= 0 }, ) fun saveSettings(s: AppSettings) { @@ -50,6 +51,7 @@ class SettingsStore @Inject constructor( .putString(K_LANGUAGE, s.language) .putString(K_DIVIDER, s.monthDividerColor) .putString(K_LABEL, s.monthLabelColor) + .putInt(K_DEFAULT_REMINDER, s.defaultReminderMinutes ?: -1) .apply() } @@ -83,6 +85,7 @@ class SettingsStore @Inject constructor( const val K_LANGUAGE = "language" const val K_DIVIDER = "month_divider_color" const val K_LABEL = "month_label_color" + const val K_DEFAULT_REMINDER = "default_reminder_minutes" const val K_CACHE_MONTHS = "cache_months" const val K_HIDDEN = "hidden_calendar_keys" const val K_BANISHED = "banished_calendar_keys" diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt index 7f6aad5..c908688 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt @@ -25,6 +25,8 @@ data class AppSettings( // How this user's private events appear to other group members: 'hidden' | 'busy'. @Json(name = "private_event_visibility") val privateEventVisibility: String = "busy", @Json(name = "group_visible_calendar_id") val groupVisibleCalendarId: Int? = null, + // Minutes-before-start applied to all events client-side; null = off. + @Json(name = "default_reminder_minutes") val defaultReminderMinutes: Int? = null, ) { val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday" } diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt index f90d9a2..5664219 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt @@ -34,6 +34,8 @@ data class CalEvent( // Server-decorated title for the group combined view (group icon / owner // prefix); rendered in group mode while `title` stays raw for editing. val displayTitle: String? = null, + // Reminder offsets in minutes-before-start (0 = at start). Local events only. + val reminders: List = emptyList(), ) { /** * Group view supplies a server-resolved colour (display_color); otherwise @@ -121,6 +123,9 @@ data class CalEvent( isGroupEvent = json.optBoolean("is_group_event", false), displayColor = json.strOrNull("display_color"), displayTitle = json.strOrNull("display_title"), + reminders = json.optJSONArray("reminders")?.let { arr -> + (0 until arr.length()).mapNotNull { (arr.opt(it) as? Number)?.toInt() } + } ?: emptyList(), ) } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index eb4b5fe..3cb86a3 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -401,20 +401,21 @@ class CalendarViewModel @Inject constructor( description: String, color: String?, isPrivate: Boolean, + reminders: List = emptyList(), onResult: (String?) -> Unit, ) { viewModelScope.launch { val result = runCatching { if (existing != null && existing.source == calendar.source) { when (existing.source) { - "local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate) + "local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate, reminders) "caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color) "homeassistant" -> repository.updateHAEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description) "google" -> repository.updateGoogleEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description) - else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate) + else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders) } } else { - createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate) + createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders) } } result.onSuccess { afterMutation(); onResult(null) } @@ -425,9 +426,10 @@ class CalendarViewModel @Inject constructor( private suspend fun createForSource( calendar: WritableCalendar, title: String, start: Instant, end: Instant, isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean, + reminders: List = emptyList(), ) { when (calendar.source) { - "local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate) + "local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate, reminders) "caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color) "google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description) "homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description) From e7bed5c5a80b3c80f8de431998ff07c7f5a0ef06 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Fri, 12 Jun 2026 20:37:49 +0200 Subject: [PATCH 23/59] feat: day preview popup, smoother month scrolling, UI polish - Long-press on a day now opens a preview popup (ported from the iOS DayContextPreviewView): all-day events as colored bars with tapered ends when they continue beyond the day, timed events as dot+time+title rows, plus quick actions (new event, week view, day view) - Month scroll performance: stable item keys + contentType in the LazyColumn, remembered Instant.now()/theme colors, remembered callbacks so visible week rows stay skippable during recomposition - Fix "+N" and "KW" labels overlapping the lowest event lane (row bottom padding 6dp -> 16dp, mirrors the iOS fix) - Event detail: delete is now a quiet red text button instead of a full-width filled container - Top bar: hairline divider so the bar/calendar boundary is visible - FAB: explicit circular shape Co-Authored-By: Claude Fable 5 --- .../java/com/scarriffle/calendarr/ui/L10n.kt | 6 + .../calendarr/ui/calendar/CalendarScreen.kt | 31 ++- .../calendarr/ui/calendar/DayPreviewDialog.kt | 258 ++++++++++++++++++ .../calendarr/ui/calendar/MonthView.kt | 26 +- .../calendarr/ui/event/EventDetailScreen.kt | 11 +- 5 files changed, 314 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/calendar/DayPreviewDialog.kt diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt index 5ce56ce..df1d32e 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -38,6 +38,9 @@ object L10n { "cal.no_events_title" to "Keine Termine", "cal.no_events_body" to "In den nächsten 90 Tagen sind keine Termine vorhanden.", "cal.loading_more" to "Lade weitere Wochen…", "cal.new_event" to "Neues Ereignis", + "cal.show_in_day_view" to "In Tagesansicht öffnen", + "cal.show_in_week_view" to "In Wochenansicht öffnen", + "cal.no_events_day" to "Keine Termine an diesem Tag", "menu.section.settings" to "Einstellungen", "menu.profile" to "Profil", "menu.appearance" to "Darstellung", "menu.accounts" to "Konten & Kalender", "menu.server" to "Server", "menu.logout" to "Abmelden", "menu.admin" to "Admin", @@ -165,6 +168,9 @@ object L10n { "cal.no_events_title" to "No events", "cal.no_events_body" to "No events in the next 90 days.", "cal.loading_more" to "Loading more weeks…", "cal.new_event" to "New event", + "cal.show_in_day_view" to "Open in day view", + "cal.show_in_week_view" to "Open in week view", + "cal.no_events_day" to "No events on this day", "menu.section.settings" to "Settings", "menu.profile" to "Profile", "menu.appearance" to "Appearance", "menu.accounts" to "Accounts & Calendars", "menu.server" to "Server", "menu.logout" to "Sign out", "menu.admin" to "Admin", diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index b0af4a9..02b8b4a 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Divider import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -94,6 +95,7 @@ fun CalendarScreen( var detailEvent by remember { mutableStateOf(null) } var editor by remember { mutableStateOf(null) } var overlay by remember { mutableStateOf(Overlay.NONE) } + var dayPreview by remember { mutableStateOf(null) } // Continuous month scrolling val monthListState = rememberLazyListState() @@ -137,6 +139,7 @@ fun CalendarScreen( floatingActionButton = { FloatingActionButton( onClick = { editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) }, + shape = CircleShape, containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, ) { @@ -152,6 +155,8 @@ fun CalendarScreen( GroupBanner(group = g, onExit = { vm.switchGroup(null) }) } Box(Modifier.fillMaxSize()) { + // remember: stable callbacks keep the lazily composed week rows + // skippable during scroll (fresh lambdas would recompose them all). CalendarBody( state = state, vm = vm, @@ -159,10 +164,10 @@ fun CalendarScreen( scrollToTodaySignal = todaySignal, monthJumpSignal = monthJumpSignal, monthJumpTarget = monthJumpTarget, - onVisibleMonthChange = { visibleMonth = it }, - onEventClick = { detailEvent = it }, - onDayClick = { date -> vm.goToDate(date, CalViewType.DAY) }, - onDayLongPress = { date -> editor = EditorRequest(null, date) }, + onVisibleMonthChange = remember { { visibleMonth = it } }, + onEventClick = remember { { detailEvent = it } }, + onDayClick = remember(vm) { { date -> vm.goToDate(date, CalViewType.DAY) } }, + onDayLongPress = remember { { date -> dayPreview = date } }, ) } } @@ -192,6 +197,18 @@ fun CalendarScreen( ) } + dayPreview?.let { date -> + DayPreviewDialog( + date = date, + events = remember(state.events, date) { vm.eventsOn(date, state.events) }, + onDismiss = { dayPreview = null }, + onEventClick = { ev -> dayPreview = null; detailEvent = ev }, + onCreateEvent = { dayPreview = null; editor = EditorRequest(null, date) }, + onOpenDay = { dayPreview = null; vm.goToDate(date, CalViewType.DAY) }, + onOpenWeek = { dayPreview = null; vm.goToDate(date, CalViewType.WEEK) }, + ) + } + // Keep the last event during the close animation. var lastDetail by remember { mutableStateOf(null) } detailEvent?.let { lastDetail = it } @@ -330,6 +347,7 @@ private fun CompactTopBar( ) { val twoLine = viewType == CalViewType.WEEK || viewType == CalViewType.DAY Surface(color = MaterialTheme.colorScheme.background) { + Column { Row( Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp), verticalAlignment = Alignment.CenterVertically, @@ -373,6 +391,11 @@ private fun CompactTopBar( } CompactIcon(Icons.Filled.Menu, onMenu, tr("nav.menu")) } + Divider( + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f), + ) + } } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/DayPreviewDialog.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/DayPreviewDialog.kt new file mode 100644 index 0000000..3d47640 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/DayPreviewDialog.kt @@ -0,0 +1,258 @@ +package com.scarriffle.calendarr.ui.calendar + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CalendarViewWeek +import androidx.compose.material.icons.filled.WbSunny +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.ui.L10n +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.contrastingTextColor +import java.time.LocalDate +import java.time.format.TextStyle + +/** + * Long-press day preview (mirrors the iOS DayContextPreviewView): all-day + * events as colored bars with tapered ends when they continue beyond this day, + * timed events as dot + time + title rows, followed by quick actions. + */ +@Composable +fun DayPreviewDialog( + date: LocalDate, + events: List, + onDismiss: () -> Unit, + onEventClick: (CalEvent) -> Unit, + onCreateEvent: () -> Unit, + onOpenDay: () -> Unit, + onOpenWeek: () -> Unit, +) { + val lang = LocalLang.current + val sorted = remember(events) { + events.sortedWith(compareByDescending { it.isAllDay }.thenBy { it.startDate }) + } + val weekdayAbbr = remember(date, lang) { + date.dayOfWeek.getDisplayName(TextStyle.SHORT, L10n.locale(lang)).uppercase() + } + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp, + ) { + Column( + Modifier + .width(290.dp) + .padding(vertical = 14.dp) + .verticalScroll(rememberScrollState()), + ) { + Row( + Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.Bottom, + ) { + Text( + weekdayAbbr, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 6.dp, bottom = 3.dp), + ) + Text( + "${date.dayOfMonth}", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + } + Divider(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) + + if (sorted.isEmpty()) { + Text( + tr("cal.no_events_day"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } else { + sorted.forEach { ev -> + if (ev.isAllDay) { + AllDayPreviewBar(event = ev, date = date, onClick = { onEventClick(ev) }) + } else { + TimedPreviewRow(event = ev, lang = lang, onClick = { onEventClick(ev) }) + } + } + } + + Divider(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) + PreviewAction(Icons.Filled.Add, tr("cal.new_event"), onCreateEvent) + PreviewAction(Icons.Filled.CalendarViewWeek, tr("cal.show_in_week_view"), onOpenWeek) + PreviewAction(Icons.Filled.WbSunny, tr("cal.show_in_day_view"), onOpenDay) + } + } + } +} + +@Composable +private fun AllDayPreviewBar(event: CalEvent, date: LocalDate, onClick: () -> Unit) { + val color = colorFromHex(event.effectiveColor) + val startDay = localDate(event.startDate) + val lastDay = localDate(event.endDate.minusSeconds(1)) // all-day end is exclusive + val cLeft = startDay.isBefore(date) + val cRight = lastDay.isAfter(date) + val shape = remember(cLeft, cRight) { ChevronBarShape(cLeft, cRight) } + + Box( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 2.dp) + .clip(shape) + .background(color) + .clickable(onClick = onClick) + .padding( + start = if (cLeft) 14.dp else 8.dp, + end = if (cRight) 14.dp else 8.dp, + top = 4.dp, + bottom = 4.dp, + ), + ) { + Text( + event.title, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = color.contrastingTextColor(), + ) + } +} + +@Composable +private fun TimedPreviewRow(event: CalEvent, lang: String, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .size(7.dp) + .clip(CircleShape) + .background(colorFromHex(event.effectiveColor)), + ) + Text( + timeLabel(event.startDate, lang), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp).width(42.dp), + ) + Text( + event.title, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun PreviewAction(icon: ImageVector, label: String, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(12.dp)) + Text(label, style = MaterialTheme.typography.bodyMedium) + } +} + +/** + * Bar shape with tapered (pointed) ends on the sides where the event continues + * beyond the shown day — port of the iOS ChevronBarShape. + */ +private class ChevronBarShape( + private val continuesLeft: Boolean, + private val continuesRight: Boolean, +) : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { + val r = with(density) { 4.dp.toPx() } + if (!continuesLeft && !continuesRight) { + return Outline.Rounded(RoundRect(size.toRect(), CornerRadius(r))) + } + val t = with(density) { 8.dp.toPx() }.coerceAtMost(size.width / 2) + val p = Path() + if (continuesLeft && continuesRight) { + p.moveTo(t, 0f) + p.lineTo(size.width - t, 0f) + p.lineTo(size.width, size.height / 2) + p.lineTo(size.width - t, size.height) + p.lineTo(t, size.height) + p.lineTo(0f, size.height / 2) + } else if (continuesLeft) { + p.moveTo(t, 0f) + p.lineTo(size.width, 0f) + p.lineTo(size.width, size.height) + p.lineTo(t, size.height) + p.lineTo(0f, size.height / 2) + } else { + p.moveTo(0f, 0f) + p.lineTo(size.width - t, 0f) + p.lineTo(size.width, size.height / 2) + p.lineTo(size.width - t, size.height) + p.lineTo(0f, size.height) + } + p.close() + return Outline.Generic(p) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt index c25055c..9632d53 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt @@ -62,7 +62,8 @@ private const val MAX_LANES = 4 private val DAY_NUM_H = 22.dp private val LANE_H = 15.dp private val LANE_SPACE = 2.dp -private val ROW_HEIGHT = DAY_NUM_H + (LANE_H + LANE_SPACE) * MAX_LANES + 6.dp +// Bottom strip must fit the 8sp "+N"/"KW" labels without event bars overlapping them. +private val ROW_HEIGHT = DAY_NUM_H + (LANE_H + LANE_SPACE) * MAX_LANES + 16.dp private enum class DividerEdge { NONE, TOP, BOTTOM } @@ -97,11 +98,14 @@ fun MonthView( val mondayFirst = state.weekStartsOnMonday val today = LocalDate.now() - val dividerColor = colorFromHex(settings.monthDividerColor, Color(0xFF7090C0)) - val labelColor = colorFromHex(settings.monthLabelColor, MaterialTheme.colorScheme.onSurfaceVariant) - val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = gridLineOpacity(settings.lineContrast)) - val secondaryText = MaterialTheme.colorScheme.onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast)) - val todayColor = colorFromHex(settings.todayColor) + val fallbackLabel = MaterialTheme.colorScheme.onSurfaceVariant + val outline = MaterialTheme.colorScheme.outline + val onBackground = MaterialTheme.colorScheme.onBackground + val dividerColor = remember(settings.monthDividerColor) { colorFromHex(settings.monthDividerColor, Color(0xFF7090C0)) } + val labelColor = remember(settings.monthLabelColor, fallbackLabel) { colorFromHex(settings.monthLabelColor, fallbackLabel) } + val gridColor = remember(outline, settings.lineContrast) { outline.copy(alpha = gridLineOpacity(settings.lineContrast)) } + val secondaryText = remember(onBackground, settings.textContrast) { onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast)) } + val todayColor = remember(settings.todayColor) { colorFromHex(settings.todayColor) } // Column width measured once → no per-row BoxWithConstraints (smooth scrolling). val density = LocalDensity.current @@ -144,7 +148,10 @@ fun MonthView( val eventsByWeek = remember(state.events, mondayFirst) { buildEventsByWeek(state.events, mondayFirst) } val dimPast = settings.dimPastEvents - val now = java.time.Instant.now() + val cwLabel = tr("cal.cw") + // remember: a fresh Instant per recomposition would invalidate every visible + // WeekRow (the main source of scroll jank); minute precision is plenty here. + val now = remember { java.time.Instant.now() } Column(Modifier.fillMaxSize()) { Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) { @@ -163,7 +170,7 @@ fun MonthView( state = listState, modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width }, ) { - items(weekCount) { index -> + items(weekCount, key = { it }, contentType = { "week" }) { index -> val weekStart = firstVisible.plusWeeks(index.toLong()) WeekRow( weekStart = weekStart, @@ -173,6 +180,7 @@ fun MonthView( dimPast = dimPast, now = now, lang = lang, + cwLabel = cwLabel, dividerColor = dividerColor, gridColor = gridColor, labelColor = labelColor, @@ -196,6 +204,7 @@ private fun WeekRow( dimPast: Boolean, now: java.time.Instant, lang: String, + cwLabel: String, dividerColor: Color, gridColor: Color, labelColor: Color, @@ -208,7 +217,6 @@ private fun WeekRow( val days = remember(weekStart) { (0 until 7).map { weekStart.plusDays(it.toLong()) } } val boundaryCol = (1 until 7).firstOrNull { days[it].dayOfMonth == 1 } val rowStartsNewMonth = days[0].dayOfMonth == 1 - val cwLabel = tr("cal.cw") val packed = remember(weekStart, weekEvents) { packEvents(weekStart, weekEvents) } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt index 59fdb9d..5417794 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.material.icons.filled.Notes import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Schedule import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -126,13 +125,15 @@ fun EventDetailScreen( Text(tr("event.copy_title")) } if (canDelete) { - Spacer(Modifier.height(12.dp)) - Button( + Spacer(Modifier.height(4.dp)) + // Deliberately low-key (plain text button): destructive action + // should be findable, not the loudest element on the screen. + TextButton( onClick = { confirmDelete = true }, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer), + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), modifier = Modifier.fillMaxWidth(), ) { - Icon(Icons.Filled.Delete, contentDescription = null) + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) Text(tr("common.delete")) } From b172a8f0552c4c664df683ebf25d9e229e4e22cc Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Fri, 12 Jun 2026 21:39:03 +0200 Subject: [PATCH 24/59] fix: round dropdown menu corners via theme shapes Material 3 menus use shapes.extraSmall (4dp default) which looked boxy; bump to 14dp so all dropdown popups (view switcher, group switcher, ...) get rounded corners matching the iOS look. Co-Authored-By: Claude Fable 5 --- .../main/java/com/scarriffle/calendarr/ui/theme/Theme.kt | 6 ++++++ 1 file changed, 6 insertions(+) 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 ff7da21..e94dd19 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 @@ -1,10 +1,13 @@ package com.scarriffle.calendarr.ui.theme +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import com.scarriffle.calendarr.domain.model.AppSettings import com.scarriffle.calendarr.util.colorFromHex import com.scarriffle.calendarr.util.contrastingTextColor @@ -53,6 +56,9 @@ fun CalendarrTheme( MaterialTheme( colorScheme = colors, typography = Typography(), + // extraSmall drives DropdownMenu containers (default 4dp looks boxy); + // rounder popups to match the iOS look. + shapes = Shapes(extraSmall = RoundedCornerShape(14.dp)), content = content, ) } From c6056fb7bdaecdd68903793f7712a40662d53bce Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 14 Jun 2026 18:28:19 +0200 Subject: [PATCH 25/59] fix: CircularProgressIndicator no longer clipped in auth buttons Use Modifier.size(20.dp) instead of height(20.dp) so the spinner is constrained on both axes and doesn't get cut off horizontally. Co-Authored-By: Claude Sonnet 4.6 --- .../main/java/com/scarriffle/calendarr/ui/auth/LoginScreen.kt | 3 ++- .../java/com/scarriffle/calendarr/ui/auth/ServerSetupScreen.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 bc0f85d..0fd6a11 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack @@ -102,7 +103,7 @@ fun LoginScreen( modifier = Modifier.fillMaxWidth(), ) { if (vm.loggingIn) { - CircularProgressIndicator(modifier = Modifier.height(20.dp), strokeWidth = 2.dp) + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) } else { Text(tr("auth.login")) } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/auth/ServerSetupScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/auth/ServerSetupScreen.kt index 413e278..e5ec7c9 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/auth/ServerSetupScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/auth/ServerSetupScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CalendarMonth @@ -78,7 +79,7 @@ fun ServerSetupScreen( modifier = Modifier.fillMaxWidth(), ) { if (vm.checking) { - CircularProgressIndicator(modifier = Modifier.height(20.dp), strokeWidth = 2.dp) + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) } else { Text(tr("auth.continue")) } From 981586691102ee6267fabb786414d52f96cd23be Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 14 Jun 2026 18:36:17 +0200 Subject: [PATCH 26/59] fix: add adaptive icon to remove launcher ring effect on Pixel devices Without mipmap-anydpi-v26 XML, Pixel launcher wraps legacy PNGs in a white ring template. Adding adaptive-icon XML with the iOS-matching foreground PNG at correct per-density sizes (108dp canvas) eliminates the nested-ring appearance. Co-Authored-By: Claude Sonnet 4.6 --- .../main/res/mipmap-anydpi-v26/ic_launcher.xml | 5 +++++ .../res/mipmap-anydpi-v26/ic_launcher_round.xml | 5 +++++ .../res/mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 10439 bytes .../res/mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 5716 bytes .../res/mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 14397 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 23919 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 32669 bytes 7 files changed, 10 insertions(+) create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c164fd9 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + 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 new file mode 100644 index 0000000..c164fd9 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..3754c9008ed26d9da4c5010a326bec266003329e GIT binary patch literal 10439 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 @XS@Fw%ghLff3Znff=@YSNxC|HKfxq;g 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 }n{%SSU){VK%`HHWB) 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..61733e376e08c1965959e0f315e680c51351e1b3 GIT binary patch literal 32669 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 Date: Sun, 14 Jun 2026 18:45:39 +0200 Subject: [PATCH 27/59] fix: set solid dark green adaptive icon background to eliminate ring Transparent background caused Pixel launcher to inject its own tinted ring. Replace with #16703D (sampled from iOS icon background) so the launcher shape-clips cleanly without any visible halo. Co-Authored-By: Claude Sonnet 4.6 --- app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml | 2 +- app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml | 2 +- app/src/main/res/values/themes.xml | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index c164fd9..80b730f 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,5 @@ - + 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 index c164fd9..80b730f 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,5 +1,5 @@ - + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 938a0b2..180863e 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,6 @@ #000000 + #16703D