Convert Advanced and Developer settings to macOS-native helpers

Extend SettingsFormSection to accept a @ViewBuilder footer for sections
with dynamic multi-line content (last background refresh, orphaned
files status). Move trailing button accessories (size, progress) out of
button labels so buttons size to their content on macOS.
This commit is contained in:
Arkadiusz Fal
2026-04-21 00:48:30 +02:00
parent 72778870e1
commit 48963a9e2e
3 changed files with 162 additions and 125 deletions

View File

@@ -26,7 +26,7 @@ struct AdvancedSettingsView: View {
@State private var isScanningStorage = false
var body: some View {
List {
SettingsFormContainer {
streamDetailsSection
mpvSection
settingsSection
@@ -98,7 +98,7 @@ struct AdvancedSettingsView: View {
@ViewBuilder
private func feedSection(settingsManager: SettingsManager) -> some View {
Section {
SettingsFormSection("settings.advanced.feed.sectionTitle") {
PlatformMenuPicker(selection: Binding(
get: { settingsManager.feedCacheValidityMinutes },
set: { settingsManager.feedCacheValidityMinutes = $0 }
@@ -109,17 +109,13 @@ struct AdvancedSettingsView: View {
} label: {
Label(String(localized: "settings.advanced.feed.cacheValidity"), systemImage: "clock")
}
} header: {
Text(String(localized: "settings.advanced.feed.sectionTitle"))
} footer: {
VStack(alignment: .leading, spacing: 4) {
Text(String(localized: "settings.advanced.feed.footer"))
if let lastCheck = settingsManager.lastBackgroundCheck {
Text(String(localized: "settings.advanced.feed.lastBackgroundRefresh \(lastCheck.formatted(date: .abbreviated, time: .shortened))"))
.foregroundStyle(.secondary)
} else {
Text(String(localized: "settings.advanced.feed.lastBackgroundRefresh.never"))
.foregroundStyle(.secondary)
}
}
}
@@ -127,7 +123,7 @@ struct AdvancedSettingsView: View {
@ViewBuilder
private func userAgentSection(settingsManager: SettingsManager) -> some View {
Section {
SettingsFormSection("settings.advanced.userAgent.sectionTitle", footer: "settings.advanced.userAgent.footer") {
Toggle(isOn: Binding(
get: { settingsManager.randomizeUserAgentPerRequest },
set: {
@@ -165,25 +161,19 @@ struct AdvancedSettingsView: View {
Label(String(localized: "settings.advanced.userAgent.randomize"), systemImage: "arrow.trianglehead.2.clockwise")
}
}
} header: {
Text(String(localized: "settings.advanced.userAgent.sectionTitle"))
} footer: {
Text(String(localized: "settings.advanced.userAgent.footer"))
}
}
@ViewBuilder
private var streamDetailsSection: some View {
if let settingsManager = appEnvironment?.settingsManager {
Section {
SettingsFormSection(footer: "settings.advanced.stream.showDetails.footer") {
Toggle(isOn: Binding(
get: { settingsManager.showAdvancedStreamDetails },
set: { settingsManager.showAdvancedStreamDetails = $0 }
)) {
Label(String(localized: "settings.advanced.stream.showDetails"), systemImage: "list.bullet.rectangle")
}
} footer: {
Text(String(localized: "settings.advanced.stream.showDetails.footer"))
}
}
}
@@ -191,7 +181,7 @@ struct AdvancedSettingsView: View {
@ViewBuilder
private var mpvSection: some View {
if let settingsManager = appEnvironment?.settingsManager {
Section {
SettingsFormSection("settings.advanced.mpv.title") {
PlatformMenuPicker(selection: Binding(
get: { settingsManager.mpvBufferSeconds },
set: { settingsManager.mpvBufferSeconds = $0 }
@@ -217,13 +207,17 @@ struct AdvancedSettingsView: View {
Label(String(localized: "settings.playback.dash"), systemImage: "bolt.horizontal")
}
#if os(tvOS)
NavigationLink {
MPVOptionsSettingsView()
} label: {
Label(String(localized: "settings.advanced.mpv.options"), systemImage: "slider.horizontal.3")
}
} header: {
Text(String(localized: "settings.advanced.mpv.title"))
#else
SettingsNavigationRow("settings.advanced.mpv.options", systemImage: "slider.horizontal.3") {
MPVOptionsSettingsView()
}
#endif
}
}
}
@@ -241,24 +235,26 @@ struct AdvancedSettingsView: View {
#if !os(tvOS)
@ViewBuilder
private var downloadsStorageSection: some View {
Section {
SettingsFormSection("settings.advanced.storage.title") {
// Storage diagnostic button
Button {
runStorageDiagnostics()
} label: {
HStack {
HStack {
Button {
runStorageDiagnostics()
} label: {
Label(String(localized: "settings.advanced.storage.scan"), systemImage: "internaldrive")
Spacer()
if isScanningStorage {
ProgressView()
.controlSize(.small)
} else if let diagnostics = storageDiagnostics {
Text(diagnostics.formattedTotal)
.foregroundStyle(.secondary)
}
}
.disabled(isScanningStorage)
if isScanningStorage {
ProgressView()
.controlSize(.small)
} else if let diagnostics = storageDiagnostics {
Text(diagnostics.formattedTotal)
.foregroundStyle(.secondary)
}
Spacer()
}
.disabled(isScanningStorage)
// Show storage breakdown if scanned
if let diagnostics = storageDiagnostics {
@@ -275,28 +271,31 @@ struct AdvancedSettingsView: View {
}
// Clear cache button
Button(role: .destructive) {
showingClearDataConfirmation = true
} label: {
Label(String(localized: "settings.advanced.data.clearCache"), systemImage: "trash")
HStack {
Button(role: .destructive) {
showingClearDataConfirmation = true
} label: {
Label(String(localized: "settings.advanced.data.clearCache"), systemImage: "trash")
}
Spacer()
}
// Delete orphaned files button
Button(role: .destructive) {
showingOrphanCleanupConfirmation = true
} label: {
HStack {
HStack {
Button(role: .destructive) {
showingOrphanCleanupConfirmation = true
} label: {
Label(String(localized: "settings.advanced.storage.deleteOrphaned"), systemImage: "trash")
Spacer()
if isScanning {
ProgressView()
.controlSize(.small)
}
}
.disabled(orphanedFilesCount == 0 || isScanning)
if isScanning {
ProgressView()
.controlSize(.small)
}
Spacer()
}
.disabled(orphanedFilesCount == 0 || isScanning)
} header: {
Text(String(localized: "settings.advanced.storage.title"))
} footer: {
if isScanning {
Text(String(localized: "settings.advanced.storage.scanning"))
@@ -314,7 +313,7 @@ struct AdvancedSettingsView: View {
@ViewBuilder
private var deviceNameSection: some View {
if let settingsManager = appEnvironment?.settingsManager {
Section {
SettingsFormSection("settings.advanced.remoteControl.sectionTitle") {
TextField(
LocalNetworkService.systemDeviceName,
text: Binding(
@@ -338,8 +337,6 @@ struct AdvancedSettingsView: View {
Label(String(localized: "remoteControl.hideWhenBackgrounded"), systemImage: "moon.fill")
}
#endif
} header: {
Text(String(localized: "settings.advanced.remoteControl.sectionTitle"))
} footer: {
VStack(alignment: .leading, spacing: 4) {
Text(String(localized: "settings.advanced.remoteControl.footer"))
@@ -355,22 +352,32 @@ struct AdvancedSettingsView: View {
@ViewBuilder
private var developerSection: some View {
Section {
SettingsFormSection(footer: "settings.developer.footer") {
#if os(tvOS)
NavigationLink {
DeveloperSettingsView()
} label: {
Label(String(localized: "settings.developer.title"), systemImage: "hammer")
}
#else
SettingsNavigationRow("settings.developer.title", systemImage: "hammer") {
DeveloperSettingsView()
}
#endif
if appEnvironment?.legacyMigrationService.hasLegacyData() == true {
#if os(tvOS)
NavigationLink {
LegacyDataImportView()
} label: {
Label(String(localized: "settings.advanced.data.importLegacy"), systemImage: "arrow.up.doc")
}
#else
SettingsNavigationRow("settings.advanced.data.importLegacy", systemImage: "arrow.up.doc") {
LegacyDataImportView()
}
#endif
}
} footer: {
Text(String(localized: "settings.developer.footer"))
}
}

View File

@@ -20,7 +20,7 @@ struct DeveloperSettingsView: View {
@State private var showingResetiCloudComplete = false
var body: some View {
List {
SettingsFormContainer {
loggingSection
if loggingEnabled {
verboseLoggingSection
@@ -87,7 +87,7 @@ struct DeveloperSettingsView: View {
@ViewBuilder
private var loggingSection: some View {
Section {
SettingsFormSection("settings.advanced.logging.sectionTitle", footer: "settings.advanced.logging.footer") {
Toggle(isOn: $loggingEnabled) {
Label(String(localized: "settings.advanced.logging.enable"), systemImage: "doc.text")
}
@@ -96,6 +96,7 @@ struct DeveloperSettingsView: View {
}
if loggingEnabled {
#if os(tvOS)
NavigationLink {
LogViewerView()
} label: {
@@ -106,18 +107,23 @@ struct DeveloperSettingsView: View {
.foregroundStyle(.secondary)
}
}
#else
SettingsNavigationRow(
"settings.advanced.logging.viewLogs",
systemImage: "list.bullet.rectangle",
trailing: { Text("\(loggingService.entries.count)") }
) {
LogViewerView()
}
#endif
}
} header: {
Text(String(localized: "settings.advanced.logging.sectionTitle"))
} footer: {
Text(String(localized: "settings.advanced.logging.footer"))
}
}
@ViewBuilder
private var verboseLoggingSection: some View {
if let settingsManager = appEnvironment?.settingsManager {
Section {
SettingsFormSection("settings.advanced.verboseLogging.sectionTitle") {
Toggle(isOn: Binding(
get: { settingsManager.verboseMPVLogging },
set: { settingsManager.verboseMPVLogging = $0 }
@@ -131,8 +137,6 @@ struct DeveloperSettingsView: View {
)) {
Label(String(localized: "settings.advanced.debug.verboseRemote"), systemImage: "antenna.radiowaves.left.and.right")
}
} header: {
Text(String(localized: "settings.advanced.verboseLogging.sectionTitle"))
}
}
}
@@ -140,7 +144,7 @@ struct DeveloperSettingsView: View {
@ViewBuilder
private var debugSection: some View {
if let settingsManager = appEnvironment?.settingsManager {
Section {
SettingsFormSection("settings.advanced.debug.sectionTitle") {
Toggle(isOn: Binding(
get: { settingsManager.showPlayerAreaDebug },
set: { settingsManager.showPlayerAreaDebug = $0 }
@@ -165,8 +169,6 @@ struct DeveloperSettingsView: View {
Label(String(localized: "settings.advanced.debug.showTVDebugButton"), systemImage: "ant.circle")
}
#endif
} header: {
Text(String(localized: "settings.advanced.debug.sectionTitle"))
}
}
}
@@ -180,22 +182,24 @@ struct DeveloperSettingsView: View {
@ViewBuilder
private var dataSection: some View {
Section {
Button {
showingDeduplicateConfirmation = true
} label: {
Label(String(localized: "settings.advanced.data.removeDuplicates"), systemImage: "doc.on.doc")
SettingsFormSection("settings.advanced.data.sectionTitle", footer: "settings.advanced.data.footer") {
HStack {
Button {
showingDeduplicateConfirmation = true
} label: {
Label(String(localized: "settings.advanced.data.removeDuplicates"), systemImage: "doc.on.doc")
}
Spacer()
}
Button(role: .destructive) {
showingResetiCloudConfirmation = true
} label: {
Label(String(localized: "settings.advanced.data.resetICloud"), systemImage: "icloud.slash")
HStack {
Button(role: .destructive) {
showingResetiCloudConfirmation = true
} label: {
Label(String(localized: "settings.advanced.data.resetICloud"), systemImage: "icloud.slash")
}
Spacer()
}
} header: {
Text(String(localized: "settings.advanced.data.sectionTitle"))
} footer: {
Text(String(localized: "settings.advanced.data.footer"))
}
}
@@ -222,22 +226,24 @@ struct DeveloperSettingsView: View {
#if !os(tvOS)
@ViewBuilder
private var notificationTestingSection: some View {
Section {
Button {
sendTestNotification()
} label: {
Label(String(localized: "settings.advanced.testing.sendTestNotification"), systemImage: "bell.badge")
SettingsFormSection("settings.advanced.testing.notifications.sectionTitle", footer: "settings.advanced.testing.notifications.footer") {
HStack {
Button {
sendTestNotification()
} label: {
Label(String(localized: "settings.advanced.testing.sendTestNotification"), systemImage: "bell.badge")
}
Spacer()
}
Button {
triggerBackgroundRefresh()
} label: {
Label(String(localized: "settings.advanced.testing.triggerBackgroundRefresh"), systemImage: "arrow.clockwise")
HStack {
Button {
triggerBackgroundRefresh()
} label: {
Label(String(localized: "settings.advanced.testing.triggerBackgroundRefresh"), systemImage: "arrow.clockwise")
}
Spacer()
}
} header: {
Text(String(localized: "settings.advanced.testing.notifications.sectionTitle"))
} footer: {
Text(String(localized: "settings.advanced.testing.notifications.footer"))
}
}

View File

@@ -43,20 +43,10 @@ struct SettingsFormContainer<Content: View>: View {
/// content with consistent padding, a bottom divider, and an optional
/// caption-sized footer.
/// - On iOS/tvOS: renders a standard `Section { } header: { } footer: { }`.
struct SettingsFormSection<Content: View>: View {
struct SettingsFormSection<Content: View, Footer: View>: View {
let header: LocalizedStringKey?
let footer: LocalizedStringKey?
@ViewBuilder let content: () -> Content
init(
_ header: LocalizedStringKey? = nil,
footer: LocalizedStringKey? = nil,
@ViewBuilder content: @escaping () -> Content
) {
self.header = header
self.footer = footer
self.content = content
}
@ViewBuilder let footer: () -> Footer
var body: some View {
#if os(macOS)
@@ -91,49 +81,83 @@ struct SettingsFormSection<Content: View>: View {
Divider()
if let footer {
Text(footer)
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.top, 6)
}
footer()
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.top, 6)
}
.padding(.bottom, 12)
}
#else
@ViewBuilder
private var platformSection: some View {
if let header, let footer {
if let header {
Section {
content()
} header: {
Text(header)
} footer: {
Text(footer)
}
} else if let header {
Section {
content()
} header: {
Text(header)
}
} else if let footer {
Section {
content()
} footer: {
Text(footer)
footer()
}
} else {
Section {
content()
} footer: {
footer()
}
}
}
#endif
}
extension SettingsFormSection where Footer == EmptyView {
init(
_ header: LocalizedStringKey? = nil,
@ViewBuilder content: @escaping () -> Content
) {
self.header = header
self.content = content
self.footer = { EmptyView() }
}
}
extension SettingsFormSection where Footer == Text {
init(
_ header: LocalizedStringKey? = nil,
footer: LocalizedStringKey,
@ViewBuilder content: @escaping () -> Content
) {
self.header = header
self.content = content
self.footer = { Text(footer) }
}
init(
_ header: LocalizedStringKey? = nil,
footer: LocalizedStringKey?,
@ViewBuilder content: @escaping () -> Content
) {
self.header = header
self.content = content
let footerKey = footer
self.footer = { footerKey.map { Text($0) } ?? Text(verbatim: "") }
}
}
extension SettingsFormSection {
init(
_ header: LocalizedStringKey? = nil,
@ViewBuilder content: @escaping () -> Content,
@ViewBuilder footer: @escaping () -> Footer
) {
self.header = header
self.content = content
self.footer = footer
}
}
/// A label style that forces the icon to a fixed width so adjacent
/// labels align regardless of icon glyph width. Use when a section has
/// a vertical stack of `Label`s with mixed-width SF Symbols.