diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index fa4ed94..1d00ed9 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -50,9 +50,8 @@ All V1 features shipped, polished, on F-Droid. Read-only calendar.
Remaining before v1.0: a UI polish/QA pass.
### Polish backlog (pre-1.0)
-- **Redesign the initial grant-access (permission) screen** — the first thing a
- new user sees; the current rationale/permission flow needs a proper Material 3
- Expressive pass to match the rest of the app
+- ~~Redesign the initial grant-access (permission) screen~~ — **done**
+ (Material 3 Expressive onboarding, shipped on the v0.6.0 branch)
## v2.0 — Write Support
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c6d112..6355576 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Changed
+- Redesigned the first-run grant-access screen — the onboarding a new user
+ sees. Material 3 Expressive layout: branded launcher-mark hero, an
+ app-name eyebrow, a benefit-led headline, three trust rows (on-device,
+ every calendar, no tracking) with tonal icon chips, a full-width filled CTA
+ with a trailing arrow, and a "Read-only · no internet permission" footnote
+ (the app declares only `READ_CALENDAR`). The denied/recovery state shares the
+ same shell with a lock-badged hero and Open-settings / Try-again actions
+
## [0.6.0] — 2026-06-11
### Added
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt
index 7673484..d53b008 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt
@@ -6,28 +6,65 @@ import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
+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.ColumnScope
+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.navigationBarsPadding
+import androidx.compose.foundation.layout.offset
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.automirrored.filled.ArrowForward
+import androidx.compose.material.icons.filled.CalendarMonth
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
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.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.compose.foundation.Image
import de.jeanlucmakiola.calendula.R
+// MD3 8dp spacing scale, scoped to this screen.
+private object Space {
+ val xs = 8.dp
+ val sm = 16.dp
+ val md = 24.dp
+ val lg = 32.dp
+ val xl = 48.dp
+}
+
@Composable
fun PermissionScreen(
onGranted: () -> Unit,
@@ -69,24 +106,68 @@ private fun RationaleContent(
onRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
- Column(
- modifier = modifier.fillMaxSize().padding(24.dp),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally,
+ PermissionScaffold(
+ modifier = modifier,
+ hero = { BrandHero(denied = false) },
+ actions = {
+ Button(
+ onClick = onRequest,
+ modifier = Modifier.fillMaxWidth().height(56.dp),
+ contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
+ ) {
+ Text(
+ text = stringResource(R.string.permission_request_button),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ Spacer(Modifier.width(Space.xs))
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowForward,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ PrivacyFootnote()
+ },
) {
+ Text(
+ text = stringResource(R.string.app_name).uppercase(),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ letterSpacing = 2.sp,
+ )
+ Spacer(Modifier.height(Space.xs))
Text(
text = stringResource(R.string.permission_rationale_title),
style = MaterialTheme.typography.headlineMedium,
+ textAlign = TextAlign.Center,
)
- Spacer(Modifier.height(16.dp))
+ Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.permission_rationale_body),
style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(Modifier.height(Space.xl))
+
+ BenefitRow(
+ icon = Icons.Filled.Lock,
+ title = stringResource(R.string.permission_benefit_private_title),
+ body = stringResource(R.string.permission_benefit_private_body),
+ )
+ Spacer(Modifier.height(Space.sm))
+ BenefitRow(
+ icon = Icons.Filled.CalendarMonth,
+ title = stringResource(R.string.permission_benefit_sync_title),
+ body = stringResource(R.string.permission_benefit_sync_body),
+ )
+ Spacer(Modifier.height(Space.sm))
+ BenefitRow(
+ icon = Icons.Filled.VisibilityOff,
+ title = stringResource(R.string.permission_benefit_privacy_title),
+ body = stringResource(R.string.permission_benefit_privacy_body),
)
- Spacer(Modifier.height(32.dp))
- Button(onClick = onRequest) {
- Text(stringResource(R.string.permission_request_button))
- }
}
}
@@ -96,35 +177,182 @@ private fun DeniedContent(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
- Column(
- modifier = modifier.fillMaxSize().padding(24.dp),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally,
+ PermissionScaffold(
+ modifier = modifier,
+ hero = { BrandHero(denied = true) },
+ actions = {
+ Button(
+ onClick = {
+ val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+ data = Uri.fromParts("package", context.packageName, null)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ },
+ modifier = Modifier.fillMaxWidth().height(56.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.permission_open_settings_button),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+ TextButton(
+ onClick = onRetry,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.permission_retry_button))
+ }
+ },
) {
Text(
text = stringResource(R.string.permission_denied_title),
style = MaterialTheme.typography.headlineMedium,
+ textAlign = TextAlign.Center,
)
- Spacer(Modifier.height(16.dp))
+ Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.permission_denied_body),
style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
)
- Spacer(Modifier.height(32.dp))
- Button(onClick = onRetry) {
- Text(stringResource(R.string.permission_retry_button))
- }
- Spacer(Modifier.height(12.dp))
- OutlinedButton(
- onClick = {
- val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
- data = Uri.fromParts("package", context.packageName, null)
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- }
- context.startActivity(intent)
- },
+ }
+}
+
+/**
+ * Shared onboarding shell: a scrollable, centred hero + body with the call(s) to
+ * action pinned to the bottom (clear of the navigation bar). The content slot is
+ * centred horizontally; benefit rows fill the width so their own content
+ * left-aligns.
+ */
+@Composable
+private fun PermissionScaffold(
+ hero: @Composable () -> Unit,
+ actions: @Composable ColumnScope.() -> Unit,
+ modifier: Modifier = Modifier,
+ body: @Composable ColumnScope.() -> Unit,
+) {
+ Scaffold(
+ modifier = modifier,
+ containerColor = MaterialTheme.colorScheme.surface,
+ bottomBar = {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(horizontal = Space.md, vertical = Space.sm),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ content = actions,
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = Space.md),
+ horizontalAlignment = Alignment.CenterHorizontally,
) {
- Text(stringResource(R.string.permission_open_settings_button))
+ Spacer(Modifier.height(Space.xl))
+ hero()
+ Spacer(Modifier.height(Space.lg))
+ body()
+ Spacer(Modifier.height(Space.md))
}
}
}
+
+/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
+@Composable
+private fun BrandHero(denied: Boolean) {
+ Box(contentAlignment = Alignment.Center) {
+ Box(
+ modifier = Modifier
+ .size(128.dp)
+ .clip(RoundedCornerShape(34.dp))
+ .background(colorResource(R.color.ic_launcher_background)),
+ ) {
+ Image(
+ painter = painterResource(R.drawable.ic_launcher_foreground),
+ contentDescription = stringResource(R.string.app_name),
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ if (denied) {
+ // A small lock badge sits over the corner to signal "blocked".
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .offset(x = 10.dp, y = 10.dp)
+ .size(44.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.errorContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Lock,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+ }
+}
+
+/** One trust point: a tonal icon chip on the left, title + supporting text right. */
+@Composable
+private fun BenefitRow(icon: ImageVector, title: String, body: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.secondaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+ Spacer(Modifier.width(Space.sm))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(text = title, style = MaterialTheme.typography.titleMedium)
+ Text(
+ text = body,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+@Composable
+private fun PrivacyFootnote() {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Lock,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(14.dp),
+ )
+ Spacer(Modifier.width(6.dp))
+ Text(
+ text = stringResource(R.string.permission_privacy_footnote),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 68c9641..6603370 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -12,13 +12,20 @@
Kalender konnte nicht gelesen werden.
- Kalender-Zugriff
- Calendula liest nur deinen Gerätekalender — keine Daten verlassen das Gerät.
- Weiter
+ Alle Termine, schön im Blick
+ Calendula braucht Lesezugriff auf deinen Kalender, um deine Termine zu zeigen. Mehr verlangt die App nie.
+ Kalender-Zugriff erlauben
Kalender-Zugriff abgelehnt
Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.
System-Einstellungen öffnen
Erneut versuchen
+ Bleibt auf deinem Gerät
+ Deine Kalender werden lokal gelesen und verlassen das Telefon nie.
+ Alle Kalender vereint
+ Google, CalDAV, lokal — alles, was synchronisiert ist, erscheint automatisch.
+ Kein Tracking, niemals
+ Keine Telemetrie, keine Analyse, keine Werbung.
+ Nur Lesezugriff · keine Internet-Berechtigung
Vorheriger Monat
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ba05942..f632463 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -13,13 +13,20 @@
Could not read the calendar.
- Calendar access
- Calendula reads only your device calendar — no data leaves your device.
- Continue
+ See all your events, beautifully
+ Calendula needs to read your calendar to show your events. That\'s the only thing it ever asks for.
+ Grant calendar access
Calendar access denied
Calendula cannot show events without calendar access. You can grant it again in the system settings.
Open system settings
Try again
+ Stays on your device
+ Your calendars are read locally and never leave the phone.
+ All your calendars, together
+ Google, CalDAV, local — anything synced to the device just appears.
+ No tracking, ever
+ Zero telemetry, zero analytics, no ads.
+ Read-only · no internet permission
Previous month