From 329ca525c1ffe78643f722eb38a0a2a8cb16bb79 Mon Sep 17 00:00:00 2001 From: usamaiqb <83345144+usamaiqb@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:00:31 +0500 Subject: [PATCH 1/3] feat(settings): redesign screen with expressive UI and live preview Adopt Material 3 expressive grouped rows (24dp/4dp corners, 2dp gaps) with leading icons, subtitles, and animated value badges. Replace the static top bar with a collapsing LargeTopAppBar, restyle bottom-sheet pickers with highlighted selection, and add haptic feedback on selections and slider ticks. Add a live reader preview card that renders sample prose and code with the actual reader colors, fonts, size, spacing, and alignment, with a light/dark preview toggle. Add a GitHub repo link to the About section. --- .../markreader/ui/screens/SettingsScreen.kt | 830 +++++++++++++----- 1 file changed, 592 insertions(+), 238 deletions(-) diff --git a/app/src/main/java/com/markreader/ui/screens/SettingsScreen.kt b/app/src/main/java/com/markreader/ui/screens/SettingsScreen.kt index f6476d8..b84d6db 100644 --- a/app/src/main/java/com/markreader/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/markreader/ui/screens/SettingsScreen.kt @@ -2,7 +2,14 @@ package com.markreader.ui.screens import android.app.Application import android.os.Build -import androidx.compose.foundation.clickable +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,27 +20,34 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.BrightnessAuto -import androidx.compose.material.icons.automirrored.filled.FormatAlignLeft -import androidx.compose.material.icons.filled.FormatAlignJustify -import androidx.compose.material.icons.filled.LightMode -import androidx.compose.material.icons.filled.DarkMode -import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.FormatAlignLeft +import androidx.compose.material.icons.automirrored.rounded.MenuBook +import androidx.compose.material.icons.automirrored.rounded.OpenInNew +import androidx.compose.material.icons.rounded.BrightnessAuto +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Code +import androidx.compose.material.icons.rounded.DarkMode +import androidx.compose.material.icons.rounded.FormatAlignJustify +import androidx.compose.material.icons.rounded.FormatLineSpacing +import androidx.compose.material.icons.rounded.FormatSize +import androidx.compose.material.icons.rounded.LightMode +import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material.icons.rounded.TextFields import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow @@ -41,8 +55,10 @@ import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -53,34 +69,49 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.markreader.data.AppThemeModePreference import com.markreader.data.CodeFontPreference -import com.markreader.data.ReadingFontPreference import com.markreader.data.ReaderThemePreference +import com.markreader.data.ReadingFontPreference import com.markreader.data.TextAlignmentPreference +import com.markreader.data.UserPreferences +import com.markreader.ui.theme.CodeFontFamily +import com.markreader.ui.theme.ReadingFontFamily import java.util.Locale import kotlin.math.abs +private const val GithubRepoUrl = "https://github.com/usamaiqb/mark-reader" + // ── Segment shape helpers ────────────────────────────────────────────────────── private enum class SegmentPosition { Single, First, Middle, Last } -private val SegmentRadius = 16.dp +private val GroupOuterRadius = 24.dp +private val GroupInnerRadius = 4.dp private fun segmentShape(position: SegmentPosition): Shape = when (position) { - SegmentPosition.Single -> RoundedCornerShape(SegmentRadius) + SegmentPosition.Single -> RoundedCornerShape(GroupOuterRadius) SegmentPosition.First -> RoundedCornerShape( - topStart = SegmentRadius, topEnd = SegmentRadius, - bottomStart = 0.dp, bottomEnd = 0.dp + topStart = GroupOuterRadius, topEnd = GroupOuterRadius, + bottomStart = GroupInnerRadius, bottomEnd = GroupInnerRadius ) - SegmentPosition.Middle -> RoundedCornerShape(0.dp) + SegmentPosition.Middle -> RoundedCornerShape(GroupInnerRadius) SegmentPosition.Last -> RoundedCornerShape( - topStart = 0.dp, topEnd = 0.dp, - bottomStart = SegmentRadius, bottomEnd = SegmentRadius + topStart = GroupInnerRadius, topEnd = GroupInnerRadius, + bottomStart = GroupOuterRadius, bottomEnd = GroupOuterRadius ) } @@ -90,120 +121,402 @@ private fun segmentShape(position: SegmentPosition): Shape = when (position) { private fun SectionHeader(title: String) { Text( text = title, - style = MaterialTheme.typography.labelLarge, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) + modifier = Modifier.padding(start = 12.dp, bottom = 8.dp) ) } @Composable -private fun SegmentedItem( +private fun SettingsGroup(content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + content() + } +} + +@Composable +private fun SettingsSurface( position: SegmentPosition, - modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, content: @Composable () -> Unit ) { val shape = segmentShape(position) - Column(modifier = modifier) { - if (onClick != null) { - Surface( - onClick = onClick, - modifier = Modifier.fillMaxWidth(), - shape = shape, - color = MaterialTheme.colorScheme.surfaceContainerLow - ) { - Box(modifier = Modifier.padding(16.dp)) { - content() - } - } - } else { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = shape, - color = MaterialTheme.colorScheme.surfaceContainerLow - ) { - Box(modifier = Modifier.padding(16.dp)) { - content() - } + if (onClick != null) { + Surface( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + shape = shape, + color = MaterialTheme.colorScheme.surfaceContainer + ) { + Box(modifier = Modifier.padding(16.dp)) { + content() } } - if (position == SegmentPosition.First || position == SegmentPosition.Middle) { - HorizontalDivider( - color = MaterialTheme.colorScheme.surfaceContainerLowest, - thickness = 1.dp - ) + } else { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = shape, + color = MaterialTheme.colorScheme.surfaceContainer + ) { + Box(modifier = Modifier.padding(16.dp)) { + content() + } } } } +@Composable +private fun RowLeadingIcon(icon: ImageVector) { + Box( + modifier = Modifier + .padding(end = 16.dp) + .size(24.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary + ) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun InlineDropdownRow( - label: String, +private fun PickerSettingsRow( + position: SegmentPosition, + icon: ImageVector, + title: String, + subtitle: String, + options: List, + optionLabel: (T) -> String, selectedLabel: String, + onSelect: (T) -> Unit +) { + var showSheet by remember { mutableStateOf(false) } + + SettingsSurface(position = position, onClick = { showSheet = true }) { + Row(verticalAlignment = Alignment.CenterVertically) { + RowLeadingIcon(icon) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(10.dp)) + Surface( + color = MaterialTheme.colorScheme.surfaceContainerLowest, + shape = CircleShape + ) { + AnimatedContent( + targetState = selectedLabel, + transitionSpec = { + fadeIn(tween(150)) togetherWith fadeOut(tween(150)) + }, + label = "valueBadge" + ) { label -> + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } + } + } + } + + if (showSheet) { + OptionSheet( + title = title, + options = options, + optionLabel = optionLabel, + selectedLabel = selectedLabel, + onSelect = onSelect, + onDismiss = { showSheet = false } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun OptionSheet( + title: String, options: List, optionLabel: (T) -> String, + selectedLabel: String, onSelect: (T) -> Unit, - showSheet: Boolean, - onShowSheet: (Boolean) -> Unit + onDismiss: () -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val haptics = LocalHapticFeedback.current - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) { Text( - text = label, - style = MaterialTheme.typography.bodyLarge + text = title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp) ) - Text( - text = selectedLabel, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary - ) - } - - if (showSheet) { - ModalBottomSheet( - onDismissRequest = { onShowSheet(false) }, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surfaceContainerLow + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - text = label, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp) - ) options.forEach { option -> val selected = optionLabel(option) == selectedLabel - ListItem( - headlineContent = { + Surface( + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + onSelect(option) + onDismiss() + }, + shape = RoundedCornerShape(20.dp), + color = if (selected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + modifier = Modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp) + ) { Text( text = optionLabel(option), - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (selected) FontWeight.Bold else null, + color = if (selected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.weight(1f) ) - }, - trailingContent = { if (selected) { Icon( - imageVector = Icons.Filled.Check, + imageVector = Icons.Rounded.Check, contentDescription = "Selected", - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.onPrimaryContainer ) } - }, - colors = ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ), - modifier = Modifier.clickable { - onSelect(option) - onShowSheet(false) } + } + } + } + Spacer(modifier = Modifier.height(24.dp)) + } +} + +@Composable +private fun SliderSettingsRow( + position: SegmentPosition, + icon: ImageVector, + title: String, + valueLabel: String, + value: Float, + onValueChange: (Float) -> Unit, + onValueChangeFinished: () -> Unit, + valueRange: ClosedFloatingPointRange, + steps: Int +) { + val haptics = LocalHapticFeedback.current + + SettingsSurface(position = position) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + RowLeadingIcon(icon) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + Text( + text = valueLabel, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + Slider( + value = value, + onValueChange = { newValue -> + if (newValue != value) { + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + } + onValueChange(newValue) + }, + onValueChangeFinished = onValueChangeFinished, + valueRange = valueRange, + steps = steps, + colors = SliderDefaults.colors( + activeTrackColor = MaterialTheme.colorScheme.primary, + thumbColor = MaterialTheme.colorScheme.primary + ) + ) + } + } +} + +@Composable +private fun SegmentedSettingsRow( + position: SegmentPosition, + icon: ImageVector, + title: String, + subtitle: String, + control: @Composable () -> Unit +) { + SettingsSurface(position = position) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + RowLeadingIcon(icon) + Column { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) + control() + } + } +} + +// ── Reader preview ───────────────────────────────────────────────────────────── + +@Composable +private fun ReaderPreviewCard( + preferences: UserPreferences, + fontSizeSp: Float, + lineHeight: Float +) { + val context = LocalContext.current + val isSystemDark = isSystemInDarkTheme() + val haptics = LocalHapticFeedback.current + + val dynamicLightScheme = remember(context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) dynamicLightColorScheme(context) else null + } + val dynamicDarkScheme = remember(context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) dynamicDarkColorScheme(context) else null + } + val (lightReaderColors, darkReaderColors) = resolveReaderColors( + readerLightTheme = preferences.readerLightTheme, + readerDarkTheme = preferences.readerDarkTheme, + dynamicLightScheme = dynamicLightScheme, + dynamicDarkScheme = dynamicDarkScheme + ) + val isBaseDark = when (preferences.appThemeMode) { + AppThemeModePreference.System -> isSystemDark + AppThemeModePreference.Light -> false + AppThemeModePreference.Dark -> true + } + var previewDark by rememberSaveable(isBaseDark) { mutableStateOf(isBaseDark) } + val colors = if (previewDark) darkReaderColors else lightReaderColors + + val surfaceColor by animateColorAsState(colors.surface, label = "previewSurface") + val contentColor by animateColorAsState(colors.content, label = "previewContent") + val mutedColor by animateColorAsState(colors.muted, label = "previewMuted") + val tonalColor by animateColorAsState(colors.tonalContainer, label = "previewTonal") + + val readingFamily = preferences.readingFont.fontFamily() + val codeFamily = preferences.codeFont.fontFamily() + val textAlign = when (preferences.textAlignment) { + TextAlignmentPreference.Left -> TextAlign.Start + TextAlignmentPreference.Justified -> TextAlign.Justify + } + + Surface( + shape = RoundedCornerShape(GroupOuterRadius), + color = surfaceColor, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(20.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Preview", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = mutedColor, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { + haptics.performHapticFeedback( + if (previewDark) HapticFeedbackType.ToggleOff else HapticFeedbackType.ToggleOn + ) + previewDark = !previewDark + } + ) { + Icon( + imageVector = if (previewDark) Icons.Rounded.DarkMode else Icons.Rounded.LightMode, + contentDescription = "Toggle preview between light and dark reader theme", + tint = mutedColor + ) + } + } + Text( + text = "The Art of Reading", + fontFamily = readingFamily, + fontWeight = FontWeight.Bold, + fontSize = (fontSizeSp * 1.2f).sp, + lineHeight = (fontSizeSp * 1.2f * lineHeight).sp, + color = contentColor + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Comfortable typography makes long documents a pleasure to read. Changes apply instantly.", + fontFamily = readingFamily, + fontSize = fontSizeSp.sp, + lineHeight = (fontSizeSp * lineHeight).sp, + textAlign = textAlign, + color = contentColor, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + Surface( + color = tonalColor, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "fun read(file: Uri) = markdown.render(file)", + fontFamily = codeFamily, + fontSize = (fontSizeSp * 0.85f).sp, + lineHeight = (fontSizeSp * 0.85f * lineHeight).sp, + color = contentColor, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp) ) } - Spacer(modifier = Modifier.height(24.dp)) } } } @@ -242,22 +555,27 @@ fun SettingsScreen( mutableFloatStateOf(preferences.lineHeight) } + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, topBar = { - TopAppBar( + LargeTopAppBar( title = { Text(text = "Settings") }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back" ) } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest - ) + colors = TopAppBarDefaults.largeTopAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), + scrollBehavior = scrollBehavior ) } ) { paddingValues: PaddingValues -> @@ -266,20 +584,19 @@ fun SettingsScreen( .fillMaxSize() .padding(paddingValues) .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) + .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) ) { // ── Appearance ───────────────────────────────────────────── Column { SectionHeader("Appearance") - - SegmentedItem(position = SegmentPosition.Single) { - Column { - Text( - text = "App theme", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(bottom = 8.dp) - ) + SettingsGroup { + SegmentedSettingsRow( + position = SegmentPosition.Single, + icon = Icons.Rounded.Palette, + title = "App theme", + subtitle = "Overall look of the app" + ) { AppThemeModePreferenceControl( selected = preferences.appThemeMode, onSelect = viewModel::setAppThemeMode @@ -292,82 +609,70 @@ fun SettingsScreen( Column { SectionHeader("Reader") - var showReaderLight by remember { mutableStateOf(false) } - var showReaderDark by remember { mutableStateOf(false) } - var showReadingFont by remember { mutableStateOf(false) } - var showCodeFont by remember { mutableStateOf(false) } + ReaderPreviewCard( + preferences = preferences, + fontSizeSp = fontSizeDraft, + lineHeight = lineHeightDraft + ) + Spacer(modifier = Modifier.height(8.dp)) - SegmentedItem( - position = SegmentPosition.First, - onClick = { showReaderLight = true } - ) { - InlineDropdownRow( - label = "Reader light theme", - selectedLabel = preferences.readerLightTheme.displayLabel(), + SettingsGroup { + PickerSettingsRow( + position = SegmentPosition.First, + icon = Icons.Rounded.LightMode, + title = "Reader light theme", + subtitle = "Used when reading in light mode", options = listOf( ReaderThemePreference.Light, ReaderThemePreference.Sepia ), optionLabel = { it.displayLabel() }, - onSelect = viewModel::setReaderLightTheme, - showSheet = showReaderLight, - onShowSheet = { showReaderLight = it } + selectedLabel = preferences.readerLightTheme.displayLabel(), + onSelect = viewModel::setReaderLightTheme ) - } - SegmentedItem( - position = SegmentPosition.Middle, - onClick = { showReaderDark = true } - ) { - InlineDropdownRow( - label = "Reader dark theme", - selectedLabel = preferences.readerDarkTheme.displayLabel(), + PickerSettingsRow( + position = SegmentPosition.Middle, + icon = Icons.Rounded.DarkMode, + title = "Reader dark theme", + subtitle = "Used when reading in dark mode", options = listOf( ReaderThemePreference.Dark, ReaderThemePreference.Amoled ), optionLabel = { it.displayLabel() }, - onSelect = viewModel::setReaderDarkTheme, - showSheet = showReaderDark, - onShowSheet = { showReaderDark = it } + selectedLabel = preferences.readerDarkTheme.displayLabel(), + onSelect = viewModel::setReaderDarkTheme ) - } - SegmentedItem( - position = SegmentPosition.Middle, - onClick = { showReadingFont = true } - ) { - InlineDropdownRow( - label = "Reading font", - selectedLabel = preferences.readingFont.displayLabel(), + PickerSettingsRow( + position = SegmentPosition.Middle, + icon = Icons.Rounded.TextFields, + title = "Reading font", + subtitle = "Typeface for prose and headings", options = listOf( ReadingFontPreference.Merriweather, ReadingFontPreference.SystemSerif ), optionLabel = { it.displayLabel() }, - onSelect = viewModel::setReadingFont, - showSheet = showReadingFont, - onShowSheet = { showReadingFont = it } + selectedLabel = preferences.readingFont.displayLabel(), + onSelect = viewModel::setReadingFont ) - } - SegmentedItem( - position = SegmentPosition.Middle, - onClick = { showCodeFont = true } - ) { - InlineDropdownRow( - label = "Code font", - selectedLabel = preferences.codeFont.displayLabel(), + PickerSettingsRow( + position = SegmentPosition.Middle, + icon = Icons.Rounded.Code, + title = "Code font", + subtitle = "Typeface for code blocks and source view", options = listOf( CodeFontPreference.JetBrainsMono, CodeFontPreference.SystemMono ), optionLabel = { it.displayLabel() }, - onSelect = viewModel::setCodeFont, - showSheet = showCodeFont, - onShowSheet = { showCodeFont = it } + selectedLabel = preferences.codeFont.displayLabel(), + onSelect = viewModel::setCodeFont ) - } - SegmentedItem(position = SegmentPosition.Middle) { - SliderPreference( - label = "Font size", + SliderSettingsRow( + position = SegmentPosition.Middle, + icon = Icons.Rounded.FormatSize, + title = "Font size", valueLabel = "${fontSizeDraft.toInt()}sp", value = fontSizeDraft, onValueChange = { fontSizeDraft = it }, @@ -377,12 +682,12 @@ fun SettingsScreen( } }, valueRange = 12f..24f, - steps = 12 + steps = 11 ) - } - SegmentedItem(position = SegmentPosition.Middle) { - SliderPreference( - label = "Line height", + SliderSettingsRow( + position = SegmentPosition.Middle, + icon = Icons.Rounded.FormatLineSpacing, + title = "Line height", valueLabel = "${String.format(Locale.US, "%.1f", lineHeightDraft)}x", value = lineHeightDraft, onValueChange = { lineHeightDraft = it }, @@ -394,36 +699,98 @@ fun SettingsScreen( valueRange = 1.2f..2.0f, steps = 7 ) - } - SegmentedItem(position = SegmentPosition.Last) { - AlignmentPreference( - selected = preferences.textAlignment, - onSelect = viewModel::setTextAlignment - ) + SegmentedSettingsRow( + position = SegmentPosition.Last, + icon = Icons.AutoMirrored.Rounded.FormatAlignLeft, + title = "Text alignment", + subtitle = "How paragraphs are laid out" + ) { + AlignmentPreference( + selected = preferences.textAlignment, + onSelect = viewModel::setTextAlignment + ) + } } } // ── About ────────────────────────────────────────────────── Column { SectionHeader("About") - SegmentedItem(position = SegmentPosition.Single) { - Column { - Text( - text = "MarkReader", - style = MaterialTheme.typography.titleMedium - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = versionLabel, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "A focused Markdown reader for local files.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + SettingsGroup { + val uriHandler = LocalUriHandler.current + SettingsSurface( + position = SegmentPosition.First, + onClick = { uriHandler.openUri(GithubRepoUrl) } + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = CircleShape, + modifier = Modifier.size(48.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Rounded.Code, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "GitHub", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = GithubRepoUrl.removePrefix("https://"), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + imageVector = Icons.AutoMirrored.Rounded.OpenInNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + SettingsSurface(position = SegmentPosition.Last) { + Row(verticalAlignment = Alignment.CenterVertically) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = CircleShape, + modifier = Modifier.size(48.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.MenuBook, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + text = "MarkReader", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = versionLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "A focused Markdown reader for local files.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } } } @@ -439,28 +806,38 @@ private fun AppThemeModePreferenceControl( selected: AppThemeModePreference, onSelect: (AppThemeModePreference) -> Unit ) { + val haptics = LocalHapticFeedback.current SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { SegmentedButton( shape = SegmentedButtonDefaults.itemShape(index = 0, count = 3), - onClick = { onSelect(AppThemeModePreference.System) }, + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + onSelect(AppThemeModePreference.System) + }, selected = selected == AppThemeModePreference.System, - icon = { Icon(Icons.Filled.BrightnessAuto, contentDescription = null) } + icon = { Icon(Icons.Rounded.BrightnessAuto, contentDescription = null) } ) { Text("System") } SegmentedButton( shape = SegmentedButtonDefaults.itemShape(index = 1, count = 3), - onClick = { onSelect(AppThemeModePreference.Light) }, + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + onSelect(AppThemeModePreference.Light) + }, selected = selected == AppThemeModePreference.Light, - icon = { Icon(Icons.Filled.LightMode, contentDescription = null) } + icon = { Icon(Icons.Rounded.LightMode, contentDescription = null) } ) { Text("Light") } SegmentedButton( shape = SegmentedButtonDefaults.itemShape(index = 2, count = 3), - onClick = { onSelect(AppThemeModePreference.Dark) }, + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + onSelect(AppThemeModePreference.Dark) + }, selected = selected == AppThemeModePreference.Dark, - icon = { Icon(Icons.Filled.DarkMode, contentDescription = null) } + icon = { Icon(Icons.Rounded.DarkMode, contentDescription = null) } ) { Text("Dark") } @@ -473,14 +850,18 @@ private fun AlignmentPreference( selected: TextAlignmentPreference, onSelect: (TextAlignmentPreference) -> Unit ) { + val haptics = LocalHapticFeedback.current SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { SegmentedButton( shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), - onClick = { onSelect(TextAlignmentPreference.Left) }, + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + onSelect(TextAlignmentPreference.Left) + }, selected = selected == TextAlignmentPreference.Left, icon = { Icon( - imageVector = Icons.AutoMirrored.Filled.FormatAlignLeft, + imageVector = Icons.AutoMirrored.Rounded.FormatAlignLeft, contentDescription = null ) } @@ -489,11 +870,14 @@ private fun AlignmentPreference( } SegmentedButton( shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), - onClick = { onSelect(TextAlignmentPreference.Justified) }, + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + onSelect(TextAlignmentPreference.Justified) + }, selected = selected == TextAlignmentPreference.Justified, icon = { Icon( - imageVector = Icons.Filled.FormatAlignJustify, + imageVector = Icons.Rounded.FormatAlignJustify, contentDescription = null ) } @@ -503,46 +887,6 @@ private fun AlignmentPreference( } } -@Composable -private fun SliderPreference( - label: String, - valueLabel: String, - value: Float, - onValueChange: (Float) -> Unit, - onValueChangeFinished: () -> Unit, - valueRange: ClosedFloatingPointRange, - steps: Int -) { - Column { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) - ) - Text( - text = valueLabel, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary - ) - } - Slider( - value = value, - onValueChange = onValueChange, - onValueChangeFinished = onValueChangeFinished, - valueRange = valueRange, - steps = steps, - colors = SliderDefaults.colors( - activeTrackColor = MaterialTheme.colorScheme.primary, - thumbColor = MaterialTheme.colorScheme.primary - ) - ) - } -} - // ── Display label helpers ────────────────────────────────────────────────────── private fun ReaderThemePreference.displayLabel(): String = when (this) { @@ -561,3 +905,13 @@ private fun CodeFontPreference.displayLabel(): String = when (this) { CodeFontPreference.JetBrainsMono -> "JetBrains Mono" CodeFontPreference.SystemMono -> "System monospace" } + +private fun ReadingFontPreference.fontFamily(): FontFamily = when (this) { + ReadingFontPreference.Merriweather -> ReadingFontFamily + ReadingFontPreference.SystemSerif -> FontFamily.Serif +} + +private fun CodeFontPreference.fontFamily(): FontFamily = when (this) { + CodeFontPreference.JetBrainsMono -> CodeFontFamily + CodeFontPreference.SystemMono -> FontFamily.Monospace +} From 639a164b4176a3860c20119f11e59bf8fb08542d Mon Sep 17 00:00:00 2001 From: usamaiqb <83345144+usamaiqb@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:40:51 +0500 Subject: [PATCH 2/3] feat(home): add recent files list Persist recently opened files in DataStore (capped at 15, de-duped by URI, evicted entries release their persistable permission grants). Opens are recorded on successful viewer loads and new-file creation; transient ACTION_VIEW files without persisted grants are skipped. Home shows quick actions plus a grouped recents list with file-type icons, relative timestamps, remove buttons, and item animations. The hero layout remains as the empty state. Extract the expressive grouped-list shapes into a shared ui/components module. --- .../markreader/data/RecentFilesRepository.kt | 141 +++++++ .../ui/components/ExpressiveList.kt | 52 +++ .../com/markreader/ui/screens/HomeScreen.kt | 387 ++++++++++++++---- .../markreader/ui/screens/HomeViewModel.kt | 26 ++ .../markreader/ui/screens/ViewerViewModel.kt | 4 + 5 files changed, 540 insertions(+), 70 deletions(-) create mode 100644 app/src/main/java/com/markreader/data/RecentFilesRepository.kt create mode 100644 app/src/main/java/com/markreader/ui/components/ExpressiveList.kt diff --git a/app/src/main/java/com/markreader/data/RecentFilesRepository.kt b/app/src/main/java/com/markreader/data/RecentFilesRepository.kt new file mode 100644 index 0000000..1524194 --- /dev/null +++ b/app/src/main/java/com/markreader/data/RecentFilesRepository.kt @@ -0,0 +1,141 @@ +package com.markreader.data + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import org.json.JSONArray +import org.json.JSONObject +import java.io.IOException + +private const val DATASTORE_NAME = "markreader_recent_files" + +private val Context.recentFilesDataStore: DataStore by preferencesDataStore( + name = DATASTORE_NAME +) + +data class RecentFile( + val uri: String, + val displayName: String, + val lastOpenedMillis: Long +) + +class RecentFilesRepository private constructor(private val context: Context) { + private object Keys { + val RECENT_FILES = stringPreferencesKey("recent_files") + } + + val recentFiles: Flow> = context.recentFilesDataStore.data + .catch { exception -> + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { prefs -> parse(prefs[Keys.RECENT_FILES]) } + + /** + * Records a file open, de-duplicating by URI (most recent open wins) and capping + * the list. Skipped when the app holds no persistable read grant for the URI + * (e.g. transient ACTION_VIEW documents that we could not reopen later anyway). + */ + suspend fun recordOpen( + uriString: String, + displayName: String, + timestamp: Long = System.currentTimeMillis() + ) { + if (displayName.isBlank()) return + if (!hasPersistedReadPermission(uriString)) return + context.recentFilesDataStore.edit { prefs -> + val current = parse(prefs[Keys.RECENT_FILES]) + val updated = listOf(RecentFile(uriString, displayName, timestamp)) + + current.filterNot { it.uri == uriString } + updated.drop(MAX_RECENT_FILES).forEach { releasePermission(it.uri) } + prefs[Keys.RECENT_FILES] = serialize(updated.take(MAX_RECENT_FILES)) + } + } + + suspend fun remove(uriString: String) { + context.recentFilesDataStore.edit { prefs -> + val current = parse(prefs[Keys.RECENT_FILES]) + val kept = current.filterNot { it.uri == uriString } + if (kept.size != current.size) { + releasePermission(uriString) + prefs[Keys.RECENT_FILES] = serialize(kept) + } + } + } + + private fun hasPersistedReadPermission(uriString: String): Boolean { + return context.contentResolver.persistedUriPermissions.any { + it.isReadPermission && it.uri.toString() == uriString + } + } + + private fun releasePermission(uriString: String) { + val grant = context.contentResolver.persistedUriPermissions + .firstOrNull { it.uri.toString() == uriString } ?: return + var flags = 0 + if (grant.isReadPermission) flags = flags or Intent.FLAG_GRANT_READ_URI_PERMISSION + if (grant.isWritePermission) flags = flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + try { + context.contentResolver.releasePersistableUriPermission(Uri.parse(uriString), flags) + } catch (ex: SecurityException) { + // Grant already revoked by the provider. + } catch (ex: IllegalArgumentException) { + // Grant no longer held. + } + } + + private fun parse(json: String?): List { + if (json.isNullOrBlank()) return emptyList() + return runCatching { + val array = JSONArray(json) + buildList { + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + val uri = obj.optString("uri") + val name = obj.optString("name") + if (uri.isNotBlank() && name.isNotBlank()) { + add(RecentFile(uri, name, obj.optLong("openedAt"))) + } + } + }.sortedByDescending { it.lastOpenedMillis } + }.getOrDefault(emptyList()) + } + + private fun serialize(files: List): String { + val array = JSONArray() + files.forEach { file -> + array.put( + JSONObject() + .put("uri", file.uri) + .put("name", file.displayName) + .put("openedAt", file.lastOpenedMillis) + ) + } + return array.toString() + } + + companion object { + private const val MAX_RECENT_FILES = 15 + + @Volatile + private var instance: RecentFilesRepository? = null + + fun getInstance(context: Context): RecentFilesRepository { + return instance ?: synchronized(this) { + instance ?: RecentFilesRepository(context.applicationContext).also { instance = it } + } + } + } +} diff --git a/app/src/main/java/com/markreader/ui/components/ExpressiveList.kt b/app/src/main/java/com/markreader/ui/components/ExpressiveList.kt new file mode 100644 index 0000000..186172b --- /dev/null +++ b/app/src/main/java/com/markreader/ui/components/ExpressiveList.kt @@ -0,0 +1,52 @@ +package com.markreader.ui.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * Material 3 expressive "connected pill" grouped-list shapes: items in a group share + * tight inner corners while the first/last get large outer corners, separated by a + * small gap instead of dividers. + */ +enum class SegmentPosition { Single, First, Middle, Last } + +val GroupOuterRadius = 24.dp +val GroupInnerRadius = 4.dp + +fun segmentShape(position: SegmentPosition): Shape = when (position) { + SegmentPosition.Single -> RoundedCornerShape(GroupOuterRadius) + SegmentPosition.First -> RoundedCornerShape( + topStart = GroupOuterRadius, topEnd = GroupOuterRadius, + bottomStart = GroupInnerRadius, bottomEnd = GroupInnerRadius + ) + SegmentPosition.Middle -> RoundedCornerShape(GroupInnerRadius) + SegmentPosition.Last -> RoundedCornerShape( + topStart = GroupInnerRadius, topEnd = GroupInnerRadius, + bottomStart = GroupOuterRadius, bottomEnd = GroupOuterRadius + ) +} + +fun segmentPositionFor(index: Int, count: Int): SegmentPosition = when { + count == 1 -> SegmentPosition.Single + index == 0 -> SegmentPosition.First + index == count - 1 -> SegmentPosition.Last + else -> SegmentPosition.Middle +} + +@Composable +fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 12.dp, bottom = 8.dp) + ) +} diff --git a/app/src/main/java/com/markreader/ui/screens/HomeScreen.kt b/app/src/main/java/com/markreader/ui/screens/HomeScreen.kt index 6ec609d..46319ee 100644 --- a/app/src/main/java/com/markreader/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/markreader/ui/screens/HomeScreen.kt @@ -1,44 +1,72 @@ package com.markreader.ui.screens -import androidx.compose.foundation.Image +import android.text.format.DateUtils import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.FolderOpen -import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.automirrored.rounded.MenuBook +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Code +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material.icons.rounded.FolderOpen +import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.platform.LocalContext +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import com.markreader.R import com.markreader.OPENABLE_MIME_TYPES +import com.markreader.R +import com.markreader.data.RecentFile +import com.markreader.ui.components.SectionHeader +import com.markreader.ui.components.SegmentPosition +import com.markreader.ui.components.segmentPositionFor +import com.markreader.ui.components.segmentShape import kotlinx.coroutines.flow.collectLatest @OptIn(ExperimentalMaterial3Api::class) @@ -110,6 +138,7 @@ fun HomeScreen( } } + val recentFiles by viewModel.recentFiles.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() Scaffold( @@ -117,93 +146,311 @@ fun HomeScreen( containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, topBar = { LargeTopAppBar( - title = { - Text( - text = "MarkReader", - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - }, + title = { Text(text = "MarkReader") }, actions = { IconButton(onClick = onOpenSettings) { Icon( - imageVector = Icons.Filled.Settings, + imageVector = Icons.Rounded.Settings, contentDescription = "Settings" ) } }, - colors = TopAppBarDefaults.topAppBarColors( + colors = TopAppBarDefaults.largeTopAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, - scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer + scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh ), scrollBehavior = scrollBehavior ) } ) { paddingValues: PaddingValues -> - Column( + val recents = recentFiles + when { + recents == null -> { + // Waiting for the first DataStore emission; avoid flashing the empty state. + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) + } + recents.isEmpty() -> { + EmptyHomeContent( + paddingValues = paddingValues, + onOpenFile = viewModel::onOpenFileRequested, + onNewFile = viewModel::onNewFileRequested + ) + } + else -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp) + ) { + item(key = "quick_actions") { + QuickActionsRow( + onOpenFile = viewModel::onOpenFileRequested, + onNewFile = viewModel::onNewFileRequested + ) + Spacer(modifier = Modifier.height(24.dp)) + } + item(key = "recent_header") { + SectionHeader("Recent") + } + itemsIndexed( + items = recents, + key = { _, file -> file.uri } + ) { index, file -> + RecentFileRow( + file = file, + position = segmentPositionFor(index, recents.size), + onClick = { viewModel.onRecentFileClicked(file.uri) }, + onRemove = { viewModel.onRemoveRecentFile(file.uri) }, + modifier = Modifier + .animateItem( + fadeInSpec = tween(durationMillis = 180), + fadeOutSpec = tween(durationMillis = 120), + placementSpec = tween(durationMillis = 200) + ) + .padding(bottom = 2.dp) + ) + } + } + } + } + } +} + +// ── Quick actions ────────────────────────────────────────────────────────────── + +@Composable +private fun QuickActionsRow( + onOpenFile: () -> Unit, + onNewFile: () -> Unit +) { + val haptics = LocalHapticFeedback.current + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + ) { + Button( + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + onOpenFile() + }, modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally + .weight(1f) + .fillMaxHeight(), + shape = RoundedCornerShape( + topStart = 28.dp, topEnd = 12.dp, + bottomStart = 28.dp, bottomEnd = 12.dp + ), + contentPadding = PaddingValues(horizontal = 16.dp) ) { - Spacer(modifier = Modifier.weight(1f)) - - Image( - painter = painterResource(id = R.drawable.ic_logo), - contentDescription = "MarkReader app icon", - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), - modifier = Modifier.size(128.dp) - ) - Text( - text = "Open a file", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(top = 24.dp) + Icon( + imageVector = Icons.Rounded.FolderOpen, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) ) - Text( - text = "Choose a Markdown or source code file to start reading.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = 8.dp) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text(text = "Open file") + } + Spacer(modifier = Modifier.width(8.dp)) + FilledTonalButton( + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + onNewFile() + }, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + shape = RoundedCornerShape( + topStart = 12.dp, topEnd = 28.dp, + bottomStart = 12.dp, bottomEnd = 28.dp + ), + contentPadding = PaddingValues(horizontal = 16.dp) + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) ) - Button( - onClick = viewModel::onOpenFileRequested, - shape = MaterialTheme.shapes.extraLarge, - contentPadding = PaddingValues(horizontal = 32.dp, vertical = 16.dp), - modifier = Modifier.padding(top = 32.dp) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text(text = "New file") + } + } +} + +// ── Recent file row ──────────────────────────────────────────────────────────── + +@Composable +private fun RecentFileRow( + file: RecentFile, + position: SegmentPosition, + onClick: () -> Unit, + onRemove: () -> Unit, + modifier: Modifier = Modifier +) { + val haptics = LocalHapticFeedback.current + val relativeTime = remember(file.lastOpenedMillis) { + DateUtils.getRelativeTimeSpanString( + file.lastOpenedMillis, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ).toString() + } + + Surface( + onClick = onClick, + shape = segmentShape(position), + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = CircleShape, + modifier = Modifier.size(48.dp) ) { - Icon( - imageVector = Icons.Filled.FolderOpen, - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize) + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = fileTypeIcon(file.displayName), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + Spacer(modifier = Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = file.displayName, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) - Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Open File", - style = MaterialTheme.typography.titleMedium + text = "${fileTypeLabel(file.displayName)} · $relativeTime", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 ) } - OutlinedButton( - onClick = viewModel::onNewFileRequested, - shape = MaterialTheme.shapes.extraLarge, - contentPadding = PaddingValues(horizontal = 32.dp, vertical = 16.dp), - modifier = Modifier.padding(top = 12.dp) + IconButton( + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + onRemove() + }, + modifier = Modifier.size(36.dp) ) { Icon( - imageVector = Icons.Filled.Add, - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize) - ) - Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) - Text( - text = "New File", - style = MaterialTheme.typography.titleMedium + imageVector = Icons.Rounded.Close, + contentDescription = "Remove from recents", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) ) } + } + } +} - Spacer(modifier = Modifier.weight(1.3f)) +// ── Empty state ──────────────────────────────────────────────────────────────── + +@Composable +private fun EmptyHomeContent( + paddingValues: PaddingValues, + onOpenFile: () -> Unit, + onNewFile: () -> Unit +) { + val haptics = LocalHapticFeedback.current + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(1f)) + + Image( + painter = painterResource(id = R.drawable.ic_logo), + contentDescription = "MarkReader app icon", + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), + modifier = Modifier.size(128.dp) + ) + Text( + text = "Open a file", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 24.dp) + ) + Text( + text = "Choose a Markdown or source code file to start reading. Recent files will show up here.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp) + ) + Button( + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + onOpenFile() + }, + shape = MaterialTheme.shapes.extraLarge, + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 16.dp), + modifier = Modifier.padding(top = 32.dp) + ) { + Icon( + imageVector = Icons.Rounded.FolderOpen, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text( + text = "Open File", + style = MaterialTheme.typography.titleMedium + ) + } + OutlinedButton( + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + onNewFile() + }, + shape = MaterialTheme.shapes.extraLarge, + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 16.dp), + modifier = Modifier.padding(top = 12.dp) + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text( + text = "New File", + style = MaterialTheme.typography.titleMedium + ) } + + Spacer(modifier = Modifier.weight(1.3f)) } } + +// ── File type helpers ────────────────────────────────────────────────────────── + +private fun fileExtension(displayName: String): String = + displayName.substringAfterLast('.', "").lowercase() + +private fun fileTypeLabel(displayName: String): String = when (val ext = fileExtension(displayName)) { + "md", "markdown" -> "Markdown" + "txt", "" -> "Text" + else -> ext.uppercase() +} + +private fun fileTypeIcon(displayName: String): ImageVector = when (fileExtension(displayName)) { + "md", "markdown" -> Icons.AutoMirrored.Rounded.MenuBook + "txt", "" -> Icons.Rounded.Description + else -> Icons.Rounded.Code +} diff --git a/app/src/main/java/com/markreader/ui/screens/HomeViewModel.kt b/app/src/main/java/com/markreader/ui/screens/HomeViewModel.kt index e93156b..6fd735b 100644 --- a/app/src/main/java/com/markreader/ui/screens/HomeViewModel.kt +++ b/app/src/main/java/com/markreader/ui/screens/HomeViewModel.kt @@ -5,14 +5,22 @@ import android.net.Uri import android.provider.OpenableColumns import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.markreader.data.RecentFile +import com.markreader.data.RecentFilesRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class HomeViewModel(application: Application) : AndroidViewModel(application) { + private val recentFilesRepository = RecentFilesRepository.getInstance(application) + private val _launchPickerSignal = MutableSharedFlow(extraBufferCapacity = 1) val launchPickerSignal: SharedFlow = _launchPickerSignal.asSharedFlow() @@ -25,6 +33,11 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) { private val _navigateToEditor = MutableSharedFlow>(extraBufferCapacity = 1) val navigateToEditor: SharedFlow> = _navigateToEditor.asSharedFlow() + /** Null until the first DataStore emission, so the UI can avoid flashing the empty state. */ + val recentFiles: StateFlow?> = recentFilesRepository.recentFiles + .map, List?> { it } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + fun onOpenFileRequested() { _launchPickerSignal.tryEmit(Unit) } @@ -40,12 +53,25 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) { } } + fun onRecentFileClicked(uriString: String) { + viewModelScope.launch { + _navigateToViewer.emit(uriString) + } + } + + fun onRemoveRecentFile(uriString: String) { + viewModelScope.launch { + recentFilesRepository.remove(uriString) + } + } + fun onNewFileCreated(uri: Uri?) { if (uri == null) return viewModelScope.launch { val displayName = resolveDisplayName(uri) val ext = displayName.substringAfterLast('.', "").lowercase() val isMarkdown = ext == "md" || ext == "markdown" + recentFilesRepository.recordOpen(uri.toString(), displayName) _navigateToEditor.emit(Pair(uri.toString(), isMarkdown)) } } diff --git a/app/src/main/java/com/markreader/ui/screens/ViewerViewModel.kt b/app/src/main/java/com/markreader/ui/screens/ViewerViewModel.kt index f1bdde4..496c20c 100644 --- a/app/src/main/java/com/markreader/ui/screens/ViewerViewModel.kt +++ b/app/src/main/java/com/markreader/ui/screens/ViewerViewModel.kt @@ -16,6 +16,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.markreader.data.AppThemeModePreference import com.markreader.data.PreferencesRepository +import com.markreader.data.RecentFilesRepository import com.markreader.data.UserPreferences import com.markreader.ui.markdown.MarkwonRenderer import com.markreader.ui.markdown.SourceCodeRenderer @@ -75,6 +76,7 @@ class ViewerViewModel( ) private val repository = PreferencesRepository.getInstance(application) + private val recentFilesRepository = RecentFilesRepository.getInstance(application) private var systemDarkTheme = false private val rendererCache = mutableMapOf() private val sourceCodeRendererCache = mutableMapOf() @@ -177,6 +179,8 @@ class ViewerViewModel( return@launch } + recentFilesRepository.recordOpen(uriString, fileName) + val textHash = markdown.hashCode() if (renderCacheTextHash != textHash) { renderCacheTextHash = textHash From af5bcdfef299382e74e265d1b8f8dc76793a281d Mon Sep 17 00:00:00 2001 From: usamaiqb <83345144+usamaiqb@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:41:08 +0500 Subject: [PATCH 3/3] feat(settings): add use dynamic colors toggle New switch row in Appearance (Android 12+ only, below App theme) that gates Material You colors app-wide: the app theme, reader Light/Dark palettes, and the settings preview card all fall back to the static brand palette when disabled. Persisted as use_dynamic_colors, default on. Settings adopts the shared grouped-list components extracted for the home screen. --- .../main/java/com/markreader/MainActivity.kt | 5 +- .../markreader/data/PreferencesRepository.kt | 7 + .../com/markreader/data/UserPreferences.kt | 1 + .../markreader/ui/screens/SettingsScreen.kt | 126 +++++++++++++----- .../ui/screens/SettingsViewModel.kt | 4 + .../com/markreader/ui/screens/ViewerScreen.kt | 16 ++- 6 files changed, 117 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/com/markreader/MainActivity.kt b/app/src/main/java/com/markreader/MainActivity.kt index 75f281a..8b0996b 100644 --- a/app/src/main/java/com/markreader/MainActivity.kt +++ b/app/src/main/java/com/markreader/MainActivity.kt @@ -37,7 +37,10 @@ class MainActivity : ComponentActivity() { .getInstance(applicationContext) .preferences .collectAsStateWithLifecycle(initialValue = UserPreferences()) - MarkReaderTheme(theme = preferences.appThemeMode.toAppTheme()) { + MarkReaderTheme( + theme = preferences.appThemeMode.toAppTheme(), + dynamicColor = preferences.useDynamicColors + ) { MarkReaderApp( externalUri = externalUri, externalUriNonce = externalUriNonce, diff --git a/app/src/main/java/com/markreader/data/PreferencesRepository.kt b/app/src/main/java/com/markreader/data/PreferencesRepository.kt index 339948c..497ef8a 100644 --- a/app/src/main/java/com/markreader/data/PreferencesRepository.kt +++ b/app/src/main/java/com/markreader/data/PreferencesRepository.kt @@ -3,6 +3,7 @@ package com.markreader.data import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey @@ -22,6 +23,7 @@ class PreferencesRepository private constructor(private val context: Context) { private object Keys { val LEGACY_THEME = stringPreferencesKey("theme") val APP_THEME_MODE = stringPreferencesKey("app_theme_mode") + val USE_DYNAMIC_COLORS = booleanPreferencesKey("use_dynamic_colors") val READER_LIGHT_THEME = stringPreferencesKey("reader_light_theme") val READER_DARK_THEME = stringPreferencesKey("reader_dark_theme") val READING_FONT = stringPreferencesKey("reading_font") @@ -74,6 +76,7 @@ class PreferencesRepository private constructor(private val context: Context) { UserPreferences( appThemeMode = appThemeMode, + useDynamicColors = prefs[Keys.USE_DYNAMIC_COLORS] ?: true, readerLightTheme = readerLightTheme, readerDarkTheme = readerDarkTheme, readingFont = readingFont, @@ -88,6 +91,10 @@ class PreferencesRepository private constructor(private val context: Context) { context.dataStore.edit { it[Keys.APP_THEME_MODE] = themeMode.name } } + suspend fun setUseDynamicColors(enabled: Boolean) { + context.dataStore.edit { it[Keys.USE_DYNAMIC_COLORS] = enabled } + } + suspend fun setReaderLightTheme(theme: ReaderThemePreference) { val safeTheme = if (theme == ReaderThemePreference.Sepia) ReaderThemePreference.Sepia else ReaderThemePreference.Light context.dataStore.edit { it[Keys.READER_LIGHT_THEME] = safeTheme.name } diff --git a/app/src/main/java/com/markreader/data/UserPreferences.kt b/app/src/main/java/com/markreader/data/UserPreferences.kt index 8a04506..08da062 100644 --- a/app/src/main/java/com/markreader/data/UserPreferences.kt +++ b/app/src/main/java/com/markreader/data/UserPreferences.kt @@ -30,6 +30,7 @@ enum class TextAlignmentPreference { data class UserPreferences( val appThemeMode: AppThemeModePreference = AppThemeModePreference.System, + val useDynamicColors: Boolean = true, val readerLightTheme: ReaderThemePreference = ReaderThemePreference.Light, val readerDarkTheme: ReaderThemePreference = ReaderThemePreference.Dark, val readingFont: ReadingFontPreference = ReadingFontPreference.Merriweather, diff --git a/app/src/main/java/com/markreader/ui/screens/SettingsScreen.kt b/app/src/main/java/com/markreader/ui/screens/SettingsScreen.kt index b84d6db..af8fb37 100644 --- a/app/src/main/java/com/markreader/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/markreader/ui/screens/SettingsScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.material.icons.automirrored.rounded.MenuBook import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.material.icons.rounded.BrightnessAuto import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Code import androidx.compose.material.icons.rounded.DarkMode import androidx.compose.material.icons.rounded.FormatAlignJustify @@ -41,6 +42,7 @@ import androidx.compose.material.icons.rounded.FormatSize import androidx.compose.material.icons.rounded.LightMode import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.TextFields +import androidx.compose.material.icons.rounded.Wallpaper import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -54,6 +56,8 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.dynamicDarkColorScheme @@ -68,7 +72,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -88,6 +91,10 @@ import com.markreader.data.ReaderThemePreference import com.markreader.data.ReadingFontPreference import com.markreader.data.TextAlignmentPreference import com.markreader.data.UserPreferences +import com.markreader.ui.components.GroupOuterRadius +import com.markreader.ui.components.SectionHeader +import com.markreader.ui.components.SegmentPosition +import com.markreader.ui.components.segmentShape import com.markreader.ui.theme.CodeFontFamily import com.markreader.ui.theme.ReadingFontFamily import java.util.Locale @@ -95,39 +102,8 @@ import kotlin.math.abs private const val GithubRepoUrl = "https://github.com/usamaiqb/mark-reader" -// ── Segment shape helpers ────────────────────────────────────────────────────── - -private enum class SegmentPosition { Single, First, Middle, Last } - -private val GroupOuterRadius = 24.dp -private val GroupInnerRadius = 4.dp - -private fun segmentShape(position: SegmentPosition): Shape = when (position) { - SegmentPosition.Single -> RoundedCornerShape(GroupOuterRadius) - SegmentPosition.First -> RoundedCornerShape( - topStart = GroupOuterRadius, topEnd = GroupOuterRadius, - bottomStart = GroupInnerRadius, bottomEnd = GroupInnerRadius - ) - SegmentPosition.Middle -> RoundedCornerShape(GroupInnerRadius) - SegmentPosition.Last -> RoundedCornerShape( - topStart = GroupInnerRadius, topEnd = GroupInnerRadius, - bottomStart = GroupOuterRadius, bottomEnd = GroupOuterRadius - ) -} - // ── Reusable composables ─────────────────────────────────────────────────────── -@Composable -private fun SectionHeader(title: String) { - Text( - text = title, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 12.dp, bottom = 8.dp) - ) -} - @Composable private fun SettingsGroup(content: @Composable () -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { @@ -321,6 +297,63 @@ private fun OptionSheet( } } +@Composable +private fun SwitchSettingsRow( + position: SegmentPosition, + icon: ImageVector, + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + val haptics = LocalHapticFeedback.current + val toggle: (Boolean) -> Unit = { newValue -> + haptics.performHapticFeedback( + if (newValue) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff + ) + onCheckedChange(newValue) + } + + SettingsSurface(position = position, onClick = { toggle(!checked) }) { + Row(verticalAlignment = Alignment.CenterVertically) { + RowLeadingIcon(icon) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Switch( + checked = checked, + onCheckedChange = toggle, + thumbContent = { + AnimatedContent( + targetState = checked, + transitionSpec = { + fadeIn(tween(100)) togetherWith fadeOut(tween(100)) + }, + label = "switchThumbIcon" + ) { isChecked -> + Icon( + imageVector = if (isChecked) Icons.Rounded.Check else Icons.Rounded.Close, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize) + ) + } + } + ) + } + } +} + @Composable private fun SliderSettingsRow( position: SegmentPosition, @@ -419,11 +452,19 @@ private fun ReaderPreviewCard( val isSystemDark = isSystemInDarkTheme() val haptics = LocalHapticFeedback.current - val dynamicLightScheme = remember(context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) dynamicLightColorScheme(context) else null + val dynamicLightScheme = remember(context, preferences.useDynamicColors) { + if (preferences.useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dynamicLightColorScheme(context) + } else { + null + } } - val dynamicDarkScheme = remember(context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) dynamicDarkColorScheme(context) else null + val dynamicDarkScheme = remember(context, preferences.useDynamicColors) { + if (preferences.useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dynamicDarkColorScheme(context) + } else { + null + } } val (lightReaderColors, darkReaderColors) = resolveReaderColors( readerLightTheme = preferences.readerLightTheme, @@ -590,9 +631,10 @@ fun SettingsScreen( // ── Appearance ───────────────────────────────────────────── Column { SectionHeader("Appearance") + val supportsDynamicColors = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S SettingsGroup { SegmentedSettingsRow( - position = SegmentPosition.Single, + position = if (supportsDynamicColors) SegmentPosition.First else SegmentPosition.Single, icon = Icons.Rounded.Palette, title = "App theme", subtitle = "Overall look of the app" @@ -602,6 +644,16 @@ fun SettingsScreen( onSelect = viewModel::setAppThemeMode ) } + if (supportsDynamicColors) { + SwitchSettingsRow( + position = SegmentPosition.Last, + icon = Icons.Rounded.Wallpaper, + title = "Use dynamic colors", + subtitle = "Tint the app from your wallpaper", + checked = preferences.useDynamicColors, + onCheckedChange = viewModel::setUseDynamicColors + ) + } } } diff --git a/app/src/main/java/com/markreader/ui/screens/SettingsViewModel.kt b/app/src/main/java/com/markreader/ui/screens/SettingsViewModel.kt index b118f95..c506be3 100644 --- a/app/src/main/java/com/markreader/ui/screens/SettingsViewModel.kt +++ b/app/src/main/java/com/markreader/ui/screens/SettingsViewModel.kt @@ -27,6 +27,10 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application viewModelScope.launch { repository.setAppThemeMode(theme) } } + fun setUseDynamicColors(enabled: Boolean) { + viewModelScope.launch { repository.setUseDynamicColors(enabled) } + } + fun setReaderLightTheme(theme: ReaderThemePreference) { viewModelScope.launch { repository.setReaderLightTheme(theme) } } diff --git a/app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt b/app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt index 3dba549..5af3eb2 100644 --- a/app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt +++ b/app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt @@ -165,11 +165,19 @@ fun ViewerScreen( else -> "Rendered mode" } - val dynamicLightScheme = remember(context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) dynamicLightColorScheme(context) else null + val dynamicLightScheme = remember(context, prefs.useDynamicColors) { + if (prefs.useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dynamicLightColorScheme(context) + } else { + null + } } - val dynamicDarkScheme = remember(context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) dynamicDarkColorScheme(context) else null + val dynamicDarkScheme = remember(context, prefs.useDynamicColors) { + if (prefs.useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dynamicDarkColorScheme(context) + } else { + null + } } val (lightReaderColors, darkReaderColors) = resolveReaderColors(