diff --git a/MC1/MC1App.swift b/MC1/MC1App.swift index b5adbc687..3c70f65a1 100644 --- a/MC1/MC1App.swift +++ b/MC1/MC1App.swift @@ -91,6 +91,8 @@ struct MC1App: App { .environment(\.appTheme, appState.themeService.current) .tint(appState.themeService.current.chromeTint) .preferredColorScheme(appState.themeService.effectiveColorScheme) + .uiScale(appState.themeService.uiTextSizePreference.uiScale) + .environment(\.dynamicTypeSize, appState.themeService.uiTextSizePreference.dynamicTypeSize ?? .large) #if !SIDELOAD .task(id: ObjectIdentifier(appState)) { await appState.storeState.service.load() } #endif diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 034a7fae7..eca03b76e 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -3848,6 +3848,18 @@ public enum L10n { /// System public static let system = L10n.tr("Settings", "Appearance.Scheme.System", fallback: "System") } + public enum TextSize { + /// Text Size + public static let header = L10n.tr("Settings", "Appearance.TextSize.Header", fallback: "Text Size") + /// Large + public static let large = L10n.tr("Settings", "Appearance.TextSize.Large", fallback: "Large") + /// Default + public static let system = L10n.tr("Settings", "Appearance.TextSize.System", fallback: "Default") + /// Extra Large + public static let xLarge = L10n.tr("Settings", "Appearance.TextSize.XLarge", fallback: "Extra Large") + /// Extra Extra Large + public static let xxLarge = L10n.tr("Settings", "Appearance.TextSize.XXLarge", fallback: "Extra Extra Large") + } public enum Themes { /// Choose a theme public static let header = L10n.tr("Settings", "Appearance.Themes.Header", fallback: "Choose a theme") diff --git a/MC1/Resources/Localization/de.lproj/Settings.strings b/MC1/Resources/Localization/de.lproj/Settings.strings index 0f3f99085..deb43d45b 100644 --- a/MC1/Resources/Localization/de.lproj/Settings.strings +++ b/MC1/Resources/Localization/de.lproj/Settings.strings @@ -1720,6 +1720,11 @@ "Appearance.Scheme.System" = "System"; "Appearance.Scheme.Light" = "Hell"; "Appearance.Scheme.Dark" = "Dunkel"; +"Appearance.TextSize.Header" = "Textgröße"; +"Appearance.TextSize.System" = "Standard"; +"Appearance.TextSize.Large" = "Groß"; +"Appearance.TextSize.XLarge" = "Sehr groß"; +"Appearance.TextSize.XXLarge" = "Extra groß"; "Appearance.Themes.Header" = "Design auswählen"; "Appearance.Themes.Selected" = "Ausgewählt"; "Appearance.MoreThemes.Link" = "Weitere Designs kaufen"; diff --git a/MC1/Resources/Localization/en.lproj/Settings.strings b/MC1/Resources/Localization/en.lproj/Settings.strings index ab661642c..2cfa66b58 100644 --- a/MC1/Resources/Localization/en.lproj/Settings.strings +++ b/MC1/Resources/Localization/en.lproj/Settings.strings @@ -1717,6 +1717,11 @@ "Appearance.Scheme.System" = "System"; "Appearance.Scheme.Light" = "Light"; "Appearance.Scheme.Dark" = "Dark"; +"Appearance.TextSize.Header" = "Text Size"; +"Appearance.TextSize.System" = "Default"; +"Appearance.TextSize.Large" = "Large"; +"Appearance.TextSize.XLarge" = "Extra Large"; +"Appearance.TextSize.XXLarge" = "Extra Extra Large"; "Appearance.Themes.Header" = "Choose a theme"; "Appearance.Themes.Selected" = "Selected"; "Appearance.MoreThemes.Link" = "Purchase More Themes"; diff --git a/MC1/Resources/Localization/es.lproj/Settings.strings b/MC1/Resources/Localization/es.lproj/Settings.strings index 44f79d5a6..01fb94010 100644 --- a/MC1/Resources/Localization/es.lproj/Settings.strings +++ b/MC1/Resources/Localization/es.lproj/Settings.strings @@ -1720,6 +1720,11 @@ "Appearance.Scheme.System" = "Sistema"; "Appearance.Scheme.Light" = "Claro"; "Appearance.Scheme.Dark" = "Oscuro"; +"Appearance.TextSize.Header" = "Tamaño de texto"; +"Appearance.TextSize.System" = "Predeterminado"; +"Appearance.TextSize.Large" = "Grande"; +"Appearance.TextSize.XLarge" = "Muy grande"; +"Appearance.TextSize.XXLarge" = "Extra grande"; "Appearance.Themes.Header" = "Elige un tema"; "Appearance.Themes.Selected" = "Seleccionado"; "Appearance.MoreThemes.Link" = "Comprar más temas"; diff --git a/MC1/Resources/Localization/fr.lproj/Settings.strings b/MC1/Resources/Localization/fr.lproj/Settings.strings index 9c8d4a592..31a068b4a 100644 --- a/MC1/Resources/Localization/fr.lproj/Settings.strings +++ b/MC1/Resources/Localization/fr.lproj/Settings.strings @@ -1720,6 +1720,11 @@ "Appearance.Scheme.System" = "Système"; "Appearance.Scheme.Light" = "Clair"; "Appearance.Scheme.Dark" = "Sombre"; +"Appearance.TextSize.Header" = "Taille du texte"; +"Appearance.TextSize.System" = "Par défaut"; +"Appearance.TextSize.Large" = "Grande"; +"Appearance.TextSize.XLarge" = "Très grande"; +"Appearance.TextSize.XXLarge" = "Extra grande"; "Appearance.Themes.Header" = "Choisir un thème"; "Appearance.Themes.Selected" = "Sélectionné"; "Appearance.MoreThemes.Link" = "Acheter d'autres thèmes"; diff --git a/MC1/Resources/Localization/it.lproj/Settings.strings b/MC1/Resources/Localization/it.lproj/Settings.strings index f6a7579bc..ba4c1fc5c 100644 --- a/MC1/Resources/Localization/it.lproj/Settings.strings +++ b/MC1/Resources/Localization/it.lproj/Settings.strings @@ -1718,6 +1718,11 @@ "Appearance.Scheme.System" = "Sistema"; "Appearance.Scheme.Light" = "Chiara"; "Appearance.Scheme.Dark" = "Scura"; +"Appearance.TextSize.Header" = "Dimensione testo"; +"Appearance.TextSize.System" = "Predefinita"; +"Appearance.TextSize.Large" = "Grande"; +"Appearance.TextSize.XLarge" = "Molto grande"; +"Appearance.TextSize.XXLarge" = "Extra grande"; "Appearance.Themes.Header" = "Scegli un tema"; "Appearance.Themes.Selected" = "Selezionato"; "Appearance.MoreThemes.Link" = "Acquista altri temi"; diff --git a/MC1/Resources/Localization/nl.lproj/Settings.strings b/MC1/Resources/Localization/nl.lproj/Settings.strings index 6a660cf15..30a286503 100644 --- a/MC1/Resources/Localization/nl.lproj/Settings.strings +++ b/MC1/Resources/Localization/nl.lproj/Settings.strings @@ -1720,6 +1720,11 @@ "Appearance.Scheme.System" = "Systeem"; "Appearance.Scheme.Light" = "Licht"; "Appearance.Scheme.Dark" = "Donker"; +"Appearance.TextSize.Header" = "Tekengrootte"; +"Appearance.TextSize.System" = "Standaard"; +"Appearance.TextSize.Large" = "Groot"; +"Appearance.TextSize.XLarge" = "Extra groot"; +"Appearance.TextSize.XXLarge" = "Extra extra groot"; "Appearance.Themes.Header" = "Kies een thema"; "Appearance.Themes.Selected" = "Geselecteerd"; "Appearance.MoreThemes.Link" = "Meer thema's kopen"; diff --git a/MC1/Resources/Localization/pl.lproj/Settings.strings b/MC1/Resources/Localization/pl.lproj/Settings.strings index 3c90f1a53..04b2b1083 100644 --- a/MC1/Resources/Localization/pl.lproj/Settings.strings +++ b/MC1/Resources/Localization/pl.lproj/Settings.strings @@ -1720,6 +1720,11 @@ "Appearance.Scheme.System" = "Systemowy"; "Appearance.Scheme.Light" = "Jasny"; "Appearance.Scheme.Dark" = "Ciemny"; +"Appearance.TextSize.Header" = "Rozmiar tekstu"; +"Appearance.TextSize.System" = "Domyślny"; +"Appearance.TextSize.Large" = "Duży"; +"Appearance.TextSize.XLarge" = "Bardzo duży"; +"Appearance.TextSize.XXLarge" = "Ekstra duży"; "Appearance.Themes.Header" = "Wybierz motyw"; "Appearance.Themes.Selected" = "Wybrany"; "Appearance.MoreThemes.Link" = "Kup więcej motywów"; diff --git a/MC1/Resources/Localization/ru.lproj/Settings.strings b/MC1/Resources/Localization/ru.lproj/Settings.strings index 89501b69e..24a973745 100644 --- a/MC1/Resources/Localization/ru.lproj/Settings.strings +++ b/MC1/Resources/Localization/ru.lproj/Settings.strings @@ -1720,6 +1720,11 @@ "Appearance.Scheme.System" = "Системная"; "Appearance.Scheme.Light" = "Светлая"; "Appearance.Scheme.Dark" = "Тёмная"; +"Appearance.TextSize.Header" = "Размер текста"; +"Appearance.TextSize.System" = "По умолчанию"; +"Appearance.TextSize.Large" = "Крупный"; +"Appearance.TextSize.XLarge" = "Очень крупный"; +"Appearance.TextSize.XXLarge" = "Экстра крупный"; "Appearance.Themes.Header" = "Выберите тему"; "Appearance.Themes.Selected" = "Выбрана"; "Appearance.MoreThemes.Link" = "Купить другие темы"; diff --git a/MC1/Resources/Localization/uk.lproj/Settings.strings b/MC1/Resources/Localization/uk.lproj/Settings.strings index 9a2165105..d0f123383 100644 --- a/MC1/Resources/Localization/uk.lproj/Settings.strings +++ b/MC1/Resources/Localization/uk.lproj/Settings.strings @@ -1720,6 +1720,11 @@ "Appearance.Scheme.System" = "Системна"; "Appearance.Scheme.Light" = "Світла"; "Appearance.Scheme.Dark" = "Темна"; +"Appearance.TextSize.Header" = "Розмір тексту"; +"Appearance.TextSize.System" = "За замовчуванням"; +"Appearance.TextSize.Large" = "Великий"; +"Appearance.TextSize.XLarge" = "Дуже великий"; +"Appearance.TextSize.XXLarge" = "Екстра великий"; "Appearance.Themes.Header" = "Виберіть тему"; "Appearance.Themes.Selected" = "Вибрано"; "Appearance.MoreThemes.Link" = "Придбати інші теми"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings b/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings index c4aca78e1..bd1b3cff2 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Settings.strings @@ -1720,6 +1720,11 @@ "Appearance.Scheme.System" = "跟随系统"; "Appearance.Scheme.Light" = "浅色"; "Appearance.Scheme.Dark" = "深色"; +"Appearance.TextSize.Header" = "文本大小"; +"Appearance.TextSize.System" = "默认"; +"Appearance.TextSize.Large" = "大"; +"Appearance.TextSize.XLarge" = "特大"; +"Appearance.TextSize.XXLarge" = "超大"; "Appearance.Themes.Header" = "选择主题"; "Appearance.Themes.Selected" = "已选择"; "Appearance.MoreThemes.Link" = "购买更多主题"; diff --git a/MC1/Theme/AppUITextSizePreference.swift b/MC1/Theme/AppUITextSizePreference.swift new file mode 100644 index 000000000..fed92e235 --- /dev/null +++ b/MC1/Theme/AppUITextSizePreference.swift @@ -0,0 +1,38 @@ +import SwiftUI + +/// Global app text-size preference, independent of which theme is selected. +/// Raw values are pinned (persisted + backed up); a rename must not change the on-disk format. +enum AppUITextSizePreference: String, CaseIterable, Identifiable { + case system + case large + case xLarge + case xxLarge + + var id: String { + rawValue + } + + /// `nil` means "defer to the system" — the value passed to `.dynamicTypeSize(_:)`. + /// `.system` maps to `nil` so the app keeps its current appearance: the root + /// override falls back to the `.large` preset, which `dynamicTypeSize(_:)` + /// treats as "no override" (no visual change for existing users). + var dynamicTypeSize: DynamicTypeSize? { + switch self { + case .system: nil + case .large: .xLarge + case .xLarge: .xxLarge + case .xxLarge: .accessibility1 + } + } + + /// Render-scale factor applied on Mac ("Designed for iPad"), where Dynamic Type + /// is disabled by the platform and no text/trait override works. + var uiScale: CGFloat { + switch self { + case .system: 1.0 + case .large: 1.2 + case .xLarge: 1.35 + case .xxLarge: 1.5 + } + } +} diff --git a/MC1/Theme/ThemeService.swift b/MC1/Theme/ThemeService.swift index c7f0744fe..900805c59 100644 --- a/MC1/Theme/ThemeService.swift +++ b/MC1/Theme/ThemeService.swift @@ -10,6 +10,7 @@ import SwiftUI final class ThemeService { private(set) var current: Theme private(set) var colorSchemePreference: AppColorSchemePreference + private(set) var uiTextSizePreference: AppUITextSizePreference private let store: StoreService private let defaults: UserDefaults @@ -42,6 +43,17 @@ final class ThemeService { forKey: PersistenceKeys.appColorSchemePreference) } + let storedTextSize = defaults.string(forKey: PersistenceKeys.appUITextSizePreference) + if let raw = storedTextSize, let preference = AppUITextSizePreference(rawValue: raw) { + uiTextSizePreference = preference + } else if storedTextSize == nil { + uiTextSizePreference = .system // missing key: no write-back + } else { + uiTextSizePreference = .system // unknown raw value: fall back and overwrite + defaults.set(AppUITextSizePreference.system.rawValue, + forKey: PersistenceKeys.appUITextSizePreference) + } + // StoreService is constructed before ThemeService, so this callback is wired before // any Transaction.updates emission. store.onEntitlementsChanged = { [weak self] in @@ -62,6 +74,11 @@ final class ThemeService { defaults.set(preference.rawValue, forKey: PersistenceKeys.appColorSchemePreference) } + func setUITextSizePreference(_ preference: AppUITextSizePreference) { + uiTextSizePreference = preference + defaults.set(preference.rawValue, forKey: PersistenceKeys.appUITextSizePreference) + } + /// Theme-forced override wins; otherwise the user's global preference applies. var effectiveColorScheme: ColorScheme? { current.preferredColorScheme ?? colorSchemePreference.colorScheme @@ -89,6 +106,9 @@ final class ThemeService { let resolvedScheme = resolveSchemePreferenceFromDefaults() if resolvedScheme != colorSchemePreference { colorSchemePreference = resolvedScheme } + + let resolvedTextSize = resolveTextSizePreferenceFromDefaults() + if resolvedTextSize != uiTextSizePreference { uiTextSizePreference = resolvedTextSize } } private func resolveThemeFromDefaults() -> Theme { @@ -123,6 +143,18 @@ final class ThemeService { return .system } + private func resolveTextSizePreferenceFromDefaults() -> AppUITextSizePreference { + let storedTextSize = defaults.string(forKey: PersistenceKeys.appUITextSizePreference) + if let raw = storedTextSize, let preference = AppUITextSizePreference(rawValue: raw) { + return preference + } + if storedTextSize == nil { return .system } // missing key: no write-back + // Unknown raw value (e.g., a future-build case via backup): fall back + corrective overwrite. + defaults.set(AppUITextSizePreference.system.rawValue, + forKey: PersistenceKeys.appUITextSizePreference) + return .system + } + /// Idempotent: invoked from `StoreService.onEntitlementsChanged`. If the current theme is no /// longer accessible (e.g. a refund), revert to default, persist, and announce for VoiceOver. /// diff --git a/MC1/Views/Appearance/AppearanceView.swift b/MC1/Views/Appearance/AppearanceView.swift index cf2af6ab4..0c1eee159 100644 --- a/MC1/Views/Appearance/AppearanceView.swift +++ b/MC1/Views/Appearance/AppearanceView.swift @@ -8,6 +8,10 @@ struct AppearanceView: View { @State private var schemeTrigger = 0 @State private var selectionTrigger = 0 + @State private var textSizeTrigger = 0 + + @ScaledMetric(relativeTo: .body) private var gridItemMinimum = ThemeCardMetrics.gridItemMinimum + @ScaledMetric(relativeTo: .body) private var gridSpacing = ThemeCardMetrics.gridSpacing private var themeService: ThemeService { appState.themeService @@ -20,7 +24,7 @@ struct AppearanceView: View { private var columns: [GridItem] { dynamicTypeSize.isAccessibilitySize ? [GridItem(.flexible())] - : [GridItem(.adaptive(minimum: ThemeCardMetrics.gridItemMinimum), spacing: ThemeCardMetrics.gridSpacing)] + : [GridItem(.adaptive(minimum: gridItemMinimum), spacing: gridSpacing)] } /// "Purchase More Themes" is shown only when at least one registry theme is unavailable. @@ -30,6 +34,7 @@ struct AppearanceView: View { var body: some View { List { + textSizeSection schemeSection themesSection if Self.shouldShowBrowseMore(available: availableThemes) { @@ -46,6 +51,19 @@ struct AppearanceView: View { .navigationBarTitleDisplayMode(.inline) .sensoryFeedback(.selection, trigger: schemeTrigger) .sensoryFeedback(.selection, trigger: selectionTrigger) + .sensoryFeedback(.selection, trigger: textSizeTrigger) + } + + private var textSizeSection: some View { + Section { + Picker(L10n.Settings.Appearance.TextSize.header, selection: textSizeBinding) { + ForEach(AppUITextSizePreference.allCases) { preference in + Text(textSizeLabel(preference)).tag(preference) + } + } + .pickerStyle(.menu) + } + .themedRowBackground(activeTheme) } private var schemeSection: some View { @@ -62,7 +80,7 @@ struct AppearanceView: View { private var themesSection: some View { Section { - LazyVGrid(columns: columns, spacing: ThemeCardMetrics.gridSpacing) { + LazyVGrid(columns: columns, spacing: gridSpacing) { ForEach(availableThemes) { theme in ThemeSelectionCard( theme: theme, @@ -88,6 +106,16 @@ struct AppearanceView: View { ) } + private var textSizeBinding: Binding { + Binding( + get: { themeService.uiTextSizePreference }, + set: { + themeService.setUITextSizePreference($0) + textSizeTrigger += 1 + } + ) + } + private func schemeLabel(_ preference: AppColorSchemePreference) -> String { switch preference { case .system: L10n.Settings.Appearance.Scheme.system @@ -96,6 +124,15 @@ struct AppearanceView: View { } } + private func textSizeLabel(_ preference: AppUITextSizePreference) -> String { + switch preference { + case .system: L10n.Settings.Appearance.TextSize.system + case .large: L10n.Settings.Appearance.TextSize.large + case .xLarge: L10n.Settings.Appearance.TextSize.xLarge + case .xxLarge: L10n.Settings.Appearance.TextSize.xxLarge + } + } + private func select(_ theme: Theme) { // The list is already filtered to accessible themes; the throw path is a defensive // guard against a refund-driven revert landing mid-tap. diff --git a/MC1/Views/Appearance/Components/ThemeSelectionCard.swift b/MC1/Views/Appearance/Components/ThemeSelectionCard.swift index be471b898..6b5bdb628 100644 --- a/MC1/Views/Appearance/Components/ThemeSelectionCard.swift +++ b/MC1/Views/Appearance/Components/ThemeSelectionCard.swift @@ -6,6 +6,11 @@ struct ThemeSelectionCard: View { let isSelected: Bool let onSelect: () -> Void + @ScaledMetric(relativeTo: .body) private var badgeIconSpacing = ThemeCardMetrics.badgeIconSpacing + @ScaledMetric(relativeTo: .body) private var contentPadding = ThemeCardMetrics.contentPadding + @ScaledMetric(relativeTo: .body) private var cornerRadius = ThemeCardMetrics.cornerRadius + @ScaledMetric(relativeTo: .body) private var selectionStrokeWidth = ThemeCardMetrics.selectionStrokeWidth + var body: some View { Button(action: onSelect) { VStack(spacing: 8) { @@ -14,7 +19,7 @@ struct ThemeSelectionCard: View { .font(.subheadline.weight(.medium)) .lineLimit(1) if isSelected { - HStack(spacing: ThemeCardMetrics.badgeIconSpacing) { + HStack(spacing: badgeIconSpacing) { Image(systemName: "checkmark") Text(L10n.Settings.Appearance.Themes.selected) } @@ -24,11 +29,11 @@ struct ThemeSelectionCard: View { Color.clear.frame(height: 1) } } - .padding(ThemeCardMetrics.contentPadding) + .padding(contentPadding) .frame(maxWidth: .infinity) .overlay( - RoundedRectangle(cornerRadius: ThemeCardMetrics.cornerRadius) - .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: ThemeCardMetrics.selectionStrokeWidth) + RoundedRectangle(cornerRadius: cornerRadius) + .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: selectionStrokeWidth) ) } .buttonStyle(.plain) diff --git a/MC1/Views/Chats/Components/ChatInputBar.swift b/MC1/Views/Chats/Components/ChatInputBar.swift index 99be680b6..984c75a26 100644 --- a/MC1/Views/Chats/Components/ChatInputBar.swift +++ b/MC1/Views/Chats/Components/ChatInputBar.swift @@ -170,6 +170,10 @@ private struct ChatInputTextField: View { let onFocus: () -> Void let glassNamespace: Namespace.ID + @ScaledMetric(relativeTo: .body) private var controlHeight = ChatInputMetrics.controlHeight + @ScaledMetric(relativeTo: .body) private var fieldCornerRadius = ChatInputMetrics.fieldCornerRadius + @ScaledMetric(relativeTo: .body) private var fieldBorderWidth = ChatInputMetrics.fieldBorderWidth + var body: some View { ChatComposerTextView( text: $text, @@ -179,7 +183,7 @@ private struct ChatInputTextField: View { onSend: onSend, onFocus: onFocus ) - .frame(maxWidth: .infinity, minHeight: ChatInputMetrics.controlHeight) + .frame(maxWidth: .infinity, minHeight: controlHeight) .overlay(alignment: .topLeading) { if text.isEmpty { Text(placeholder) @@ -199,7 +203,7 @@ private struct ChatInputTextField: View { .padding(.trailing, 10) .accessibilityHidden(true) } - .textFieldBackground() + .textFieldBackground(cornerRadius: fieldCornerRadius, borderWidth: fieldBorderWidth) .liquidGlassID("chatComposer", in: glassNamespace) } } @@ -264,12 +268,14 @@ private struct ChatSendButton: View { let sendAccessibilityHint: String let onSend: () -> Void + @ScaledMetric(relativeTo: .body) private var controlHeight = ChatInputMetrics.controlHeight + var body: some View { Button(action: onSend) { Image(systemName: "arrow.up") - .font(.system(size: 18, weight: .semibold)) + .font(.body.bold()) .foregroundStyle(.white) - .frame(width: ChatInputMetrics.controlHeight, height: ChatInputMetrics.controlHeight) + .frame(width: controlHeight, height: controlHeight) .sendButtonBackground() .contentShape(Circle()) } @@ -292,15 +298,15 @@ private extension View { } @ViewBuilder - func textFieldBackground() -> some View { + func textFieldBackground(cornerRadius: CGFloat, borderWidth: CGFloat) -> some View { if #available(iOS 26.0, *) { - glassEffect(.regular.interactive(), in: .rect(cornerRadius: ChatInputMetrics.fieldCornerRadius)) + glassEffect(.regular.interactive(), in: .rect(cornerRadius: cornerRadius)) } else { // Without the glass capsule the field would read as one fill on the bar's fill, so its edge // carries the affordance. A clear centre keeps it legible against any theme canvas. overlay { - RoundedRectangle(cornerRadius: ChatInputMetrics.fieldCornerRadius) - .strokeBorder(Color(.separator), lineWidth: ChatInputMetrics.fieldBorderWidth) + RoundedRectangle(cornerRadius: cornerRadius) + .strokeBorder(Color(.separator), lineWidth: borderWidth) } } } diff --git a/MC1/Views/Chats/Components/ChatShareMenu.swift b/MC1/Views/Chats/Components/ChatShareMenu.swift index 8c56ff474..d41fba15b 100644 --- a/MC1/Views/Chats/Components/ChatShareMenu.swift +++ b/MC1/Views/Chats/Components/ChatShareMenu.swift @@ -13,6 +13,8 @@ struct ChatShareMenu: View { @Environment(\.appState) private var appState + @ScaledMetric(relativeTo: .body) private var controlHeight = ChatInputMetrics.controlHeight + @State private var isShowingContactPicker = false @State private var locationTask: Task? @@ -98,9 +100,9 @@ struct ChatShareMenu: View { .disabled(!canShareMyInfo) } label: { Image(systemName: "plus") - .font(.system(size: 18, weight: .semibold)) + .font(.body.weight(.semibold)) .foregroundStyle(plusIconColor) - .frame(width: ChatInputMetrics.controlHeight, height: ChatInputMetrics.controlHeight) + .frame(width: controlHeight, height: controlHeight) .plusButtonBackground() } .buttonStyle(.plain) diff --git a/MC1/Views/Components/UIScaleContainer.swift b/MC1/Views/Components/UIScaleContainer.swift new file mode 100644 index 000000000..16b75733e --- /dev/null +++ b/MC1/Views/Components/UIScaleContainer.swift @@ -0,0 +1,58 @@ +import SwiftUI +import UIKit + +/// Render-time zoom for the whole app UI. +/// +/// On "Designed for iPad" (iOS apps running on Apple Silicon Macs) Apple disables +/// Dynamic Type entirely: neither the SwiftUI `\.dynamicTypeSize` nor the low-level +/// `\.sizeCategory` environment values scale `Text`/`List` fonts on that runtime. +/// The only way to make the whole UI bigger there is to scale the rendered output. +/// +/// The naive `scaleEffect` clips content because the view still lays out at 1.0× +/// while rendering at `scale` — scroll views measure layout space, not render +/// space. The fix is the classic "scale with compensating frame" trick: lay the +/// content out in a frame of `windowSize / scale`, then scale it up by `scale` so +/// the rendered output exactly fills the window. Layout sees the pre-scale size, +/// so scroll metrics and hit targets stay consistent with what is on screen. +/// +/// Only active when actually running on a Mac (`isiOSAppOnMac`) with a scale > 1; +/// on iOS the standard Dynamic Type path (`.environment(\.dynamicTypeSize, ...)`) +/// already scales text and `@ScaledMetric` values proportionally, so the view is +/// passed through untouched with no `GeometryReader` layout overhead. +struct UIScaleContainer: View { + let scale: CGFloat + @ViewBuilder let content: Content + + private var shouldZoom: Bool { + ProcessInfo.processInfo.isiOSAppOnMac && scale > 1.001 + } + + var body: some View { + if shouldZoom { + GeometryReader { proxy in + content + .frame( + width: proxy.size.width / scale, + height: proxy.size.height / scale, + alignment: .topLeading + ) + .scaleEffect(scale, anchor: .topLeading) + .frame( + width: proxy.size.width, + height: proxy.size.height, + alignment: .topLeading + ) + .clipped() + } + } else { + content + } + } +} + +extension View { + /// Scales the whole view by `scale` on Mac ("Designed for iPad"); no-op on iOS. + func uiScale(_ scale: CGFloat) -> some View { + UIScaleContainer(scale: scale) { self } + } +} diff --git a/MC1/Views/Onboarding/DeviceScanView.swift b/MC1/Views/Onboarding/DeviceScanView.swift index 023c6bf65..ff79a0803 100644 --- a/MC1/Views/Onboarding/DeviceScanView.swift +++ b/MC1/Views/Onboarding/DeviceScanView.swift @@ -14,13 +14,17 @@ struct DeviceScanView: View { @State private var otherAppDeviceID: UUID? private var demoModeManager = DemoModeManager.shared + @ScaledMetric(relativeTo: .body) private var largeSpacing = OnboardingMetrics.largeSpacing + @ScaledMetric(relativeTo: .body) private var mediumSpacing = OnboardingMetrics.mediumSpacing + @ScaledMetric(relativeTo: .body) private var minHitTarget = OnboardingMetrics.minHitTarget + private var hasConnectedDevice: Bool { appState.connectionState == .ready } var body: some View { - VStack(spacing: OnboardingMetrics.largeSpacing) { - VStack(spacing: OnboardingMetrics.mediumSpacing) { + VStack(spacing: largeSpacing) { + VStack(spacing: mediumSpacing) { PulsingAntenna() Text(L10n.Onboarding.DeviceScan.title) @@ -43,7 +47,7 @@ struct DeviceScanView: View { Spacer() if hasConnectedDevice, !didInitiatePairing { - VStack(spacing: OnboardingMetrics.mediumSpacing) { + VStack(spacing: mediumSpacing) { Text(L10n.Onboarding.DeviceScan.alreadyPaired) .font(.title2) .multilineTextAlignment(.center) @@ -53,7 +57,7 @@ struct DeviceScanView: View { Spacer() - VStack(spacing: OnboardingMetrics.mediumSpacing) { + VStack(spacing: mediumSpacing) { if hasConnectedDevice { Button { appState.onboarding.onboardingPath.append(.region) @@ -68,14 +72,14 @@ struct DeviceScanView: View { primaryCTA ViewThatFits { - HStack(spacing: OnboardingMetrics.largeSpacing) { + HStack(spacing: largeSpacing) { secondaryButtons } - VStack(spacing: OnboardingMetrics.mediumSpacing) { + VStack(spacing: mediumSpacing) { secondaryButtons } } - .frame(minHeight: OnboardingMetrics.minHitTarget) + .frame(minHeight: minHitTarget) Button(L10n.Onboarding.DeviceScan.noDeviceYet) { showingNoDeviceSheet = true @@ -83,7 +87,7 @@ struct DeviceScanView: View { .font(.subheadline) .foregroundStyle(.secondary) .padding(.vertical, 8) - .frame(minHeight: OnboardingMetrics.minHitTarget) + .frame(minHeight: minHitTarget) } } .padding(.horizontal) diff --git a/MC1/Views/Onboarding/NoDeviceSheet.swift b/MC1/Views/Onboarding/NoDeviceSheet.swift index 497a00a4a..9f3fd3b1b 100644 --- a/MC1/Views/Onboarding/NoDeviceSheet.swift +++ b/MC1/Views/Onboarding/NoDeviceSheet.swift @@ -9,13 +9,18 @@ struct NoDeviceSheet: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize @State private var dismissTrigger = false + @ScaledMetric(relativeTo: .body) private var largeSpacing = OnboardingMetrics.largeSpacing + @ScaledMetric(relativeTo: .body) private var mediumSpacing = OnboardingMetrics.mediumSpacing + @ScaledMetric(relativeTo: .body) private var sheetTopPadding = OnboardingMetrics.sheetTopPadding + @ScaledMetric(relativeTo: .body) private var minHitTarget = OnboardingMetrics.minHitTarget + var body: some View { - VStack(spacing: OnboardingMetrics.largeSpacing) { + VStack(spacing: largeSpacing) { Text(L10n.Onboarding.NoDevice.Sheet.title) .font(.title2) .bold() .accessibilityHeading(.h1) - .padding(.top, OnboardingMetrics.sheetTopPadding) + .padding(.top, sheetTopPadding) Text(L10n.Onboarding.NoDevice.Sheet.body) .font(.body) @@ -25,7 +30,7 @@ struct NoDeviceSheet: View { Spacer() - VStack(spacing: OnboardingMetrics.mediumSpacing) { + VStack(spacing: mediumSpacing) { Button { dismissTrigger.toggle() appState.completeOnboarding() @@ -37,17 +42,17 @@ struct NoDeviceSheet: View { .padding() } .liquidGlassProminentButtonStyle() - .frame(minHeight: OnboardingMetrics.minHitTarget) + .frame(minHeight: minHitTarget) Button(L10n.Onboarding.NoDevice.Sheet.cancel) { dismiss() } .buttonStyle(.bordered) .tint(.secondary) - .frame(minHeight: OnboardingMetrics.minHitTarget) + .frame(minHeight: minHitTarget) } .padding(.horizontal) - .padding(.bottom, OnboardingMetrics.largeSpacing) + .padding(.bottom, largeSpacing) } .sensoryFeedback(.selection, trigger: dismissTrigger) .presentationDetents(dynamicTypeSize.isAccessibilitySize ? [.large] : [.medium]) diff --git a/MC1/Views/Onboarding/PermissionsView.swift b/MC1/Views/Onboarding/PermissionsView.swift index 44b8699e9..29ffc5853 100644 --- a/MC1/Views/Onboarding/PermissionsView.swift +++ b/MC1/Views/Onboarding/PermissionsView.swift @@ -7,11 +7,18 @@ struct PermissionsView: View { @State private var coordinator = PermissionsCoordinator() @State private var permissionGrantTrigger = false + @ScaledMetric(relativeTo: .body) private var sheetTopPadding = OnboardingMetrics.sheetTopPadding + @ScaledMetric(relativeTo: .body) private var mediumSpacing = OnboardingMetrics.mediumSpacing + @ScaledMetric(relativeTo: .body) private var iconSize = OnboardingMetrics.iconSize + @ScaledMetric(relativeTo: .body) private var headerTopPadding = OnboardingMetrics.headerTopPadding + @ScaledMetric(relativeTo: .body) private var contentPadding = OnboardingMetrics.contentPadding + @ScaledMetric(relativeTo: .body) private var cardSpacing = OnboardingMetrics.cardSpacing + var body: some View { - VStack(spacing: OnboardingMetrics.sheetTopPadding) { - VStack(spacing: OnboardingMetrics.mediumSpacing) { + VStack(spacing: sheetTopPadding) { + VStack(spacing: mediumSpacing) { Image(systemName: "checkmark.shield.fill") - .font(.system(size: OnboardingMetrics.iconSize)) + .font(.system(size: iconSize)) .foregroundStyle(.tint) Text(L10n.Onboarding.Permissions.title) @@ -25,15 +32,15 @@ struct PermissionsView: View { .multilineTextAlignment(.center) .padding(.horizontal) } - .padding(.top, OnboardingMetrics.headerTopPadding) + .padding(.top, headerTopPadding) GeometryReader { proxy in ScrollView { VStack(spacing: 0) { Spacer(minLength: 0) - LiquidGlassContainer(spacing: OnboardingMetrics.contentPadding) { - VStack(spacing: OnboardingMetrics.cardSpacing) { + LiquidGlassContainer(spacing: contentPadding) { + VStack(spacing: cardSpacing) { PermissionCard( icon: "bell.fill", title: L10n.Onboarding.Permissions.Notifications.title, diff --git a/MC1/Views/Onboarding/PresetStepView.swift b/MC1/Views/Onboarding/PresetStepView.swift index c80638427..fdbc75d49 100644 --- a/MC1/Views/Onboarding/PresetStepView.swift +++ b/MC1/Views/Onboarding/PresetStepView.swift @@ -13,6 +13,14 @@ struct PresetStepView: View { @State private var commitTrigger = false @State private var forceShowPicker = false + @ScaledMetric(relativeTo: .body) private var cardSpacing = OnboardingMetrics.cardSpacing + @ScaledMetric(relativeTo: .body) private var iconSize = OnboardingMetrics.iconSize + @ScaledMetric(relativeTo: .body) private var mediumSpacing = OnboardingMetrics.mediumSpacing + @ScaledMetric(relativeTo: .body) private var minHitTarget = OnboardingMetrics.minHitTarget + @ScaledMetric(relativeTo: .body) private var titleStackSpacing = OnboardingMetrics.titleStackSpacing + @ScaledMetric(relativeTo: .body) private var headerTopPadding = OnboardingMetrics.headerTopPadding + @ScaledMetric(relativeTo: .body) private var compactSpacing = OnboardingMetrics.compactSpacing + private var region: RegionSelection? { appState.regionSelection } @@ -76,10 +84,10 @@ struct PresetStepView: View { } private func alreadyConfiguredState(preset: RadioPreset) -> some View { - VStack(spacing: OnboardingMetrics.cardSpacing) { + VStack(spacing: cardSpacing) { Spacer() Image(systemName: "checkmark.circle.fill") - .font(.system(size: OnboardingMetrics.iconSize)) + .font(.system(size: iconSize)) .foregroundStyle(.tint) Text(L10n.Onboarding.Preset.AlreadyConfigured.title) .font(.largeTitle) @@ -96,7 +104,7 @@ struct PresetStepView: View { Spacer() - VStack(spacing: OnboardingMetrics.mediumSpacing) { + VStack(spacing: mediumSpacing) { Button { commitTrigger.toggle() appState.completeOnboarding() @@ -113,7 +121,7 @@ struct PresetStepView: View { } .buttonStyle(.bordered) .tint(.accentColor) - .frame(minHeight: OnboardingMetrics.minHitTarget) + .frame(minHeight: minHitTarget) } .padding(.horizontal) .padding(.bottom) @@ -121,8 +129,8 @@ struct PresetStepView: View { } private var pickerState: some View { - VStack(spacing: OnboardingMetrics.cardSpacing) { - VStack(spacing: OnboardingMetrics.titleStackSpacing) { + VStack(spacing: cardSpacing) { + VStack(spacing: titleStackSpacing) { Text(L10n.Onboarding.Preset.title) .font(.largeTitle) .bold() @@ -137,10 +145,10 @@ struct PresetStepView: View { .foregroundStyle(.secondary) } } - .padding(.top, OnboardingMetrics.headerTopPadding) + .padding(.top, headerTopPadding) ScrollView { - VStack(spacing: OnboardingMetrics.mediumSpacing) { + VStack(spacing: mediumSpacing) { ForEach(visiblePresets) { preset in rowCard(preset) } @@ -152,7 +160,7 @@ struct PresetStepView: View { .font(.footnote) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - .padding(.top, OnboardingMetrics.mediumSpacing) + .padding(.top, mediumSpacing) } } .padding(.horizontal) @@ -189,7 +197,7 @@ struct PresetStepView: View { selectedID = preset.id } label: { HStack { - VStack(alignment: .leading, spacing: OnboardingMetrics.compactSpacing) { + VStack(alignment: .leading, spacing: compactSpacing) { Text(preset.name) .font(.body) Text("\(preset.frequencyMHz, format: .number.precision(.fractionLength(3)).locale(.posix)) MHz") @@ -203,7 +211,7 @@ struct PresetStepView: View { } } .padding() - .frame(maxWidth: .infinity, minHeight: OnboardingMetrics.minHitTarget) + .frame(maxWidth: .infinity, minHeight: minHitTarget) .contentShape(.rect) } .buttonStyle(.plain) diff --git a/MC1/Views/Onboarding/PulsingAntenna.swift b/MC1/Views/Onboarding/PulsingAntenna.swift index 2cfba6235..1445dfece 100644 --- a/MC1/Views/Onboarding/PulsingAntenna.swift +++ b/MC1/Views/Onboarding/PulsingAntenna.swift @@ -5,11 +5,13 @@ import SwiftUI struct PulsingAntenna: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion + @ScaledMetric(relativeTo: .body) private var heroSize = OnboardingMetrics.heroSize + var body: some View { Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: OnboardingMetrics.heroSize / 2)) + .font(.system(size: heroSize / 2)) .foregroundStyle(.tint) - .frame(height: OnboardingMetrics.heroSize) + .frame(height: heroSize) .symbolEffect(.pulse, isActive: !reduceMotion) .accessibilityHidden(true) } diff --git a/MC1/Views/Onboarding/RegionStepView.swift b/MC1/Views/Onboarding/RegionStepView.swift index 6a500ed8f..4ce75c1d1 100644 --- a/MC1/Views/Onboarding/RegionStepView.swift +++ b/MC1/Views/Onboarding/RegionStepView.swift @@ -15,6 +15,13 @@ struct RegionStepView: View { @State private var pendingUserRetry = false @State private var errorMessage: String? + @ScaledMetric(relativeTo: .body) private var cardSpacing = OnboardingMetrics.cardSpacing + @ScaledMetric(relativeTo: .body) private var titleStackSpacing = OnboardingMetrics.titleStackSpacing + @ScaledMetric(relativeTo: .body) private var headerTopPadding = OnboardingMetrics.headerTopPadding + @ScaledMetric(relativeTo: .body) private var contentPadding = OnboardingMetrics.contentPadding + @ScaledMetric(relativeTo: .body) private var cardCornerRadius = OnboardingMetrics.cardCornerRadius + @ScaledMetric(relativeTo: .body) private var minHitTarget = OnboardingMetrics.minHitTarget + private var locationGranted: Bool { appState.locationService.isAuthorized } @@ -59,7 +66,7 @@ struct RegionStepView: View { } private var resolvingState: some View { - VStack(spacing: OnboardingMetrics.cardSpacing) { + VStack(spacing: cardSpacing) { Spacer() ProgressView().controlSize(.large) Text(L10n.Onboarding.Region.resolving) @@ -70,8 +77,8 @@ struct RegionStepView: View { } private func detectedState(region: RegionSelection) -> some View { - VStack(spacing: OnboardingMetrics.cardSpacing) { - VStack(spacing: OnboardingMetrics.titleStackSpacing) { + VStack(spacing: cardSpacing) { + VStack(spacing: titleStackSpacing) { Text(L10n.Onboarding.Region.title) .font(.largeTitle) .bold() @@ -81,11 +88,11 @@ struct RegionStepView: View { .foregroundStyle(.secondary) .multilineTextAlignment(.center) } - .padding(.top, OnboardingMetrics.headerTopPadding) + .padding(.top, headerTopPadding) Spacer() - VStack(spacing: OnboardingMetrics.titleStackSpacing) { + VStack(spacing: titleStackSpacing) { Text(L10n.Onboarding.Region.Detected.tag) .font(.caption.weight(.medium)) .foregroundStyle(.tint) @@ -97,9 +104,9 @@ struct RegionStepView: View { .font(.caption) .foregroundStyle(.secondary) } - .padding(OnboardingMetrics.contentPadding) + .padding(contentPadding) .frame(maxWidth: .infinity) - .liquidGlass(in: .rect(cornerRadius: OnboardingMetrics.cardCornerRadius)) + .liquidGlass(in: .rect(cornerRadius: cardCornerRadius)) .padding(.horizontal) .accessibilityElement(children: .combine) @@ -109,7 +116,7 @@ struct RegionStepView: View { .font(.subheadline) .buttonStyle(.bordered) .tint(.accentColor) - .frame(minHeight: OnboardingMetrics.minHitTarget) + .frame(minHeight: minHitTarget) Spacer() @@ -130,8 +137,8 @@ struct RegionStepView: View { } private var manualPickerState: some View { - VStack(spacing: OnboardingMetrics.cardSpacing) { - VStack(spacing: OnboardingMetrics.titleStackSpacing) { + VStack(spacing: cardSpacing) { + VStack(spacing: titleStackSpacing) { Text(L10n.Onboarding.Region.title) .font(.largeTitle) .bold() @@ -141,7 +148,7 @@ struct RegionStepView: View { .foregroundStyle(.secondary) .multilineTextAlignment(.center) } - .padding(.top, OnboardingMetrics.headerTopPadding) + .padding(.top, headerTopPadding) RegionPickerView(selection: $manualSelection) @@ -157,7 +164,7 @@ struct RegionStepView: View { } .font(.subheadline) .foregroundStyle(.tint) - .frame(minHeight: OnboardingMetrics.minHitTarget) + .frame(minHeight: minHitTarget) } Button { diff --git a/MC1/Views/Onboarding/TroubleshootingSheet.swift b/MC1/Views/Onboarding/TroubleshootingSheet.swift index 02efd0305..093c760b8 100644 --- a/MC1/Views/Onboarding/TroubleshootingSheet.swift +++ b/MC1/Views/Onboarding/TroubleshootingSheet.swift @@ -11,6 +11,8 @@ struct TroubleshootingSheet: View { @Environment(\.openURL) private var openURL @State private var isClearing = false + @ScaledMetric(relativeTo: .body) private var titleStackSpacing = OnboardingMetrics.titleStackSpacing + var body: some View { NavigationStack { List { @@ -24,7 +26,7 @@ struct TroubleshootingSheet: View { } Section { - VStack(alignment: .leading, spacing: OnboardingMetrics.titleStackSpacing) { + VStack(alignment: .leading, spacing: titleStackSpacing) { Text(L10n.Onboarding.Troubleshooting.FactoryReset.explanation) .font(.subheadline) .foregroundStyle(.secondary) @@ -59,7 +61,7 @@ struct TroubleshootingSheet: View { } Section { - VStack(alignment: .leading, spacing: OnboardingMetrics.titleStackSpacing) { + VStack(alignment: .leading, spacing: titleStackSpacing) { Text(L10n.Onboarding.Troubleshooting.SystemSettings.manageAccessories) .font(.subheadline) Text(L10n.Onboarding.Troubleshooting.SystemSettings.path) diff --git a/MC1/Views/Onboarding/WelcomeView.swift b/MC1/Views/Onboarding/WelcomeView.swift index a3217e172..1cccd8273 100644 --- a/MC1/Views/Onboarding/WelcomeView.swift +++ b/MC1/Views/Onboarding/WelcomeView.swift @@ -3,12 +3,15 @@ import SwiftUI struct WelcomeView: View { @Environment(\.appState) private var appState + @ScaledMetric(relativeTo: .body) private var heroSize = OnboardingMetrics.heroSize + @ScaledMetric(relativeTo: .body) private var cardSpacing = OnboardingMetrics.cardSpacing + var body: some View { - VStack(spacing: OnboardingMetrics.cardSpacing * 2) { + VStack(spacing: cardSpacing * 2) { Spacer() MeshAnimationView() - .frame(height: OnboardingMetrics.heroSize) + .frame(height: heroSize) .padding(.horizontal) VStack(spacing: 12) { diff --git a/MC1/Views/PathEditing/AddHopPickerView.swift b/MC1/Views/PathEditing/AddHopPickerView.swift index 5d7eff945..a6060347d 100644 --- a/MC1/Views/PathEditing/AddHopPickerView.swift +++ b/MC1/Views/PathEditing/AddHopPickerView.swift @@ -357,13 +357,17 @@ private struct PickerRowView: View { @State private var showSuccess = false @State private var resetTask: Task? + @ScaledMetric(relativeTo: .body) private var rowContentSpacing = PathEditMetrics.rowContentSpacing + @ScaledMetric(relativeTo: .body) private var badgeSpacing = PathEditMetrics.badgeSpacing + @ScaledMetric(relativeTo: .body) private var tapTarget = PathEditMetrics.tapTarget + private static let successDuration: Duration = .seconds(1.5) var body: some View { Button(action: handleTap) { - HStack(spacing: PathEditMetrics.rowContentSpacing) { + HStack(spacing: rowContentSpacing) { VStack(alignment: .leading, spacing: 2) { - HStack(spacing: PathEditMetrics.badgeSpacing) { + HStack(spacing: badgeSpacing) { Text(node.displayName) .font(.body) if node.isFavorite { @@ -394,7 +398,7 @@ private struct PickerRowView: View { Spacer() trailingIcon } - .frame(minHeight: PathEditMetrics.tapTarget) + .frame(minHeight: tapTarget) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -441,14 +445,17 @@ private struct PickerRowView: View { private struct BulkCodeRow: View { let classification: HopCodeClassification + @ScaledMetric(relativeTo: .body) private var rowContentSpacing = PathEditMetrics.rowContentSpacing + @ScaledMetric(relativeTo: .body) private var tapTarget = PathEditMetrics.tapTarget + var body: some View { - HStack(spacing: PathEditMetrics.rowContentSpacing) { + HStack(spacing: rowContentSpacing) { Text(rowText) .font(.callout) Spacer() trailingIcon } - .frame(minHeight: PathEditMetrics.tapTarget) + .frame(minHeight: tapTarget) .accessibilityElement(children: .combine) } diff --git a/MC1/Views/PathEditing/PathEditCTALabel.swift b/MC1/Views/PathEditing/PathEditCTALabel.swift index 921f11a17..f0291af29 100644 --- a/MC1/Views/PathEditing/PathEditCTALabel.swift +++ b/MC1/Views/PathEditing/PathEditCTALabel.swift @@ -9,12 +9,15 @@ struct PathEditCTALabel: View { let title: String let systemImage: String + @ScaledMetric(relativeTo: .body) private var ctaIconSpacing = PathEditMetrics.ctaIconSpacing + @ScaledMetric(relativeTo: .body) private var ctaIconSize = PathEditMetrics.ctaIconSize + var body: some View { - HStack(spacing: PathEditMetrics.ctaIconSpacing) { + HStack(spacing: ctaIconSpacing) { Spacer() Image(systemName: systemImage) .font(.body.weight(.semibold)) - .frame(width: PathEditMetrics.ctaIconSize, height: PathEditMetrics.ctaIconSize) + .frame(width: ctaIconSize, height: ctaIconSize) Text(title) .font(.body.weight(.semibold)) Spacer() diff --git a/MC1/Views/PathEditing/PathEditingSheet.swift b/MC1/Views/PathEditing/PathEditingSheet.swift index af79cef7d..de0f38295 100644 --- a/MC1/Views/PathEditing/PathEditingSheet.swift +++ b/MC1/Views/PathEditing/PathEditingSheet.swift @@ -243,6 +243,8 @@ private struct PathHopRow: View { let index: Int let totalCount: Int + @ScaledMetric(relativeTo: .body) private var tapTarget = PathEditMetrics.tapTarget + var body: some View { VStack(alignment: .leading, spacing: 2) { if let name = hop.resolvedName { @@ -254,7 +256,7 @@ private struct PathHopRow: View { Text(hop.hashHex).font(.body.monospaced()) } } - .frame(minHeight: PathEditMetrics.tapTarget) + .frame(minHeight: tapTarget) .accessibilityElement(children: .combine) .accessibilityLabel(accessibilityDescription) .accessibilityHint(L10n.Contacts.Contacts.PathEdit.hopHint) diff --git a/MC1/Views/Support/Components/ThemeBundleCard.swift b/MC1/Views/Support/Components/ThemeBundleCard.swift index b429b6b6f..3bc5c4a39 100644 --- a/MC1/Views/Support/Components/ThemeBundleCard.swift +++ b/MC1/Views/Support/Components/ThemeBundleCard.swift @@ -11,6 +11,8 @@ struct ThemeBundleCard: View { @Environment(\.appTheme) private var theme @State private var isPurchasing = false + @ScaledMetric(relativeTo: .body) private var cornerRadius = ThemeCardMetrics.cornerRadius + var body: some View { VStack(alignment: .center, spacing: 10) { Text(L10n.Settings.Support.Bundle.title) @@ -23,7 +25,7 @@ struct ThemeBundleCard: View { } .padding(12) .frame(maxWidth: .infinity, alignment: .center) - .background(theme.surfaces?.card ?? Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: ThemeCardMetrics.cornerRadius)) + .background(theme.surfaces?.card ?? Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: cornerRadius)) .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityLabel) .accessibilityHint(isActionable ? L10n.Settings.Support.Accessibility.BundleCard.lockedHint : "") diff --git a/MC1/Views/Support/Components/ThemePreviewCard.swift b/MC1/Views/Support/Components/ThemePreviewCard.swift index cf3e1d50b..fbd5246fb 100644 --- a/MC1/Views/Support/Components/ThemePreviewCard.swift +++ b/MC1/Views/Support/Components/ThemePreviewCard.swift @@ -7,6 +7,9 @@ struct ThemePreviewCard: View { let theme: Theme let isOwned: Bool + @ScaledMetric(relativeTo: .body) private var badgeIconSpacing = ThemeCardMetrics.badgeIconSpacing + @ScaledMetric(relativeTo: .body) private var contentPadding = ThemeCardMetrics.contentPadding + var body: some View { VStack(spacing: 8) { ZStack(alignment: .topTrailing) { @@ -23,7 +26,7 @@ struct ThemePreviewCard: View { .font(.subheadline.weight(.medium)) .lineLimit(1) if isOwned { - HStack(spacing: ThemeCardMetrics.badgeIconSpacing) { + HStack(spacing: badgeIconSpacing) { Image(systemName: "checkmark") Text(L10n.Settings.Support.Themes.owned) } @@ -31,7 +34,7 @@ struct ThemePreviewCard: View { .foregroundStyle(.secondary) } } - .padding(ThemeCardMetrics.contentPadding) + .padding(contentPadding) .frame(maxWidth: .infinity) .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityLabel) diff --git a/MC1/Views/Support/Sections/ThemesPurchaseSection.swift b/MC1/Views/Support/Sections/ThemesPurchaseSection.swift index c8d1db3d1..f904427a9 100644 --- a/MC1/Views/Support/Sections/ThemesPurchaseSection.swift +++ b/MC1/Views/Support/Sections/ThemesPurchaseSection.swift @@ -9,6 +9,12 @@ struct ThemesPurchaseSection: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.purchase) private var purchase + @ScaledMetric(relativeTo: .body) private var gridItemMinimum = ThemeCardMetrics.gridItemMinimum + @ScaledMetric(relativeTo: .body) private var gridSpacing = ThemeCardMetrics.gridSpacing + @ScaledMetric(relativeTo: .body) private var allUnlockedEmojiSize = ThemeCardMetrics.allUnlockedEmojiSize + @ScaledMetric(relativeTo: .body) private var allUnlockedSpacing = ThemeCardMetrics.allUnlockedSpacing + @ScaledMetric(relativeTo: .body) private var allUnlockedVerticalPadding = ThemeCardMetrics.allUnlockedVerticalPadding + private var storeState: StoreState { appState.storeState } @@ -24,7 +30,7 @@ struct ThemesPurchaseSection: View { private var columns: [GridItem] { dynamicTypeSize.isAccessibilitySize ? [GridItem(.flexible())] - : [GridItem(.adaptive(minimum: ThemeCardMetrics.gridItemMinimum), spacing: ThemeCardMetrics.gridSpacing)] + : [GridItem(.adaptive(minimum: gridItemMinimum), spacing: gridSpacing)] } var body: some View { @@ -32,7 +38,7 @@ struct ThemesPurchaseSection: View { if ownsEveryTheme { allUnlockedCard } else { - LazyVGrid(columns: columns, spacing: ThemeCardMetrics.gridSpacing) { + LazyVGrid(columns: columns, spacing: gridSpacing) { ForEach(purchasableThemes) { theme in ThemePreviewCard(theme: theme, isOwned: isOwned(theme)) } @@ -64,14 +70,14 @@ struct ThemesPurchaseSection: View { } private var allUnlockedCard: some View { - VStack(spacing: ThemeCardMetrics.allUnlockedSpacing) { + VStack(spacing: allUnlockedSpacing) { Text(verbatim: "🎉") - .font(.system(size: ThemeCardMetrics.allUnlockedEmojiSize)) + .font(.system(size: allUnlockedEmojiSize)) Text(L10n.Settings.Support.Themes.allUnlocked) .font(.headline) } .frame(maxWidth: .infinity) - .padding(.vertical, ThemeCardMetrics.allUnlockedVerticalPadding) + .padding(.vertical, allUnlockedVerticalPadding) .listRowInsets(ThemeCardMetrics.gridRowInsets) .listRowBackground(Color.clear) .listRowSeparator(.hidden) diff --git a/MC1Services/Sources/MC1Services/Services/BackupUserDefaults.swift b/MC1Services/Sources/MC1Services/Services/BackupUserDefaults.swift index eab311b35..cd800a782 100644 --- a/MC1Services/Sources/MC1Services/Services/BackupUserDefaults.swift +++ b/MC1Services/Sources/MC1Services/Services/BackupUserDefaults.swift @@ -10,6 +10,7 @@ public struct BackupUserDefaults: Codable, Sendable, Equatable { public var mapStyleSelection: String? public var selectedThemeID: String? public var appColorSchemePreference: String? + public var appUITextSizePreference: String? public var mapShowLabels: Bool? public var mapNorthLocked: Bool? public var showDiscoveredNodesOnMap: Bool? @@ -150,6 +151,7 @@ public struct BackupUserDefaults: Codable, Sendable, Equatable { (\.tracePathViewMode, AppStorageKey.tracePathViewMode.rawValue), (\.selectedThemeID, PersistenceKeys.selectedThemeID), (\.appColorSchemePreference, PersistenceKeys.appColorSchemePreference), + (\.appUITextSizePreference, PersistenceKeys.appUITextSizePreference), ] /// Key strings used by `stringMappings`. Internal solely for testability — diff --git a/MC1Services/Sources/MC1Services/Utilities/PersistenceKeys.swift b/MC1Services/Sources/MC1Services/Utilities/PersistenceKeys.swift index 6b6086d34..c04ef63a1 100644 --- a/MC1Services/Sources/MC1Services/Utilities/PersistenceKeys.swift +++ b/MC1Services/Sources/MC1Services/Utilities/PersistenceKeys.swift @@ -23,4 +23,7 @@ public enum PersistenceKeys { /// App color-scheme preference raw value (`"system"` | `"light"` | `"dark"`). Bare string. public static let appColorSchemePreference = "appColorSchemePreference" + + /// App text-size preference raw value (`"system"` | `"large"` | `"xLarge"` | `"xxLarge"`). Bare string. + public static let appUITextSizePreference = "appUITextSizePreference" } diff --git a/MC1Tests/ThemeServiceTests.swift b/MC1Tests/ThemeServiceTests.swift index b9704b9d1..a172c41db 100644 --- a/MC1Tests/ThemeServiceTests.swift +++ b/MC1Tests/ThemeServiceTests.swift @@ -91,6 +91,49 @@ struct ThemeServicePureTests { #expect(defaults.string(forKey: PersistenceKeys.appColorSchemePreference) == "dark") } + @Test + func `missing appUITextSizePreference uses .system without writing back`() { + let defaults = freshDefaults() + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.uiTextSizePreference == .system) + #expect(defaults.object(forKey: PersistenceKeys.appUITextSizePreference) == nil) + } + + @Test + func `unknown appUITextSizePreference falls back to .system and overwrites`() { + let defaults = freshDefaults() + defaults.set("auto", forKey: PersistenceKeys.appUITextSizePreference) + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.uiTextSizePreference == .system) + #expect(defaults.string(forKey: PersistenceKeys.appUITextSizePreference) + == AppUITextSizePreference.system.rawValue) + } + + @Test + func `setUITextSizePreference persists the raw value and updates state`() { + let defaults = freshDefaults() + let service = ThemeService(store: emptyStore(), defaults: defaults) + service.setUITextSizePreference(.xLarge) + #expect(service.uiTextSizePreference == .xLarge) + #expect(defaults.string(forKey: PersistenceKeys.appUITextSizePreference) == "xLarge") + } + + @Test + func `AppUITextSizePreference maps to the expected dynamicTypeSize values`() { + #expect(AppUITextSizePreference.system.dynamicTypeSize == nil) + #expect(AppUITextSizePreference.large.dynamicTypeSize == .xLarge) + #expect(AppUITextSizePreference.xLarge.dynamicTypeSize == .xxLarge) + #expect(AppUITextSizePreference.xxLarge.dynamicTypeSize == .accessibility1) + } + + @Test + func `AppUITextSizePreference maps to the expected uiScale values`() { + #expect(AppUITextSizePreference.system.uiScale == 1.0) + #expect(AppUITextSizePreference.large.uiScale == 1.2) + #expect(AppUITextSizePreference.xLarge.uiScale == 1.35) + #expect(AppUITextSizePreference.xxLarge.uiScale == 1.5) + } + @Test func `effectiveColorScheme: default theme defers to the preference`() { let service = ThemeService(store: emptyStore(), defaults: freshDefaults()) @@ -173,6 +216,42 @@ struct ThemeServicePureTests { == AppColorSchemePreference.system.rawValue) } + @Test + func `refreshFromUserDefaults adopts an externally written text-size preference`() { + let defaults = freshDefaults() + let service = ThemeService(store: emptyStore(), defaults: defaults) + defaults.set("large", forKey: PersistenceKeys.appUITextSizePreference) + + service.refreshFromUserDefaults() + + #expect(service.uiTextSizePreference == .large) + } + + @Test + func `refreshFromUserDefaults overwrites an unknown text-size preference with .system`() { + let defaults = freshDefaults() + let service = ThemeService(store: emptyStore(), defaults: defaults) + defaults.set("auto", forKey: PersistenceKeys.appUITextSizePreference) + + service.refreshFromUserDefaults() + + #expect(service.uiTextSizePreference == .system) + #expect(defaults.string(forKey: PersistenceKeys.appUITextSizePreference) + == AppUITextSizePreference.system.rawValue) + } + + @Test + func `restore-origin downgrade: an unknown backed-up text-size falls back to .system on init`() { + let defaults = freshDefaults() + var prefs = BackupUserDefaults() + prefs.appUITextSizePreference = "auto" // a future raw value this build doesn't know + prefs.restore(to: defaults) // write-if-missing writes "auto" + let service = ThemeService(store: emptyStore(), defaults: defaults) + #expect(service.uiTextSizePreference == .system) + #expect(defaults.string(forKey: PersistenceKeys.appUITextSizePreference) + == AppUITextSizePreference.system.rawValue) + } + @Test func `restore-origin downgrade: an unknown backed-up scheme falls back to .system on init`() { let defaults = freshDefaults()