From d58b0ed1cb8ba7aeca42e3c48cc9d623efb61531 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 11:59:53 +0000 Subject: [PATCH 1/6] Redesign navigation and Settings per handoff spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Global navigation: - Remove Settings from bottom nav bar; bottom bar now has 3 tabs (Home, History, Stats) - Add Settings gear icon (IconButton) to top-right of Home screen app bar Settings screen: - Replace expandable accordion cards with flat Material ListItem layout - Section headers: SemiBold weight, primary colour, 20dp top padding - Each settings section becomes a sub-screen (back arrow, dedicated TopAppBar) Cycle · One-Tap Quick Log · Reminders · Appearance · Security & Privacy · Data & Backup · Widgets · About - Navigation items trail a ChevronRight; toggle items trail a Material Switch - HorizontalDividers between major groups - Icons in 24×24dp bounding boxes via SettingsNavItem - Support banner padding aligned with list item insets --- .../java/com/mapgie/goflo/MainActivity.kt | 12 +- .../goflo/ui/screens/home/HomeScreen.kt | 11 + .../ui/screens/settings/SettingsScreen.kt | 1416 +++++++++-------- 3 files changed, 794 insertions(+), 645 deletions(-) diff --git a/app/src/main/java/com/mapgie/goflo/MainActivity.kt b/app/src/main/java/com/mapgie/goflo/MainActivity.kt index 08e815a..9c8cdd9 100644 --- a/app/src/main/java/com/mapgie/goflo/MainActivity.kt +++ b/app/src/main/java/com/mapgie/goflo/MainActivity.kt @@ -14,7 +14,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BarChart import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar @@ -161,7 +160,7 @@ private fun MainNavHost(app: GoFloApplication, currentTheme: AppTheme, pendingCa val backStackEntry by navController.currentBackStackEntryAsState() val currentRoute = backStackEntry?.destination?.route - val bottomNavRoutes = listOf(Screen.Home.route, Screen.History.route, Screen.Stats.route, Screen.Settings.route) + val bottomNavRoutes = listOf(Screen.Home.route, Screen.History.route, Screen.Stats.route) val showBottomBar = bottomNavRoutes.any { currentRoute?.startsWith(it) == true } Scaffold( @@ -195,15 +194,6 @@ private fun MainNavHost(app: GoFloApplication, currentTheme: AppTheme, pendingCa icon = { Icon(Icons.Default.BarChart, contentDescription = "Stats") }, label = { Text("Stats") } ) - NavigationBarItem( - selected = currentRoute == Screen.Settings.route, - onClick = { navController.navigate(Screen.Settings.route) { - popUpTo(navController.graph.findStartDestination().id) { saveState = true } - launchSingleTop = true; restoreState = true - } }, - icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, - label = { Text("Settings") } - ) } } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt index 01057c5..48f6964 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt @@ -26,6 +26,8 @@ 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.DateRange +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.IconButton import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar @@ -157,6 +159,15 @@ fun HomeScreen( ) } }, + actions = { + IconButton(onClick = { onNavigate(Screen.Settings.route) }) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings", + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + }, colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = MaterialTheme.colorScheme.primaryContainer, titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt index 65067e0..2e5f57a 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt @@ -7,13 +7,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import com.mapgie.goflo.AppIconChoice import com.mapgie.goflo.ui.components.BetaFeedbackBanner -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -38,8 +31,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Eco -import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.NightsStay import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.WaterDrop @@ -67,6 +60,7 @@ import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold @@ -89,7 +83,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.draw.rotate import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path @@ -101,13 +94,16 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.outlined.TouchApp import androidx.compose.material.icons.outlined.Tune import androidx.compose.material.icons.outlined.Widgets import com.mapgie.goflo.BuildConfig +import com.mapgie.goflo.data.database.entities.TrackingCategory +import com.mapgie.goflo.data.preferences.AppPreferences +import com.mapgie.goflo.data.preferences.ReminderSettings +import com.mapgie.goflo.data.preferences.SecuritySettings import com.mapgie.goflo.data.preferences.hasPinSet import com.mapgie.goflo.data.repository.ImportResult import androidx.compose.material3.Checkbox @@ -125,33 +121,27 @@ private enum class StandardPalette( val lightTheme: AppTheme, val darkTheme: AppTheme, val systemTheme: AppTheme, - /** Primary colour shown in the top-left half of the diagonal swatch. */ val previewArgb: Long, - /** - * Accent (tertiary or secondary) colour shown in the bottom-right half of the - * diagonal swatch — gives each palette a two-tone preview so the swatches look - * vibrant rather than flat single-colour circles. - */ val accentArgb: Long, ) { // Classic - CORAL ("Coral", AppTheme.CORAL, AppTheme.CORAL_DARK, AppTheme.CORAL_SYSTEM, 0xFFC15542L, 0xFFB5307AL), // coral-red + rose-magenta - TEAL ("Teal", AppTheme.TURQUOISE, AppTheme.TURQUOISE_DARK, AppTheme.SYSTEM, 0xFF00696FL, 0xFF4E6078L), // deep teal + slate-blue - SAGE ("Sage", AppTheme.GREEN, AppTheme.GREEN_DARK, AppTheme.GREEN_SYSTEM, 0xFF386A20L, 0xFF6B5BAEL), // forest-green + periwinkle + CORAL ("Coral", AppTheme.CORAL, AppTheme.CORAL_DARK, AppTheme.CORAL_SYSTEM, 0xFFC15542L, 0xFFB5307AL), + TEAL ("Teal", AppTheme.TURQUOISE, AppTheme.TURQUOISE_DARK, AppTheme.SYSTEM, 0xFF00696FL, 0xFF4E6078L), + SAGE ("Sage", AppTheme.GREEN, AppTheme.GREEN_DARK, AppTheme.GREEN_SYSTEM, 0xFF386A20L, 0xFF6B5BAEL), // Fun - SUMMER_CANDY ("Summer Candy", AppTheme.SUMMER_CANDY, AppTheme.SUMMER_CANDY_DARK, AppTheme.SUMMER_CANDY_SYSTEM, 0xFFC2185BL, 0xFF9B27AFL), // raspberry + electric-violet - BEACH_VIBES ("Beach Vibes", AppTheme.BEACH_VIBES, AppTheme.BEACH_VIBES_DARK, AppTheme.BEACH_VIBES_SYSTEM, 0xFF0D47A1L, 0xFFD4700AL), // deep ocean-blue + vivid coral-orange - PEACH_MELBA ("Peach Melba", AppTheme.PEACH_MELBA, AppTheme.PEACH_MELBA_DARK, AppTheme.PEACH_MELBA_SYSTEM, 0xFFBF360CL, 0xFF9C5BA0L), // vivid terra-cotta + dusty lilac - DISCO ("All-Night Disco Party", AppTheme.DISCO, AppTheme.DISCO_DARK, AppTheme.DISCO_SYSTEM, 0xFF7B0EA0L, 0xFF76B900L), // deep-violet + acid-lime - METAL_CHICK ("Metal Chic", AppTheme.METAL_CHICK, AppTheme.METAL_CHICK_DARK, AppTheme.METAL_CHICK_SYSTEM, 0xFF4A4A5AL, 0xFF6B2D3EL), // charcoal + burgundy - WHIMSY ("Whimsy Whispers", AppTheme.WHIMSY, AppTheme.WHIMSY_DARK, AppTheme.WHIMSY_SYSTEM, 0xFF5050A0L, 0xFF2D7A6EL), // periwinkle + mint-teal - COLOUR_HAPPY ("Colour Me Happy", AppTheme.COLOUR_HAPPY, AppTheme.COLOUR_HAPPY_DARK, AppTheme.COLOUR_HAPPY_SYSTEM, 0xFFD63A26L, 0xFF00A8E8L), // vivid strawberry + electric cerulean + SUMMER_CANDY ("Summer Candy", AppTheme.SUMMER_CANDY, AppTheme.SUMMER_CANDY_DARK, AppTheme.SUMMER_CANDY_SYSTEM, 0xFFC2185BL, 0xFF9B27AFL), + BEACH_VIBES ("Beach Vibes", AppTheme.BEACH_VIBES, AppTheme.BEACH_VIBES_DARK, AppTheme.BEACH_VIBES_SYSTEM, 0xFF0D47A1L, 0xFFD4700AL), + PEACH_MELBA ("Peach Melba", AppTheme.PEACH_MELBA, AppTheme.PEACH_MELBA_DARK, AppTheme.PEACH_MELBA_SYSTEM, 0xFFBF360CL, 0xFF9C5BA0L), + DISCO ("All-Night Disco Party", AppTheme.DISCO, AppTheme.DISCO_DARK, AppTheme.DISCO_SYSTEM, 0xFF7B0EA0L, 0xFF76B900L), + METAL_CHICK ("Metal Chic", AppTheme.METAL_CHICK, AppTheme.METAL_CHICK_DARK, AppTheme.METAL_CHICK_SYSTEM, 0xFF4A4A5AL, 0xFF6B2D3EL), + WHIMSY ("Whimsy Whispers", AppTheme.WHIMSY, AppTheme.WHIMSY_DARK, AppTheme.WHIMSY_SYSTEM, 0xFF5050A0L, 0xFF2D7A6EL), + COLOUR_HAPPY ("Colour Me Happy", AppTheme.COLOUR_HAPPY, AppTheme.COLOUR_HAPPY_DARK, AppTheme.COLOUR_HAPPY_SYSTEM, 0xFFD63A26L, 0xFF00A8E8L), // Bold - DRAGON_FIRE ("Dragon Fire", AppTheme.DRAGON_FIRE, AppTheme.DRAGON_FIRE_DARK, AppTheme.DRAGON_FIRE_SYSTEM, 0xFFB71C1CL, 0xFFE07800L), // blood-red + lava-orange - MIDNIGHT_NEON ("Midnight Neon", AppTheme.MIDNIGHT_NEON, AppTheme.MIDNIGHT_NEON_DARK, AppTheme.MIDNIGHT_NEON_SYSTEM, 0xFF6200EAL, 0xFF76B900L), // electric-violet + acid-lime - // Accessibility (max-contrast palette circles — always shown regardless of WCAG toggle) - MAX_CONTRAST ("Max Contrast", AppTheme.HIGH_CONTRAST_LIGHT, AppTheme.HIGH_CONTRAST_DARK, AppTheme.HIGH_CONTRAST_LIGHT, 0xFF1A1A1AL, 0xFF000000L), // near-black primary - BLUE_ORANGE_PAL ("Blue & Orange", AppTheme.BLUE_ORANGE, AppTheme.BLUE_ORANGE, AppTheme.BLUE_ORANGE, 0xFF005FADL, 0xFF8B5000L), // cobalt-blue + burnt-sienna + DRAGON_FIRE ("Dragon Fire", AppTheme.DRAGON_FIRE, AppTheme.DRAGON_FIRE_DARK, AppTheme.DRAGON_FIRE_SYSTEM, 0xFFB71C1CL, 0xFFE07800L), + MIDNIGHT_NEON ("Midnight Neon", AppTheme.MIDNIGHT_NEON, AppTheme.MIDNIGHT_NEON_DARK, AppTheme.MIDNIGHT_NEON_SYSTEM, 0xFF6200EAL, 0xFF76B900L), + // Accessibility + MAX_CONTRAST ("Max Contrast", AppTheme.HIGH_CONTRAST_LIGHT, AppTheme.HIGH_CONTRAST_DARK, AppTheme.HIGH_CONTRAST_LIGHT, 0xFF1A1A1AL, 0xFF000000L), + BLUE_ORANGE_PAL ("Blue & Orange", AppTheme.BLUE_ORANGE, AppTheme.BLUE_ORANGE, AppTheme.BLUE_ORANGE, 0xFF005FADL, 0xFF8B5000L), } private val AppTheme.themeMode: ThemeMode? get() = when (this) { @@ -180,7 +170,7 @@ private val AppTheme.themeMode: ThemeMode? get() = when (this) { AppTheme.METAL_CHICK_DARK, AppTheme.WHIMSY_DARK, AppTheme.COLOUR_HAPPY_DARK, AppTheme.DRAGON_FIRE_DARK, AppTheme.MIDNIGHT_NEON_DARK -> ThemeMode.DARK - else -> null // accessibility + else -> null } private val AppTheme.standardPalette: StandardPalette? get() = when (this) { @@ -255,6 +245,12 @@ private val AppTheme.summaryLabel: String get() = when (this) { AppTheme.BLUE_ORANGE -> "Blue & Orange" } +// ── Sub-screen routing ──────────────────────────────────────────────────────── + +private enum class SettingsSubScreen { + NONE, CYCLE, QUICK_LOG, REMINDERS, APPEARANCE, SECURITY, DATA, WIDGETS, ABOUT +} + // ── Main screen ─────────────────────────────────────────────────────────────── @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @@ -278,18 +274,23 @@ fun SettingsScreen( AppIconChoice.valueOf(prefs.iconChoice) }.getOrDefault(AppIconChoice.DEFAULT) - var showTimePicker by rememberSaveable { mutableStateOf(false) } - var showRemovePinDialog by rememberSaveable { mutableStateOf(false) } - var customIconError by remember { mutableStateOf(null) } - var showDisclaimer by rememberSaveable { mutableStateOf(false) } - var showDeleteAllDialog by rememberSaveable { mutableStateOf(false) } - var showChangelog by rememberSaveable { mutableStateOf(false) } - var showExportDialog by rememberSaveable { mutableStateOf(false) } - var pendingImportUri by remember { mutableStateOf(null) } + var currentSubScreen by rememberSaveable { mutableStateOf(SettingsSubScreen.NONE) } + var showTimePicker by rememberSaveable { mutableStateOf(false) } + var showRemovePinDialog by rememberSaveable { mutableStateOf(false) } + var customIconError by remember { mutableStateOf(null) } + var showDisclaimer by rememberSaveable { mutableStateOf(false) } + var showDeleteAllDialog by rememberSaveable { mutableStateOf(false) } + var showChangelog by rememberSaveable { mutableStateOf(false) } + var showExportDialog by rememberSaveable { mutableStateOf(false) } + var pendingImportUri by remember { mutableStateOf(null) } var showImportOptionsDialog by rememberSaveable { mutableStateOf(false) } - var importResult by remember { mutableStateOf(null) } - var removePinInput by rememberSaveable { mutableStateOf("") } - var removePinError by rememberSaveable { mutableStateOf(false) } + var importResult by remember { mutableStateOf(null) } + var removePinInput by rememberSaveable { mutableStateOf("") } + var removePinError by rememberSaveable { mutableStateOf(false) } + + BackHandler(currentSubScreen != SettingsSubScreen.NONE) { + currentSubScreen = SettingsSubScreen.NONE + } val importFilePicker = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent() @@ -399,6 +400,8 @@ fun SettingsScreen( return } + // ── App-level dialogs (rendered above whichever sub-screen is active) ────── + if (showChangelog) { ChangelogDialog(onDismiss = { showChangelog = false }) } @@ -414,8 +417,6 @@ fun SettingsScreen( ) } - // ── Dialogs ─────────────────────────────────────────────────────────────── - if (showTimePicker) { AlertDialog( onDismissRequest = { showTimePicker = false }, @@ -532,7 +533,94 @@ fun SettingsScreen( ) } - // ── Computed summaries for collapsed headers ─────────────────────────────── + // ── Route to sub-screens ────────────────────────────────────────────────── + + when (currentSubScreen) { + SettingsSubScreen.NONE -> SettingsMainList( + prefs = prefs, + security = security, + categories = categories, + currentTheme = currentTheme, + reminder = reminder, + onNavigateTo = { currentSubScreen = it }, + onNavigateToManageCategories = onNavigateToManageCategories, + onOpenDiscord = { openUrl(context, "https://discord.gg/xphnQCZeYq") }, + onOpenSupport = { openUrl(context, "https://github.com/sponsors/mapgie") }, + onOpenPrivacy = onNavigateToPrivacy + ) + SettingsSubScreen.CYCLE -> CycleSubScreen( + prefs = prefs, + viewModel = viewModel, + onBack = { currentSubScreen = SettingsSubScreen.NONE } + ) + SettingsSubScreen.QUICK_LOG -> QuickLogSubScreen( + prefs = prefs, + categories = categories, + viewModel = viewModel, + onBack = { currentSubScreen = SettingsSubScreen.NONE } + ) + SettingsSubScreen.REMINDERS -> RemindersSubScreen( + reminder = reminder, + viewModel = viewModel, + onShowTimePicker = { showTimePicker = true }, + onBack = { currentSubScreen = SettingsSubScreen.NONE } + ) + SettingsSubScreen.APPEARANCE -> AppearanceSubScreen( + currentTheme = currentTheme, + prefs = prefs, + currentIconChoice = currentIconChoice, + viewModel = viewModel, + onPickCustomImage = { customIconPicker.launch("image/*") }, + onBack = { currentSubScreen = SettingsSubScreen.NONE } + ) + SettingsSubScreen.SECURITY -> SecuritySubScreen( + security = security, + viewModel = viewModel, + onNavigateToPinSetup = onNavigateToPinSetup, + onShowRemovePinDialog = { showRemovePinDialog = true }, + onBack = { currentSubScreen = SettingsSubScreen.NONE } + ) + SettingsSubScreen.DATA -> DataSubScreen( + onShowExportDialog = { showExportDialog = true }, + onShowImportPicker = { importFilePicker.launch("application/json") }, + onShowDeleteDialog = { showDeleteAllDialog = true }, + onBack = { currentSubScreen = SettingsSubScreen.NONE } + ) + SettingsSubScreen.WIDGETS -> WidgetsSubScreen( + prefs = prefs, + security = security, + viewModel = viewModel, + onBack = { currentSubScreen = SettingsSubScreen.NONE } + ) + SettingsSubScreen.ABOUT -> AboutSubScreen( + onNavigateToPrivacy = onNavigateToPrivacy, + onNavigateToLicenses = onNavigateToLicenses, + onShowChangelog = { showChangelog = true }, + onShowDisclaimer = { showDisclaimer = true }, + onBack = { currentSubScreen = SettingsSubScreen.NONE } + ) + } +} + +// ── Settings main flat list ─────────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SettingsMainList( + prefs: AppPreferences, + security: SecuritySettings, + categories: List, + currentTheme: AppTheme, + reminder: ReminderSettings, + onNavigateTo: (SettingsSubScreen) -> Unit, + onNavigateToManageCategories: () -> Unit, + onOpenDiscord: () -> Unit, + onOpenSupport: () -> Unit, + onOpenPrivacy: () -> Unit, +) { + val cycleSummary = if (prefs.preferredCycleLength > 0) + "Custom: ${prefs.preferredCycleLength} days" + else "Auto — calculated from history" val activeReminderCount = listOf( reminder.preperiodEnabled, @@ -542,20 +630,18 @@ fun SettingsScreen( val reminderSummary = if (activeReminderCount == 0) "No reminders enabled" else "$activeReminderCount active · %02d:%02d".format(reminder.reminderHour, reminder.reminderMinute) - val cycleSummary = if (prefs.preferredCycleLength > 0) - "Custom: ${prefs.preferredCycleLength} days" - else "Auto — calculated from history" - val securitySummary = if (security.hasPinSet) "PIN lock enabled" else "No PIN set" - // ── Scaffold ────────────────────────────────────────────────────────────── + val quickLogSummary = if (prefs.quickLogCategoryId == -1L) "Tap → Period" + else categories.firstOrNull { it.id == prefs.quickLogCategoryId }?.name + ?.let { "Tap → $it" } ?: "Tap → Period" Scaffold( topBar = { TopAppBar( title = { Text("Settings") }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, + containerColor = MaterialTheme.colorScheme.primaryContainer, titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer ) ) @@ -566,659 +652,741 @@ fun SettingsScreen( .fillMaxSize() .padding(top = padding.calculateTopPadding()) ) { - BetaFeedbackBanner() - Column( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .verticalScroll(rememberScrollState()) - .padding(bottom = padding.calculateBottomPadding()) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { + BetaFeedbackBanner() + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(bottom = padding.calculateBottomPadding()) + ) { - // ── Medical device disclaimer banner (always visible at top) ───── - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer + // Medical device disclaimer (always visible at top) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(18.dp) + ) + Text( + text = "Not a medical device. GoFlo is for personal tracking only and is not a substitute for professional medical advice.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + + // ── TRACKING ───────────────────────────────────────────────── + SettingsSectionHeader("Tracking") + SettingsNavItem( + title = "What You Track", + subtitle = "Customise the symptoms & metrics you log", + icon = Icons.Outlined.Tune, + onClick = onNavigateToManageCategories ) - ) { - Row( - modifier = Modifier.padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.Top + SettingsNavItem( + title = "Cycle", + subtitle = cycleSummary, + icon = Icons.Outlined.Autorenew, + onClick = { onNavigateTo(SettingsSubScreen.CYCLE) } + ) + SettingsNavItem( + title = "One-Tap Quick Log", + subtitle = quickLogSummary, + icon = Icons.Outlined.TouchApp, + onClick = { onNavigateTo(SettingsSubScreen.QUICK_LOG) } + ) + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + + // ── NOTIFICATIONS ───────────────────────────────────────────── + SettingsSectionHeader("Notifications") + SettingsNavItem( + title = "Reminders", + subtitle = reminderSummary, + icon = Icons.Outlined.NotificationsNone, + onClick = { onNavigateTo(SettingsSubScreen.REMINDERS) } + ) + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + + // ── PERSONALISATION ─────────────────────────────────────────── + SettingsSectionHeader("Personalisation") + SettingsNavItem( + title = "Appearance", + subtitle = currentTheme.summaryLabel, + icon = Icons.Outlined.Palette, + onClick = { onNavigateTo(SettingsSubScreen.APPEARANCE) } + ) + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + + // ── PRIVACY & DATA ──────────────────────────────────────────── + SettingsSectionHeader("Privacy & Data") + SettingsNavItem( + title = "Security & Privacy", + subtitle = securitySummary, + icon = Icons.Outlined.Lock, + iconTint = if (!security.hasPinSet) MaterialTheme.colorScheme.error else null, + onClick = { onNavigateTo(SettingsSubScreen.SECURITY) } + ) + SettingsNavItem( + title = "Data & Backup", + subtitle = "Export, import & manage your data", + icon = Icons.Outlined.Storage, + onClick = { onNavigateTo(SettingsSubScreen.DATA) } + ) + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + + // ── WIDGETS ─────────────────────────────────────────────────── + SettingsSectionHeader("Widgets") + SettingsNavItem( + title = "Home Screen Widgets", + subtitle = "Two widgets available", + icon = Icons.Outlined.Widgets, + onClick = { onNavigateTo(SettingsSubScreen.WIDGETS) } + ) + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + + // ── HELP & FEEDBACK ─────────────────────────────────────────── + SettingsSectionHeader("Help & Feedback") + SettingsNavItem( + title = "Bug report / Feature suggestion", + subtitle = "Join the conversation on Discord", + icon = Icons.Outlined.BugReport, + onClick = onOpenDiscord + ) + SupportCard( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + onSupport = onOpenSupport + ) + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + + // ── ABOUT ───────────────────────────────────────────────────── + SettingsSectionHeader("About") + SettingsNavItem( + title = "About GoFlo", + subtitle = "v${BuildConfig.VERSION_NAME}", + icon = Icons.Outlined.Info, + onClick = { onNavigateTo(SettingsSubScreen.ABOUT) } + ) + + // Privacy Policy — always visible at the very bottom + OutlinedButton( + onClick = onOpenPrivacy, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) ) { Icon( imageVector = Icons.Outlined.Info, contentDescription = null, - tint = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.size(18.dp) ) - Text( - text = "Not a medical device. GoFlo is for personal tracking only and is not a substitute for professional medical advice.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) + Spacer(Modifier.width(8.dp)) + Text("Privacy Policy & Medical Disclaimer") } + + Spacer(Modifier.height(8.dp)) } + } + } +} - // ═══════════════════════════════════════════════════════════════════ - // TRACKING - // Core tracking configuration — most users visit this group first - // ═══════════════════════════════════════════════════════════════════ - SettingsSectionHeader("Tracking") - - // Cycle ──────────────────────────────────────────────────────────── - CollapsibleSection( - title = "Cycle", - icon = Icons.Outlined.Autorenew, - summary = cycleSummary - ) { - val customEnabled = prefs.preferredCycleLength > 0 - SwitchRow( - label = "Custom cycle length", - subtitle = if (customEnabled) - "Using ${prefs.preferredCycleLength} days" - else - "Auto — calculated from your history", - checked = customEnabled, - onCheckedChange = { on -> +// ── Sub-screen: Cycle ───────────────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CycleSubScreen( + prefs: AppPreferences, + viewModel: SettingsViewModel, + onBack: () -> Unit +) { + SettingsSubScreenScaffold(title = "Cycle", onBack = onBack) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + val customEnabled = prefs.preferredCycleLength > 0 + + ListItem( + headlineContent = { Text("Custom cycle length") }, + supportingContent = { + Text(if (customEnabled) "Using ${prefs.preferredCycleLength} days" + else "Auto — calculated from your history") + }, + trailingContent = { + Switch( + checked = customEnabled, + onCheckedChange = null + ) + }, + modifier = Modifier + .clickable { viewModel.setPreferredCycleLength( - if (on) prefs.preferredCycleLength.coerceIn(21, 45).let { if (it == 0) 28 else it } - else 0 + if (customEnabled) 0 + else prefs.preferredCycleLength.coerceIn(21, 45).let { if (it == 0) 28 else it } ) } - ) - if (customEnabled) { - var sliderDays by remember(prefs.preferredCycleLength) { - mutableStateOf(prefs.preferredCycleLength.toFloat()) - } - Column(modifier = Modifier.padding(start = 8.dp)) { - Text( - "Cycle length: ${sliderDays.toInt()} days", - style = MaterialTheme.typography.bodyMedium - ) - Slider( - value = sliderDays, - onValueChange = { sliderDays = it }, - onValueChangeFinished = { viewModel.setPreferredCycleLength(sliderDays.toInt()) }, - valueRange = 21f..45f, - steps = 23 - ) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text("21 days", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - Text("45 days", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } + .semantics { role = Role.Switch } + ) + + if (customEnabled) { + var sliderDays by remember(prefs.preferredCycleLength) { + mutableStateOf(prefs.preferredCycleLength.toFloat()) + } + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + "Cycle length: ${sliderDays.toInt()} days", + style = MaterialTheme.typography.bodyMedium + ) + Slider( + value = sliderDays, + onValueChange = { sliderDays = it }, + onValueChangeFinished = { viewModel.setPreferredCycleLength(sliderDays.toInt()) }, + valueRange = 21f..45f, + steps = 23 + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("21 days", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("45 days", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) } } + } - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + HorizontalDivider() - SwitchRow( - label = "Show period predictions", - subtitle = "Display predicted future period days on the calendar", - checked = prefs.showPeriodPrediction, - onCheckedChange = { viewModel.setShowPeriodPrediction(it) } - ) + ListItem( + headlineContent = { Text("Show period predictions") }, + supportingContent = { Text("Display predicted future period days on the calendar") }, + trailingContent = { Switch(checked = prefs.showPeriodPrediction, onCheckedChange = null) }, + modifier = Modifier + .clickable { viewModel.setShowPeriodPrediction(!prefs.showPeriodPrediction) } + .semantics { role = Role.Switch } + ) - SwitchRow( - label = "Show ovulation markers", - subtitle = "Display ovulation day and fertility window on the calendar", - checked = prefs.showOvulationMarkers, - onCheckedChange = { viewModel.setShowOvulationMarkers(it) } - ) - } + HorizontalDivider() - // What You Track — nav card (high-value feature, given prominent nav treatment) ── - SettingsNavCard( - title = "What You Track", - subtitle = "Customise the symptoms & metrics you log", - icon = Icons.Outlined.Tune, - onClick = onNavigateToManageCategories + ListItem( + headlineContent = { Text("Show ovulation markers") }, + supportingContent = { Text("Display ovulation day and fertility window on the calendar") }, + trailingContent = { Switch(checked = prefs.showOvulationMarkers, onCheckedChange = null) }, + modifier = Modifier + .clickable { viewModel.setShowOvulationMarkers(!prefs.showOvulationMarkers) } + .semantics { role = Role.Switch } ) + } + } +} + +// ── Sub-screen: One-Tap Quick Log ───────────────────────────────────────────── - // One-Tap Quick Log ──────────────────────────────────────────────── - CollapsibleSection( - title = "One-Tap Quick Log", - icon = Icons.Outlined.TouchApp, - summary = if (prefs.quickLogCategoryId == -1L) "Tap → Period" - else categories.firstOrNull { it.id == prefs.quickLogCategoryId }?.name - ?.let { "Tap → $it" } ?: "Tap → Period" +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun QuickLogSubScreen( + prefs: AppPreferences, + categories: List, + viewModel: SettingsViewModel, + onBack: () -> Unit +) { + SettingsSubScreenScaffold(title = "One-Tap Quick Log", onBack = onBack) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + "When you tap the quick-add button, it logs:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - Text( - "When you tap the quick-add button, it logs:", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + FilterChip( + selected = prefs.quickLogCategoryId == -1L, + onClick = { viewModel.setQuickLogCategory(-1L) }, + label = { Text("Period") }, + leadingIcon = if (prefs.quickLogCategoryId == -1L) { + { Icon(Icons.Default.Check, contentDescription = null, + modifier = Modifier.size(FilterChipDefaults.IconSize)) } + } else null ) - Spacer(Modifier.height(4.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - // Log Period option + categories.forEach { cat -> FilterChip( - selected = prefs.quickLogCategoryId == -1L, - onClick = { viewModel.setQuickLogCategory(-1L) }, - label = { Text("Period") }, - leadingIcon = if (prefs.quickLogCategoryId == -1L) { + selected = prefs.quickLogCategoryId == cat.id, + onClick = { viewModel.setQuickLogCategory(cat.id) }, + label = { Text(cat.name) }, + leadingIcon = if (prefs.quickLogCategoryId == cat.id) { { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(FilterChipDefaults.IconSize)) } } else null ) - // One chip per tracking category - categories.forEach { cat -> - FilterChip( - selected = prefs.quickLogCategoryId == cat.id, - onClick = { viewModel.setQuickLogCategory(cat.id) }, - label = { Text(cat.name) }, - leadingIcon = if (prefs.quickLogCategoryId == cat.id) { - { Icon(Icons.Default.Check, contentDescription = null, - modifier = Modifier.size(FilterChipDefaults.IconSize)) } - } else null - ) - } } } + } + } +} - // ═══════════════════════════════════════════════════════════════════ - // NOTIFICATIONS - // ═══════════════════════════════════════════════════════════════════ - SettingsSectionHeader("Notifications") - - CollapsibleSection( - title = "Reminders", - icon = Icons.Outlined.NotificationsNone, - summary = reminderSummary - ) { - SwitchRow( - label = "Before period alert", - subtitle = "Notify ${reminder.preperiodDaysBefore} day(s) before predicted start", - checked = reminder.preperiodEnabled, - onCheckedChange = viewModel::setPreperiodEnabled - ) - if (reminder.preperiodEnabled) { - Column(modifier = Modifier.padding(start = 8.dp)) { - Text( - "Days before: ${reminder.preperiodDaysBefore}", - style = MaterialTheme.typography.bodyMedium - ) - Slider( - value = reminder.preperiodDaysBefore.toFloat(), - onValueChange = { viewModel.setPreperiodDays(it.toInt()) }, - valueRange = 1f..7f, - steps = 5 - ) - } - } - - SwitchRow( - label = "Ovulation window", - subtitle = "Notify around mid-cycle", - checked = reminder.ovulationEnabled, - onCheckedChange = viewModel::setOvulationEnabled - ) +// ── Sub-screen: Reminders ───────────────────────────────────────────────────── - SwitchRow( - label = "Daily log reminder", - subtitle = "Remind to log while period is active", - checked = reminder.dailyDuringPeriodEnabled, - onCheckedChange = viewModel::setDailyEnabled - ) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RemindersSubScreen( + reminder: ReminderSettings, + viewModel: SettingsViewModel, + onShowTimePicker: () -> Unit, + onBack: () -> Unit +) { + SettingsSubScreenScaffold(title = "Reminders", onBack = onBack) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + ListItem( + headlineContent = { Text("Before period alert") }, + supportingContent = { Text("Notify ${reminder.preperiodDaysBefore} day(s) before predicted start") }, + trailingContent = { Switch(checked = reminder.preperiodEnabled, onCheckedChange = null) }, + modifier = Modifier + .clickable { viewModel.setPreperiodEnabled(!reminder.preperiodEnabled) } + .semantics { role = Role.Switch } + ) - val anyEnabled = reminder.preperiodEnabled || - reminder.ovulationEnabled || - reminder.dailyDuringPeriodEnabled - if (anyEnabled) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text("Reminder time", style = MaterialTheme.typography.bodyMedium) - Text( - "%02d:%02d".format(reminder.reminderHour, reminder.reminderMinute), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) - } - OutlinedButton(onClick = { showTimePicker = true }) { Text("Change") } - } + if (reminder.preperiodEnabled) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { + Text("Days before: ${reminder.preperiodDaysBefore}", style = MaterialTheme.typography.bodyMedium) + Slider( + value = reminder.preperiodDaysBefore.toFloat(), + onValueChange = { viewModel.setPreperiodDays(it.toInt()) }, + valueRange = 1f..7f, + steps = 5 + ) } } - // ═══════════════════════════════════════════════════════════════════ - // PERSONALISATION - // ═══════════════════════════════════════════════════════════════════ - SettingsSectionHeader("Personalisation") + HorizontalDivider() - CollapsibleSection( - title = "Appearance", - icon = Icons.Outlined.Palette, - summary = currentTheme.summaryLabel - ) { - CompactThemePicker( - current = currentTheme, - wcagChecked = prefs.wcagMode, - onSelect = { viewModel.setTheme(it.name) }, - onWcagToggle = { viewModel.setWcagMode(it) } - ) + ListItem( + headlineContent = { Text("Ovulation window") }, + supportingContent = { Text("Notify around mid-cycle") }, + trailingContent = { Switch(checked = reminder.ovulationEnabled, onCheckedChange = null) }, + modifier = Modifier + .clickable { viewModel.setOvulationEnabled(!reminder.ovulationEnabled) } + .semantics { role = Role.Switch } + ) - HorizontalDivider( - modifier = Modifier.padding(vertical = 12.dp), - color = MaterialTheme.colorScheme.outlineVariant - ) + HorizontalDivider() - AppIconPicker( - currentChoice = currentIconChoice, - onSelect = { viewModel.setIconChoice(it) }, - onPickCustomImage = { customIconPicker.launch("image/*") } - ) - } + ListItem( + headlineContent = { Text("Daily log reminder") }, + supportingContent = { Text("Remind to log while period is active") }, + trailingContent = { Switch(checked = reminder.dailyDuringPeriodEnabled, onCheckedChange = null) }, + modifier = Modifier + .clickable { viewModel.setDailyEnabled(!reminder.dailyDuringPeriodEnabled) } + .semantics { role = Role.Switch } + ) - // ═══════════════════════════════════════════════════════════════════ - // PRIVACY & DATA - // Security first — then data management - // ═══════════════════════════════════════════════════════════════════ - SettingsSectionHeader("Privacy & Data") - - // Security & Privacy — icon tint shifts to error colour when unprotected - CollapsibleSection( - title = "Security & Privacy", - icon = Icons.Outlined.Lock, - summary = securitySummary, - iconTint = if (!security.hasPinSet) MaterialTheme.colorScheme.error else null - ) { - if (!security.hasPinSet) { - Text( - "No PIN set — your data is accessible without authentication.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(4.dp)) - Button( - onClick = { onNavigateToPinSetup(false) }, - modifier = Modifier.fillMaxWidth() - ) { Text("Set PIN Lock") } - } else { - Text( - "PIN lock is enabled.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(4.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedButton( - onClick = { onNavigateToPinSetup(true) }, - modifier = Modifier.weight(1f) - ) { Text("Change PIN") } - OutlinedButton( - onClick = { showRemovePinDialog = true }, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { Text("Remove PIN") } - } - if (viewModel.isBiometricAvailable) { - Spacer(Modifier.height(4.dp)) - SwitchRow( - label = "Biometric unlock", - subtitle = "Use fingerprint or face to unlock", - checked = security.biometricEnabled, - onCheckedChange = { viewModel.setBiometricEnabled(it) } + val anyEnabled = reminder.preperiodEnabled || reminder.ovulationEnabled || reminder.dailyDuringPeriodEnabled + if (anyEnabled) { + HorizontalDivider() + ListItem( + headlineContent = { Text("Reminder time") }, + supportingContent = { + Text( + "%02d:%02d".format(reminder.reminderHour, reminder.reminderMinute), + color = MaterialTheme.colorScheme.primary ) + }, + trailingContent = { + OutlinedButton(onClick = onShowTimePicker) { Text("Change") } } - } - } - - // Data & Backup (renamed from "Data" — title alone was too vague) - CollapsibleSection( - title = "Data & Backup", - icon = Icons.Outlined.Storage, - summary = "Export, import & manage your data" - ) { - Text( - "Back up or transfer your data. Export JSON to keep a full backup; " + - "CSV is useful for spreadsheets.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(Modifier.height(4.dp)) - - OutlinedButton( - onClick = { showExportDialog = true }, - modifier = Modifier.fillMaxWidth() - ) { Text("Export Data") } + } + } + } +} - OutlinedButton( - onClick = { importFilePicker.launch("application/json") }, - modifier = Modifier.fillMaxWidth() - ) { Text("Import Data") } +// ── Sub-screen: Appearance ──────────────────────────────────────────────────── - Spacer(Modifier.height(4.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - Spacer(Modifier.height(4.dp)) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun AppearanceSubScreen( + currentTheme: AppTheme, + prefs: AppPreferences, + currentIconChoice: AppIconChoice, + viewModel: SettingsViewModel, + onPickCustomImage: () -> Unit, + onBack: () -> Unit +) { + SettingsSubScreenScaffold(title = "Appearance", onBack = onBack) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CompactThemePicker( + current = currentTheme, + wcagChecked = prefs.wcagMode, + onSelect = { viewModel.setTheme(it.name) }, + onWcagToggle = { viewModel.setWcagMode(it) } + ) - OutlinedButton( - onClick = { showDeleteAllDialog = true }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { Text("Delete All Data") } - } + HorizontalDivider() - // ═══════════════════════════════════════════════════════════════════ - // WIDGETS - // ═══════════════════════════════════════════════════════════════════ - SettingsSectionHeader("Widgets") + AppIconPicker( + currentChoice = currentIconChoice, + onSelect = { viewModel.setIconChoice(it) }, + onPickCustomImage = onPickCustomImage + ) + } + } +} - CollapsibleSection( - title = "Home Screen Widgets", - icon = Icons.Outlined.Widgets, - summary = "Two widgets available" - ) { - Text( - "GoFlo offers two home-screen widgets. Long-press your home screen " + - "and choose Widgets to add them.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(8.dp)) +// ── Sub-screen: Security & Privacy ──────────────────────────────────────────── - // GoFlo Status widget description - Text("GoFlo Status (2×1)", style = MaterialTheme.typography.labelMedium) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SecuritySubScreen( + security: SecuritySettings, + viewModel: SettingsViewModel, + onNavigateToPinSetup: (changing: Boolean) -> Unit, + onShowRemovePinDialog: () -> Unit, + onBack: () -> Unit +) { + SettingsSubScreenScaffold(title = "Security & Privacy", onBack = onBack) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (!security.hasPinSet) { Text( - "Shows your cycle status at a glance — current cycle day, days until " + - "your next period, or a privacy placeholder when PIN lock is active.", + "No PIN set — your data is accessible without authentication.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(Modifier.height(8.dp)) - - // Quick Log widget description - Text("Quick Log (2×2)", style = MaterialTheme.typography.labelMedium) + Button( + onClick = { onNavigateToPinSetup(false) }, + modifier = Modifier.fillMaxWidth() + ) { Text("Set PIN Lock") } + } else { Text( - "Shows up to four of your active tracking categories. Tap any button " + - "to jump straight to today's log entry for that category.", + "PIN lock is enabled.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) - - // PIN guard toggle — only shown when a PIN is set - if (security.hasPinSet) { - Spacer(Modifier.height(8.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - Spacer(Modifier.height(8.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton( + onClick = { onNavigateToPinSetup(true) }, + modifier = Modifier.weight(1f) + ) { Text("Change PIN") } + OutlinedButton( + onClick = onShowRemovePinDialog, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { Text("Remove PIN") } + } + if (viewModel.isBiometricAvailable) { + HorizontalDivider() SwitchRow( - label = "Show data on GoFlo Status widget", - subtitle = "By default the widget hides cycle data when PIN lock is enabled. " + - "Turn this on to show live data on your home screen.", - checked = prefs.widgetDataVisible, - onCheckedChange = { viewModel.setWidgetDataVisible(it) } + label = "Biometric unlock", + subtitle = "Use fingerprint or face to unlock", + checked = security.biometricEnabled, + onCheckedChange = { viewModel.setBiometricEnabled(it) } ) } } + } + } +} - // ═══════════════════════════════════════════════════════════════════ - // HELP & FEEDBACK - // ═══════════════════════════════════════════════════════════════════ - SettingsSectionHeader("Help & Feedback") +// ── Sub-screen: Data & Backup ───────────────────────────────────────────────── - SettingsNavCard( - title = "Found a bug? Feature suggestion?", - subtitle = "Join the conversation on Discord", - icon = Icons.Outlined.BugReport, - onClick = { openUrl(context, "https://discord.gg/xphnQCZeYq") } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DataSubScreen( + onShowExportDialog: () -> Unit, + onShowImportPicker: () -> Unit, + onShowDeleteDialog: () -> Unit, + onBack: () -> Unit +) { + SettingsSubScreenScaffold(title = "Data & Backup", onBack = onBack) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + "Back up or transfer your data. Export JSON to keep a full backup; " + + "CSV is useful for spreadsheets.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - SupportCard( - onSupport = { openUrl(context, "https://github.com/sponsors/mapgie") } + OutlinedButton( + onClick = onShowExportDialog, + modifier = Modifier.fillMaxWidth() + ) { Text("Export Data") } + + OutlinedButton( + onClick = onShowImportPicker, + modifier = Modifier.fillMaxWidth() + ) { Text("Import Data") } + + HorizontalDivider() + + OutlinedButton( + onClick = onShowDeleteDialog, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { Text("Delete All Data") } + } + } +} + +// ── Sub-screen: Widgets ─────────────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun WidgetsSubScreen( + prefs: AppPreferences, + security: SecuritySettings, + viewModel: SettingsViewModel, + onBack: () -> Unit +) { + SettingsSubScreenScaffold(title = "Home Screen Widgets", onBack = onBack) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "GoFlo offers two home-screen widgets. Long-press your home screen " + + "and choose Widgets to add them.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - // ═══════════════════════════════════════════════════════════════════ - // ABOUT - // ═══════════════════════════════════════════════════════════════════ - SettingsSectionHeader("About") + Text("GoFlo Status (2×1)", style = MaterialTheme.typography.labelMedium) + Text( + "Shows your cycle status at a glance — current cycle day, days until " + + "your next period, or a privacy placeholder when PIN lock is active.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) - CollapsibleSection( - title = "About", - icon = Icons.Outlined.Info, - summary = "GoFlo v${BuildConfig.VERSION_NAME}" - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .clickable { showChangelog = true } - .semantics { role = Role.Button } - .padding(vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = "GoFlo v${BuildConfig.VERSION_NAME}", - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = "Tap to see changelog", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) - } + Spacer(Modifier.height(4.dp)) + Text("Quick Log (2×2)", style = MaterialTheme.typography.labelMedium) + Text( + "Shows up to four of your active tracking categories. Tap any button " + + "to jump straight to today's log entry for that category.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) - Text( - "All your data stays on your device — nothing is sent anywhere.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + if (security.hasPinSet) { + HorizontalDivider() + SwitchRow( + label = "Show data on GoFlo Status widget", + subtitle = "By default the widget hides cycle data when PIN lock is enabled. " + + "Turn this on to show live data on your home screen.", + checked = prefs.widgetDataVisible, + onCheckedChange = { viewModel.setWidgetDataVisible(it) } ) - Spacer(Modifier.height(4.dp)) + } + } + } +} - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedButton( - onClick = onNavigateToPrivacy, - modifier = Modifier.weight(1f) - ) { Text("Privacy Policy") } - OutlinedButton( - onClick = onNavigateToLicenses, - modifier = Modifier.weight(1f) - ) { Text("Licences") } - } +// ── Sub-screen: About ───────────────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AboutSubScreen( + onNavigateToPrivacy: () -> Unit, + onNavigateToLicenses: () -> Unit, + onShowChangelog: () -> Unit, + onShowDisclaimer: () -> Unit, + onBack: () -> Unit +) { + SettingsSubScreenScaffold(title = "About", onBack = onBack) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + ListItem( + headlineContent = { Text("GoFlo v${BuildConfig.VERSION_NAME}") }, + supportingContent = { Text("Tap to see changelog", color = MaterialTheme.colorScheme.primary) }, + modifier = Modifier.clickable(onClick = onShowChangelog) + ) + + HorizontalDivider() + + Text( + "All your data stays on your device — nothing is sent anywhere.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = onNavigateToPrivacy, + modifier = Modifier.weight(1f) + ) { Text("Privacy Policy") } + OutlinedButton( + onClick = onNavigateToLicenses, + modifier = Modifier.weight(1f) + ) { Text("Licences") } } - // Privacy & Disclaimer — always visible at the very bottom, never buried OutlinedButton( - onClick = onNavigateToPrivacy, - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp) + onClick = onShowDisclaimer, + modifier = Modifier.fillMaxWidth() ) { - Icon( - imageVector = Icons.Outlined.Info, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) + Icon(Icons.Outlined.Info, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) - Text("Privacy Policy & Medical Disclaimer") + Text("Medical Disclaimer") } - - Spacer(Modifier.height(8.dp)) - } } } } -// ── Collapsible section card ────────────────────────────────────────────────── +// ── Sub-screen scaffold wrapper ─────────────────────────────────────────────── +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun CollapsibleSection( +private fun SettingsSubScreenScaffold( + title: String, + onBack: () -> Unit, + content: @Composable (androidx.compose.foundation.layout.PaddingValues) -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + }, + content = content + ) +} + +// ── Flat list item — navigation ──────────────────────────────────────────────── + +@Composable +private fun SettingsNavItem( title: String, + subtitle: String, icon: ImageVector, - summary: String, iconTint: Color? = null, - content: @Composable () -> Unit + onClick: () -> Unit ) { - var expanded by rememberSaveable { mutableStateOf(false) } - val chevronAngle by animateFloatAsState( - targetValue = if (expanded) 180f else 0f, - label = "chevron" - ) - - Card( - modifier = Modifier - .fillMaxWidth() - .animateContentSize(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Column { - // Header row — always visible, tappable to expand/collapse - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { expanded = !expanded } - .padding(horizontal = 16.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = iconTint ?: MaterialTheme.colorScheme.primary, - modifier = Modifier.size(22.dp) - ) - Column { - Text(title, style = MaterialTheme.typography.titleSmall) - if (!expanded && summary.isNotEmpty()) { - Text( - text = summary, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } + ListItem( + headlineContent = { Text(title) }, + supportingContent = { Text(subtitle) }, + leadingContent = { + Box(Modifier.size(24.dp), contentAlignment = Alignment.Center) { Icon( - imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = if (expanded) "Collapse" else "Expand", - modifier = Modifier - .size(20.dp) - .rotate(chevronAngle), - tint = MaterialTheme.colorScheme.onSurfaceVariant + imageVector = icon, + contentDescription = null, + tint = iconTint ?: MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) ) } - - // Expandable content - AnimatedVisibility( - visible = expanded, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Column( - modifier = Modifier - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - content() - } - } - } - } + }, + trailingContent = { + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + modifier = Modifier.clickable(onClick = onClick) + ) } // ── Section header label ────────────────────────────────────────────────────── -/** Subtle uppercase label that visually separates semantic groups. */ @Composable private fun SettingsSectionHeader(title: String) { Text( text = title.uppercase(), - style = MaterialTheme.typography.labelMedium, + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 4.dp, top = 8.dp, bottom = 2.dp) + modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 4.dp, end = 16.dp) ) } -// ── External link helper ─────────────────────────────────────────────────────── +// ── External link helper ────────────────────────────────────────────────────── -/** Opens [url] in the user's browser (or matching app) via an ACTION_VIEW intent. */ private fun openUrl(context: Context, url: String) { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } -// ── Navigation card (forward-chevron rows) ───────────────────────────────────── - -/** - * A tappable card that navigates to another screen. - * Visually distinct from [CollapsibleSection] — uses a ChevronRight instead of - * the animated expand/collapse arrow, signalling "go somewhere" not "open here". - */ -@Composable -private fun SettingsNavCard( - title: String, - subtitle: String, - icon: ImageVector, - onClick: () -> Unit -) { - Card( - modifier = Modifier.fillMaxWidth(), - onClick = onClick, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(22.dp) - ) - Column(modifier = Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.titleSmall) - Text(subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - // ── Support card ─────────────────────────────────────────────────────────────── -/** - * A visually distinct "buy me a coffee" style card with a filled Support button, - * set apart from the plain navigation rows to give the ask a gentle, friendly tone. - */ @Composable -private fun SupportCard(onSupport: () -> Unit) { +private fun SupportCard( + onSupport: () -> Unit, + modifier: Modifier = Modifier +) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.tertiaryContainer ) @@ -1234,7 +1402,7 @@ private fun SupportCard(onSupport: () -> Unit) { imageVector = Icons.Outlined.FavoriteBorder, contentDescription = null, tint = MaterialTheme.colorScheme.onTertiaryContainer, - modifier = Modifier.size(22.dp) + modifier = Modifier.size(24.dp) ) Column(modifier = Modifier.weight(1f)) { Text( @@ -1268,7 +1436,6 @@ private fun CompactThemePicker( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - // ── Mode segmented control ──────────────────────────────────────────── Text( "Mode", style = MaterialTheme.typography.labelMedium, @@ -1300,8 +1467,6 @@ private fun CompactThemePicker( .clickable { when (mode) { ThemeMode.SYSTEM -> { - // Preserve the current palette so "Auto" uses the - // user's chosen colour, not the hardcoded teal default. val palette = currentPalette ?: StandardPalette.TEAL onSelect(palette.systemTheme) } @@ -1337,7 +1502,6 @@ private fun CompactThemePicker( ) } } - // Divider between segments if (index < modes.size - 1) { Box( modifier = Modifier @@ -1349,7 +1513,6 @@ private fun CompactThemePicker( } } - // ── WCAG accessible toggle ──────────────────────────────────────────── Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -1367,7 +1530,6 @@ private fun CompactThemePicker( ) } - // ── Palette circles ─────────────────────────────────────────────────── Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( "Colour", @@ -1385,9 +1547,6 @@ private fun CompactThemePicker( palette = palette, selected = selected, onClick = { - // MAX_CONTRAST and BLUE_ORANGE_PAL don't have distinct - // light/dark/system variants — their lightTheme is always - // used (both lightTheme and darkTheme point to the same entry). val mode = currentMode ?: ThemeMode.LIGHT onSelect( when (mode) { @@ -1424,28 +1583,22 @@ private fun PaletteOption(palette: StandardPalette, selected: Boolean, onClick: modifier = Modifier.size(44.dp), contentAlignment = Alignment.Center ) { - // Diagonal-split circle: upper-left = primary, lower-right = accent. - // Shows the palette's two key colours at a glance instead of a flat - // single-colour disc. Canvas(modifier = Modifier.size(40.dp)) { val w = size.width val h = size.height val r = w / 2f clipPath(Path().apply { addOval(Rect(0f, 0f, w, h)) }) { - // Upper-left triangle — primary (period colour) drawPath( path = Path().apply { moveTo(0f, 0f); lineTo(w, 0f); lineTo(0f, h); close() }, color = primaryColor, ) - // Lower-right triangle — accent (ovulation / tertiary colour) drawPath( path = Path().apply { moveTo(w, 0f); lineTo(w, h); lineTo(0f, h); close() }, color = accentColor, ) } - // Selection / outline ring drawn over the fill val strokePx = if (selected) 3.dp.toPx() else 1.dp.toPx() drawCircle( color = if (selected) selRingColor else outlineColor, @@ -1474,27 +1627,25 @@ private fun PaletteOption(palette: StandardPalette, selected: Boolean, onClick: // ── App icon picker ─────────────────────────────────────────────────────────── -/** Preview background colour for each icon choice (matches the adaptive-icon background). */ private val AppIconChoice.previewBg: Color get() = when (this) { - AppIconChoice.DEFAULT -> Color(0xFFFFD5CBL) // original GoFlo icon background (light salmon) - AppIconChoice.LEAF -> Color(0xFFC8E6C9L) - AppIconChoice.MOON -> Color(0xFF1A237EL) // deep night-sky indigo - AppIconChoice.STAR -> Color(0xFF311B92L) // deep purple + AppIconChoice.DEFAULT -> Color(0xFFFFD5CBL) + AppIconChoice.LEAF -> Color(0xFFC8E6C9L) + AppIconChoice.MOON -> Color(0xFF1A237EL) + AppIconChoice.STAR -> Color(0xFF311B92L) } -/** Icon foreground / tint colour for each choice. */ private val AppIconChoice.previewFg: Color get() = when (this) { - AppIconChoice.DEFAULT -> Color(0xFFD9604AL) // original GoFlo coral drop - AppIconChoice.LEAF -> Color(0xFF2E7D32L) - AppIconChoice.MOON -> Color(0xFFFFF8E1L) // warm ivory crescent - AppIconChoice.STAR -> Color(0xFFFFD740L) // bright gold sparkle + AppIconChoice.DEFAULT -> Color(0xFFD9604AL) + AppIconChoice.LEAF -> Color(0xFF2E7D32L) + AppIconChoice.MOON -> Color(0xFFFFF8E1L) + AppIconChoice.STAR -> Color(0xFFFFD740L) } private val AppIconChoice.previewIcon: androidx.compose.ui.graphics.vector.ImageVector get() = when (this) { AppIconChoice.DEFAULT -> Icons.Filled.WaterDrop - AppIconChoice.LEAF -> Icons.Filled.Eco - AppIconChoice.MOON -> Icons.Filled.NightsStay - AppIconChoice.STAR -> Icons.Filled.AutoAwesome + AppIconChoice.LEAF -> Icons.Filled.Eco + AppIconChoice.MOON -> Icons.Filled.NightsStay + AppIconChoice.STAR -> Icons.Filled.AutoAwesome } @OptIn(ExperimentalLayoutApi::class) @@ -1506,7 +1657,6 @@ private fun AppIconPicker( ) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - // ── Default GoFlo icon ──────────────────────────────────────────────── FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -1518,7 +1668,6 @@ private fun AppIconPicker( ) } - // ── Discreet icons ──────────────────────────────────────────────────── HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Text( "Discreet icons", @@ -1543,7 +1692,6 @@ private fun AppIconPicker( } } - // ── Custom icon ─────────────────────────────────────────────────────── HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Text( "Your own icon", @@ -1572,11 +1720,11 @@ private fun IconChoiceCell( selected: Boolean, onClick: () -> Unit, ) { - val bg = choice.previewBg - val fg = choice.previewFg - val icon = choice.previewIcon - val border = if (selected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f) + val bg = choice.previewBg + val fg = choice.previewFg + val icon = choice.previewIcon + val border = if (selected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.outline.copy(alpha = 0.35f) val borderWidth = if (selected) 2.5.dp else 1.dp Column( @@ -1632,9 +1780,9 @@ private fun IconChoiceCell( @Composable private fun SwitchRow( - label: String, - subtitle: String, - checked: Boolean, + label: String, + subtitle: String, + checked: Boolean, onCheckedChange: (Boolean) -> Unit ) { Row( From 39ce53338ff495a9aabbb9e1915a8a3c91761006 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 12:33:44 +0000 Subject: [PATCH 2/6] Update changelog for navigation & Settings redesign (0.14.0-beta.4) --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc2dffd..2d6106b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,19 @@ Rules: --- +## [0.14.0-beta.4] - 2026-05-31 + +### Changed +- **Navigation — Settings moved to top app bar** — the Settings tab has been removed from the bottom navigation bar. A gear icon button in the top-right of the Home screen app bar now opens Settings, keeping the bottom bar focused on the three core destinations: Home, History, and Stats. +- **Settings — flat Material list layout** — the expandable accordion card layout has been replaced with a flat Material `ListItem` layout. Each section entry is a dense list row; tapping it opens a dedicated sub-screen with its own top app bar and back arrow, rather than expanding inline. +- **Settings — section headers** — headers (TRACKING, NOTIFICATIONS, etc.) are now `SemiBold`, rendered in the primary colour, with increased top padding (20 dp) to clearly separate grouped items. +- **Settings — navigation items** — items that open a sub-screen show a trailing Chevron Right icon. Items that control a binary setting show a trailing Material Switch that toggles immediately without navigating. +- **Settings — section dividers** — `HorizontalDivider` separates the major setting groups (Tracking, Notifications, Personalisation, Privacy & Data, Widgets, Help & Feedback, About). +- **Settings — icon alignment** — leading icons are constrained to 24×24 dp bounding boxes for consistent vertical alignment across all list items. +- **Settings — standardised background** — list items use the `surface` container colour, removing the "boxed" `surfaceVariant` card look of the old layout. + +--- + ## [0.14.0-beta.3] - 2026-05-31 ### Changed From 560f0647dfb0248807dabdec716be081a12a91e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 13:01:15 +0000 Subject: [PATCH 3/6] Renumber nav/settings redesign entry to 0.16.0-beta.1 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd15848..a82e26e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Rules: --- -## [0.15.0-beta.2] - 2026-05-31 +## [0.16.0-beta.1] - 2026-05-31 ### Changed - **Navigation — Settings moved to top app bar** — the Settings tab has been removed from the bottom navigation bar. A gear icon button in the top-right of the Home screen app bar now opens Settings, keeping the bottom bar focused on the core destinations: Home, History, (Dashboard,) and Stats. From e3993fdb436c52516e93e57629cc8c64cbf4a4dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 13:11:49 +0000 Subject: [PATCH 4/6] =?UTF-8?q?Bump=20versionCode=2047=E2=86=9248,=20versi?= =?UTF-8?q?onName=200.15.0-beta.1=E2=86=920.16.0-beta.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 19cd566..9837fa1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.mapgie.goflo" minSdk = 26 targetSdk = 34 - versionCode = 47 - versionName = "0.15.0-beta.1" + versionCode = 48 + versionName = "0.16.0-beta.1" } signingConfigs { From b2b491f6bf594bd3d816791546121f5e57cf19a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 13:39:34 +0000 Subject: [PATCH 5/6] feat: full-page export screen, widget category picker, denser settings menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export Data is now a dedicated scrollable sub-screen (was a height-capped AlertDialog that cropped content). All options — date range, include/exclude chips, JSON/CSV format — are fully accessible with a sticky Export button. - Quick Log (4×2) widget: users can now choose up to 4 specific categories to pin in the widget. Stored as comma-separated IDs in DataStore; falls back to the first four active categories when none are selected. - Status (2×1) widget: "Show data when PIN is set" toggle is now always visible in Widgets settings, disabled with a hint when no PIN is configured. - Section header top padding reduced 20 dp → 12 dp (accessibility-safe — only decorative spacing is reduced, touch targets are unchanged). - Bump: versionCode 48 → 49, versionName 0.16.0-beta.1 → 0.17.0-beta.1. https://claude.ai/code/session_0124n77X9sNuLnc3wEL4NUnU --- CHANGELOG.md | 12 + app/build.gradle.kts | 4 +- .../data/preferences/ReminderPreferences.kt | 11 + .../screens/settings/ExportOptionsDialog.kt | 2 +- .../ui/screens/settings/SettingsScreen.kt | 319 +++++++++++++++--- .../ui/screens/settings/SettingsViewModel.kt | 3 + .../com/mapgie/goflo/widget/QuickLogWidget.kt | 15 +- 7 files changed, 321 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a82e26e..64dd96b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,18 @@ Rules: --- +## [0.17.0-beta.1] - 2026-05-31 + +### Added +- **Settings — Export Data full-page screen** — "Export Data" now opens a dedicated scrollable full-page screen (replacing the cropped `AlertDialog`). All options — date range presets, include/exclude toggles, JSON/CSV format selection — are fully accessible with a sticky Export button at the bottom. +- **Widgets — Quick Log category picker** — users can now choose which specific categories appear in the Quick Log (4×2) widget (up to 4). If none are selected the first four active categories are shown automatically, preserving previous behaviour. +- **Widgets — Status widget privacy opt-in** — the "Show data when PIN is set" toggle is now always visible in the Widgets settings sub-screen, with clear explanatory copy. It is disabled (with a hint) when no PIN is set, so users know the option exists before they set one. + +### Changed +- **Settings — section header spacing** — top padding on section headers reduced from 20 dp to 12 dp for a slightly denser layout; touch-target sizes are unaffected (accessibility-safe). + +--- + ## [0.16.0-beta.1] - 2026-05-31 ### Changed diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9837fa1..afc7003 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.mapgie.goflo" minSdk = 26 targetSdk = 34 - versionCode = 48 - versionName = "0.16.0-beta.1" + versionCode = 49 + versionName = "0.17.0-beta.1" } signingConfigs { diff --git a/app/src/main/java/com/mapgie/goflo/data/preferences/ReminderPreferences.kt b/app/src/main/java/com/mapgie/goflo/data/preferences/ReminderPreferences.kt index 8155eb3..d1b3f44 100644 --- a/app/src/main/java/com/mapgie/goflo/data/preferences/ReminderPreferences.kt +++ b/app/src/main/java/com/mapgie/goflo/data/preferences/ReminderPreferences.kt @@ -66,6 +66,11 @@ data class AppPreferences( val dashboardEnabled: Boolean = false, /** JSON-encoded list of pinned stat combos. */ val pinnedStats: String = "", + /** + * Comma-separated TrackingCategory IDs to show in the Quick Log (4×2) widget. + * Empty string means "auto" — the first four active categories by displayOrder. + */ + val widgetCategoryIds: String = "", ) class AppPreferencesStore(private val context: Context) { @@ -89,6 +94,7 @@ class AppPreferencesStore(private val context: Context) { val WIDGET_DATA_VISIBLE = booleanPreferencesKey("widget_data_visible") val DASHBOARD_ENABLED = booleanPreferencesKey("dashboard_enabled") val PINNED_STATS = stringPreferencesKey("pinned_stats") + val WIDGET_CATEGORY_IDS = stringPreferencesKey("widget_category_ids") } val preferences: Flow = context.dataStore.data.map { prefs -> @@ -105,6 +111,7 @@ class AppPreferencesStore(private val context: Context) { widgetDataVisible = prefs[Keys.WIDGET_DATA_VISIBLE] ?: false, dashboardEnabled = prefs[Keys.DASHBOARD_ENABLED] ?: false, pinnedStats = prefs[Keys.PINNED_STATS] ?: "", + widgetCategoryIds = prefs[Keys.WIDGET_CATEGORY_IDS] ?: "", reminder = ReminderSettings( preperiodEnabled = prefs[Keys.PREPERIOD_ENABLED] ?: false, preperiodDaysBefore = prefs[Keys.PREPERIOD_DAYS] ?: 2, @@ -201,4 +208,8 @@ class AppPreferencesStore(private val context: Context) { suspend fun setPinnedStats(json: String) { context.dataStore.edit { it[Keys.PINNED_STATS] = json } } + + suspend fun setWidgetCategoryIds(ids: String) { + context.dataStore.edit { it[Keys.WIDGET_CATEGORY_IDS] = ids } + } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/settings/ExportOptionsDialog.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/settings/ExportOptionsDialog.kt index c9f796b..cf98268 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/settings/ExportOptionsDialog.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/settings/ExportOptionsDialog.kt @@ -239,7 +239,7 @@ fun ExportOptionsDialog( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ExportDatePickerDialog( +internal fun ExportDatePickerDialog( initial: LocalDate, minDate: LocalDate? = null, onConfirm: (LocalDate) -> Unit, diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt index 2e5f57a..958b8d2 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt @@ -52,6 +52,10 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.RadioButton +import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -99,8 +103,16 @@ import androidx.compose.ui.unit.dp import androidx.compose.material.icons.outlined.TouchApp import androidx.compose.material.icons.outlined.Tune import androidx.compose.material.icons.outlined.Widgets +import androidx.compose.material.icons.filled.Archive import com.mapgie.goflo.BuildConfig import com.mapgie.goflo.data.database.entities.TrackingCategory +import com.mapgie.goflo.data.export.DateRangePreset +import com.mapgie.goflo.data.export.ExportConfig +import com.mapgie.goflo.data.export.ExportFormat +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter import com.mapgie.goflo.data.preferences.AppPreferences import com.mapgie.goflo.data.preferences.ReminderSettings import com.mapgie.goflo.data.preferences.SecuritySettings @@ -245,10 +257,14 @@ private val AppTheme.summaryLabel: String get() = when (this) { AppTheme.BLUE_ORANGE -> "Blue & Orange" } +// ── Export date display format ───────────────────────────────────────────────── + +private val exportDateFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy") + // ── Sub-screen routing ──────────────────────────────────────────────────────── private enum class SettingsSubScreen { - NONE, CYCLE, QUICK_LOG, REMINDERS, APPEARANCE, SECURITY, DATA, WIDGETS, ABOUT + NONE, CYCLE, QUICK_LOG, REMINDERS, APPEARANCE, SECURITY, DATA, EXPORT_DATA, WIDGETS, ABOUT } // ── Main screen ─────────────────────────────────────────────────────────────── @@ -281,7 +297,6 @@ fun SettingsScreen( var showDisclaimer by rememberSaveable { mutableStateOf(false) } var showDeleteAllDialog by rememberSaveable { mutableStateOf(false) } var showChangelog by rememberSaveable { mutableStateOf(false) } - var showExportDialog by rememberSaveable { mutableStateOf(false) } var pendingImportUri by remember { mutableStateOf(null) } var showImportOptionsDialog by rememberSaveable { mutableStateOf(false) } var importResult by remember { mutableStateOf(null) } @@ -289,7 +304,10 @@ fun SettingsScreen( var removePinError by rememberSaveable { mutableStateOf(false) } BackHandler(currentSubScreen != SettingsSubScreen.NONE) { - currentSubScreen = SettingsSubScreen.NONE + currentSubScreen = when (currentSubScreen) { + SettingsSubScreen.EXPORT_DATA -> SettingsSubScreen.DATA + else -> SettingsSubScreen.NONE + } } val importFilePicker = rememberLauncherForActivityResult( @@ -406,17 +424,6 @@ fun SettingsScreen( ChangelogDialog(onDismiss = { showChangelog = false }) } - if (showExportDialog) { - ExportOptionsDialog( - categories = allCategoriesForExport, - onDismiss = { showExportDialog = false }, - onExport = { config -> - showExportDialog = false - viewModel.exportWithOptions(config) { intent -> context.startActivity(intent) } - } - ) - } - if (showTimePicker) { AlertDialog( onDismissRequest = { showTimePicker = false }, @@ -581,16 +588,25 @@ fun SettingsScreen( onBack = { currentSubScreen = SettingsSubScreen.NONE } ) SettingsSubScreen.DATA -> DataSubScreen( - onShowExportDialog = { showExportDialog = true }, + onNavigateToExport = { currentSubScreen = SettingsSubScreen.EXPORT_DATA }, onShowImportPicker = { importFilePicker.launch("application/json") }, onShowDeleteDialog = { showDeleteAllDialog = true }, onBack = { currentSubScreen = SettingsSubScreen.NONE } ) + SettingsSubScreen.EXPORT_DATA -> ExportDataSubScreen( + categories = allCategoriesForExport, + onExport = { config -> + viewModel.exportWithOptions(config) { intent -> context.startActivity(intent) } + currentSubScreen = SettingsSubScreen.DATA + }, + onBack = { currentSubScreen = SettingsSubScreen.DATA } + ) SettingsSubScreen.WIDGETS -> WidgetsSubScreen( - prefs = prefs, - security = security, - viewModel = viewModel, - onBack = { currentSubScreen = SettingsSubScreen.NONE } + prefs = prefs, + security = security, + categories = categories, + viewModel = viewModel, + onBack = { currentSubScreen = SettingsSubScreen.NONE } ) SettingsSubScreen.ABOUT -> AboutSubScreen( onNavigateToPrivacy = onNavigateToPrivacy, @@ -1139,7 +1155,7 @@ private fun SecuritySubScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun DataSubScreen( - onShowExportDialog: () -> Unit, + onNavigateToExport: () -> Unit, onShowImportPicker: () -> Unit, onShowDeleteDialog: () -> Unit, onBack: () -> Unit @@ -1161,7 +1177,7 @@ private fun DataSubScreen( ) OutlinedButton( - onClick = onShowExportDialog, + onClick = onNavigateToExport, modifier = Modifier.fillMaxWidth() ) { Text("Export Data") } @@ -1185,13 +1201,14 @@ private fun DataSubScreen( // ── Sub-screen: Widgets ─────────────────────────────────────────────────────── -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun WidgetsSubScreen( - prefs: AppPreferences, - security: SecuritySettings, - viewModel: SettingsViewModel, - onBack: () -> Unit + prefs: AppPreferences, + security: SecuritySettings, + categories: List, + viewModel: SettingsViewModel, + onBack: () -> Unit ) { SettingsSubScreenScaffold(title = "Home Screen Widgets", onBack = onBack) { padding -> Column( @@ -1209,16 +1226,42 @@ private fun WidgetsSubScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) + // ── GoFlo Status (2×1) ──────────────────────────────────────────── + HorizontalDivider() Text("GoFlo Status (2×1)", style = MaterialTheme.typography.labelMedium) Text( - "Shows your cycle status at a glance — current cycle day, days until " + - "your next period, or a privacy placeholder when PIN lock is active.", + "Shows your cycle status at a glance — current cycle day and days " + + "until your next period.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(Modifier.height(4.dp)) - Text("Quick Log (2×2)", style = MaterialTheme.typography.labelMedium) + ListItem( + headlineContent = { Text("Show data when PIN is set") }, + supportingContent = { + Text( + if (!security.hasPinSet) + "Set a PIN in Security & Privacy to enable this option" + else + "Show live cycle data on your home screen instead of the privacy placeholder" + ) + }, + trailingContent = { + Switch( + checked = prefs.widgetDataVisible, + onCheckedChange = null, + enabled = security.hasPinSet + ) + }, + modifier = if (security.hasPinSet) Modifier + .clickable { viewModel.setWidgetDataVisible(!prefs.widgetDataVisible) } + .semantics { role = Role.Switch } + else Modifier + ) + + // ── Quick Log (4×2) ─────────────────────────────────────────────── + HorizontalDivider() + Text("Quick Log (4×2)", style = MaterialTheme.typography.labelMedium) Text( "Shows up to four of your active tracking categories. Tap any button " + "to jump straight to today's log entry for that category.", @@ -1226,15 +1269,41 @@ private fun WidgetsSubScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) - if (security.hasPinSet) { - HorizontalDivider() - SwitchRow( - label = "Show data on GoFlo Status widget", - subtitle = "By default the widget hides cycle data when PIN lock is enabled. " + - "Turn this on to show live data on your home screen.", - checked = prefs.widgetDataVisible, - onCheckedChange = { viewModel.setWidgetDataVisible(it) } + if (categories.isNotEmpty()) { + Text( + "Choose which categories appear (up to 4). If none are chosen, " + + "the first four active categories are shown automatically.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) + + val selectedIds = prefs.widgetCategoryIds + .split(",") + .mapNotNull { it.trim().toLongOrNull() } + .filter { it > 0L } + .toSet() + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + categories.forEach { cat -> + val isSelected = cat.id in selectedIds + val atLimit = selectedIds.size >= 4 && !isSelected + FilterChip( + selected = isSelected, + enabled = !atLimit, + onClick = { + val newIds = if (isSelected) selectedIds - cat.id else selectedIds + cat.id + viewModel.setWidgetCategoryIds(newIds.joinToString(",")) + }, + label = { Text(cat.name) }, + leadingIcon = if (isSelected) { + { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(FilterChipDefaults.IconSize)) } + } else null + ) + } + } } } } @@ -1297,6 +1366,178 @@ private fun AboutSubScreen( } } +// ── Sub-screen: Export Data ─────────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun ExportDataSubScreen( + categories: List, + onExport: (ExportConfig) -> Unit, + onBack: () -> Unit, +) { + var format by rememberSaveable { mutableStateOf(ExportFormat.JSON) } + var datePreset by rememberSaveable { mutableStateOf(DateRangePreset.ALL_TIME) } + var customStart by remember { mutableStateOf(null) } + var customEnd by remember { mutableStateOf(null) } + var includePeriods by rememberSaveable { mutableStateOf(true) } + var selectedCategoryIds by remember { mutableStateOf(categories.map { it.id }.toSet()) } + var showStartPicker by rememberSaveable { mutableStateOf(false) } + var showEndPicker by rememberSaveable { mutableStateOf(false) } + + if (showStartPicker) { + ExportDatePickerDialog( + initial = customStart ?: LocalDate.now().minusYears(1), + onConfirm = { customStart = it; showStartPicker = false }, + onDismiss = { showStartPicker = false } + ) + } + if (showEndPicker) { + ExportDatePickerDialog( + initial = customEnd ?: LocalDate.now(), + minDate = customStart, + onConfirm = { customEnd = it; showEndPicker = false }, + onDismiss = { showEndPicker = false } + ) + } + + val customRangeReady = datePreset != DateRangePreset.CUSTOM || (customStart != null && customEnd != null) + val hasAnything = includePeriods || selectedCategoryIds.isNotEmpty() + + SettingsSubScreenScaffold(title = "Export Data", onBack = onBack) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // ── Date range ─────────────────────────────────────────────── + Text("Date range", style = MaterialTheme.typography.labelLarge) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + DateRangePreset.entries.forEach { preset -> + FilterChip( + selected = datePreset == preset, + onClick = { datePreset = preset }, + label = { Text(preset.label) } + ) + } + } + if (datePreset == DateRangePreset.CUSTOM) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = { showStartPicker = true }, + modifier = Modifier.weight(1f) + ) { Text(customStart?.format(exportDateFmt) ?: "From") } + Text("–") + OutlinedButton( + onClick = { showEndPicker = true }, + modifier = Modifier.weight(1f) + ) { Text(customEnd?.format(exportDateFmt) ?: "To") } + } + } + + HorizontalDivider() + + // ── What to include ────────────────────────────────────────── + Text("What to include", style = MaterialTheme.typography.labelLarge) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + FilterChip( + selected = includePeriods, + onClick = { includePeriods = !includePeriods }, + label = { Text("Periods") } + ) + categories.forEach { cat -> + val selected = cat.id in selectedCategoryIds + FilterChip( + selected = selected, + onClick = { + selectedCategoryIds = if (selected) + selectedCategoryIds - cat.id + else + selectedCategoryIds + cat.id + }, + label = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text(cat.name) + if (cat.isArchived) { + Icon( + Icons.Filled.Archive, + contentDescription = "Archived", + modifier = Modifier.size(12.dp) + ) + } + } + } + ) + } + } + + HorizontalDivider() + + // ── Format ─────────────────────────────────────────────────── + Text("Format", style = MaterialTheme.typography.labelLarge) + ExportFormat.entries.forEach { f -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { format = f } + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = format == f, onClick = { format = f }) + Spacer(Modifier.width(4.dp)) + Column { + Text(f.name, style = MaterialTheme.typography.bodyMedium) + Text( + when (f) { + ExportFormat.JSON -> "Full backup — can be re-imported" + ExportFormat.CSV -> "Spreadsheet-friendly flat table" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + // ── Sticky Export button ───────────────────────────────────────── + HorizontalDivider() + Box(modifier = Modifier.padding(16.dp)) { + Button( + onClick = { + onExport( + ExportConfig( + format = format, + includePeriods = includePeriods, + selectedCategoryIds = selectedCategoryIds, + dateRangePreset = datePreset, + customStartDate = customStart, + customEndDate = customEnd + ) + ) + }, + enabled = customRangeReady && hasAnything, + modifier = Modifier.fillMaxWidth() + ) { Text("Export") } + } + } + } +} + // ── Sub-screen scaffold wrapper ─────────────────────────────────────────────── @OptIn(ExperimentalMaterial3Api::class) @@ -1368,7 +1609,7 @@ private fun SettingsSectionHeader(title: String) { text = title.uppercase(), style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 4.dp, end = 16.dp) + modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp, end = 16.dp) ) } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsViewModel.kt index 42368af..196895f 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsViewModel.kt @@ -79,6 +79,9 @@ class SettingsViewModel( fun setWidgetDataVisible(visible: Boolean) = viewModelScope.launch { store.setWidgetDataVisible(visible) } + fun setWidgetCategoryIds(ids: String) = + viewModelScope.launch { store.setWidgetCategoryIds(ids) } + // ── Theme ────────────────────────────────────────────────────────────────── fun setTheme(theme: String) = viewModelScope.launch { store.setTheme(theme) } diff --git a/app/src/main/java/com/mapgie/goflo/widget/QuickLogWidget.kt b/app/src/main/java/com/mapgie/goflo/widget/QuickLogWidget.kt index 7df635a..e25d0fa 100644 --- a/app/src/main/java/com/mapgie/goflo/widget/QuickLogWidget.kt +++ b/app/src/main/java/com/mapgie/goflo/widget/QuickLogWidget.kt @@ -110,12 +110,21 @@ class QuickLogWidget : AppWidgetProvider() { widgetId: Int, ) { val app = context.applicationContext as GoFloApplication - val categories = app.trackingRepository + val prefs = app.preferencesStore.preferences.first() + val preferredIds = prefs.widgetCategoryIds + .split(",") + .mapNotNull { it.trim().toLongOrNull() } + .filter { it > 0L } + .toSet() + val allActive = app.trackingRepository .getActiveCategories() .first() .filter { !it.isSystem && !it.isArchived } - .sortedBy { it.displayOrder } - .take(4) + val categories = if (preferredIds.isEmpty()) { + allActive.sortedBy { it.displayOrder }.take(4) + } else { + preferredIds.mapNotNull { id -> allActive.firstOrNull { it.id == id } }.take(4) + } val views = RemoteViews(context.packageName, R.layout.widget_quick_log) From 6ec89440e4f667b754ebddd584a2af79e83da377 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 13:46:52 +0000 Subject: [PATCH 6/6] fix: restore Settings list scroll position when navigating back from sub-screens Hoist the main list ScrollState to SettingsScreen so it lives outside the `when` routing block. Previously, SettingsMainList left the composition when any sub-screen opened, dropping rememberScrollState() back to 0 on return. https://claude.ai/code/session_0124n77X9sNuLnc3wEL4NUnU --- .../com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt index 958b8d2..88d4e8c 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt @@ -25,6 +25,7 @@ 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.ScrollState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -290,6 +291,7 @@ fun SettingsScreen( AppIconChoice.valueOf(prefs.iconChoice) }.getOrDefault(AppIconChoice.DEFAULT) + val mainListScrollState = rememberScrollState() var currentSubScreen by rememberSaveable { mutableStateOf(SettingsSubScreen.NONE) } var showTimePicker by rememberSaveable { mutableStateOf(false) } var showRemovePinDialog by rememberSaveable { mutableStateOf(false) } @@ -549,6 +551,7 @@ fun SettingsScreen( categories = categories, currentTheme = currentTheme, reminder = reminder, + scrollState = mainListScrollState, onNavigateTo = { currentSubScreen = it }, onNavigateToManageCategories = onNavigateToManageCategories, onOpenDiscord = { openUrl(context, "https://discord.gg/xphnQCZeYq") }, @@ -628,6 +631,7 @@ private fun SettingsMainList( categories: List, currentTheme: AppTheme, reminder: ReminderSettings, + scrollState: ScrollState, onNavigateTo: (SettingsSubScreen) -> Unit, onNavigateToManageCategories: () -> Unit, onOpenDiscord: () -> Unit, @@ -673,7 +677,7 @@ private fun SettingsMainList( modifier = Modifier .fillMaxWidth() .weight(1f) - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) .padding(bottom = padding.calculateBottomPadding()) ) {