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