Files
yattee/Yattee/Views/Home/HomeSettingsView.swift
Arkadiusz Fal 8464464199 Fix locales
2026-02-09 00:13:46 +01:00

821 lines
30 KiB
Swift

//
// HomeSettingsView.swift
// Yattee
//
// Settings sheet for customizing the Home view layout.
//
import SwiftUI
struct HomeSettingsView: View {
@Environment(\.appEnvironment) private var appEnvironment
// Local state for editing (copied from settings on appear, saved on dismiss)
@State private var shortcutLayout: HomeShortcutLayout = .cards
@State private var shortcutOrder: [HomeShortcutItem] = []
@State private var shortcutVisibility: [HomeShortcutItem: Bool] = [:]
@State private var sectionOrder: [HomeSectionItem] = []
@State private var sectionVisibility: [HomeSectionItem: Bool] = [:]
@State private var sectionItemsLimit: Int = 5
// Available items (not yet added to Home)
@State private var availableShortcutsByInstance: [(instance: Instance, cards: [HomeShortcutItem])] = []
@State private var availableSectionsByInstance: [(instance: Instance, sections: [HomeSectionItem])] = []
@State private var availableShortcutsByMediaSource: [(source: MediaSource, cards: [HomeShortcutItem])] = []
@State private var availableSectionsByMediaSource: [(source: MediaSource, sections: [HomeSectionItem])] = []
// Edit mode for delete functionality
@State private var isEditMode = false
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
var body: some View {
List {
shortcutsSection
availableShortcutsSection
sectionsSection
availableSectionsSection
itemsLimitSection
}
#if os(iOS)
.environment(\.editMode, isEditMode ? .constant(.active) : .constant(.inactive))
#endif
.navigationTitle(String(localized: "home.settings.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.onAppear {
loadSettings()
}
.onDisappear {
saveSettings()
}
}
// MARK: - Sections
private var shortcutsSection: some View {
Section {
#if !os(tvOS)
// Layout picker (List vs Cards)
Picker(String(localized: "home.settings.shortcuts.layout"), selection: $shortcutLayout) {
ForEach(HomeShortcutLayout.allCases, id: \.self) { layout in
Label(layout.displayName, systemImage: layout.systemImage)
.tag(layout)
}
}
.pickerStyle(.segmented)
#endif
#if os(tvOS)
ForEach(Array(shortcutOrder.enumerated()), id: \.element.id) { index, card in
if card != .downloads {
TVHomeItemRow(
icon: card.icon,
title: card.localizedTitle,
isVisible: shortcutBinding(for: card),
canMoveUp: index > 0 && shortcutOrder[index - 1] != .downloads,
canMoveDown: index < shortcutOrder.count - 1,
onMoveUp: { moveShortcut(at: index, direction: -1) },
onMoveDown: { moveShortcut(at: index, direction: 1) },
canDelete: canDelete(shortcut: card),
onDelete: { removeShortcut(card) }
)
}
}
#else
ForEach(shortcutOrder) { card in
shortcutRowView(for: card)
}
.onMove { from, to in
shortcutOrder.move(fromOffsets: from, toOffset: to)
}
#endif
} header: {
Text(String(localized: "home.settings.shortcuts.header"))
}
}
private var availableShortcutsSection: some View {
Section {
if availableShortcutsByInstance.isEmpty && availableShortcutsByMediaSource.isEmpty {
Text(String(localized: "home.settings.availableShortcuts.empty"))
.foregroundStyle(.secondary)
.italic()
} else {
ForEach(availableShortcutsByInstance, id: \.instance.id) { item in
ForEach(item.cards) { card in
availableShortcutRow(for: card, instance: item.instance)
}
}
ForEach(availableShortcutsByMediaSource, id: \.source.id) { item in
ForEach(item.cards) { card in
availableMediaSourceShortcutRow(for: card, source: item.source)
}
}
}
} header: {
Text(String(localized: "home.settings.availableShortcuts.header"))
} footer: {
Text(String(localized: "home.settings.availableShortcuts.footer"))
}
}
private var sectionsSection: some View {
Section {
#if os(tvOS)
ForEach(Array(sectionOrder.enumerated()), id: \.element.id) { index, section in
if section != .downloads {
TVHomeItemRow(
icon: section.icon,
title: section.localizedTitle,
isVisible: sectionBinding(for: section),
canMoveUp: index > 0 && sectionOrder[index - 1] != .downloads,
canMoveDown: index < sectionOrder.count - 1,
onMoveUp: { moveSection(at: index, direction: -1) },
onMoveDown: { moveSection(at: index, direction: 1) },
canDelete: canDelete(section: section),
onDelete: { removeSection(section) }
)
}
}
#else
ForEach(sectionOrder) { section in
sectionRowView(for: section)
}
.onMove { from, to in
sectionOrder.move(fromOffsets: from, toOffset: to)
}
#endif
} header: {
Text(String(localized: "home.settings.sections.header"))
} footer: {
Text(String(localized: "home.settings.sections.footer"))
}
}
private var availableSectionsSection: some View {
Section {
if availableSectionsByInstance.isEmpty && availableSectionsByMediaSource.isEmpty {
Text(String(localized: "home.settings.availableSections.empty"))
.foregroundStyle(.secondary)
.italic()
} else {
ForEach(availableSectionsByInstance, id: \.instance.id) { item in
ForEach(item.sections) { section in
availableSectionRow(for: section, instance: item.instance)
}
}
ForEach(availableSectionsByMediaSource, id: \.source.id) { item in
ForEach(item.sections) { section in
availableMediaSourceSectionRow(for: section, source: item.source)
}
}
}
} header: {
Text(String(localized: "home.settings.availableSections.header"))
} footer: {
Text(String(localized: "home.settings.availableSections.footer"))
}
}
#if os(tvOS)
private func moveShortcut(at index: Int, direction: Int) {
let newIndex = index + direction
guard newIndex >= 0, newIndex < shortcutOrder.count else { return }
shortcutOrder.swapAt(index, newIndex)
}
private func moveSection(at index: Int, direction: Int) {
let newIndex = index + direction
guard newIndex >= 0, newIndex < sectionOrder.count else { return }
sectionOrder.swapAt(index, newIndex)
}
#endif
private var itemsLimitSection: some View {
Section {
#if os(tvOS)
HStack {
Text(String(localized: "home.settings.itemsLimit"))
Spacer()
Button {
if sectionItemsLimit > 1 { sectionItemsLimit -= 1 }
} label: {
Image(systemName: "minus.circle")
}
.buttonStyle(TVSettingsButtonStyle())
Text("\(sectionItemsLimit)")
.monospacedDigit()
Button {
if sectionItemsLimit < 20 { sectionItemsLimit += 1 }
} label: {
Image(systemName: "plus.circle")
}
.buttonStyle(TVSettingsButtonStyle())
}
#else
Stepper(value: $sectionItemsLimit, in: 1...20) {
HStack {
Text(String(localized: "home.settings.itemsLimit"))
Spacer()
Text("\(sectionItemsLimit)")
.foregroundStyle(.secondary)
}
}
#endif
}
}
// MARK: - Bindings
private func shortcutBinding(for card: HomeShortcutItem) -> Binding<Bool> {
Binding(
get: { shortcutVisibility[card] ?? true },
set: { shortcutVisibility[card] = $0 }
)
}
private func sectionBinding(for section: HomeSectionItem) -> Binding<Bool> {
Binding(
get: { sectionVisibility[section] ?? false },
set: { sectionVisibility[section] = $0 }
)
}
// MARK: - Data Management
private func loadSettings() {
guard let settings = settingsManager,
let env = appEnvironment else { return }
shortcutLayout = settings.homeShortcutLayout
shortcutOrder = settings.homeShortcutOrder
shortcutVisibility = settings.homeShortcutVisibility
sectionOrder = settings.homeSectionOrder
sectionVisibility = settings.homeSectionVisibility
sectionItemsLimit = settings.homeSectionItemsLimit
// Load available items
let instances = env.instancesManager.instances
availableShortcutsByInstance = settings.allAvailableShortcuts(instances: instances)
availableSectionsByInstance = settings.allAvailableSections(instances: instances)
let sources = env.mediaSourcesManager.sources
availableShortcutsByMediaSource = settings.allAvailableMediaSourceShortcuts(sources: sources)
availableSectionsByMediaSource = settings.allAvailableMediaSourceSections(sources: sources)
}
private func saveSettings() {
guard let settings = settingsManager else { return }
settings.homeShortcutLayout = shortcutLayout
settings.homeShortcutOrder = shortcutOrder
settings.homeShortcutVisibility = shortcutVisibility
settings.homeSectionOrder = sectionOrder
settings.homeSectionVisibility = sectionVisibility
settings.homeSectionItemsLimit = sectionItemsLimit
}
// MARK: - Available Item Management
private func addShortcut(_ card: HomeShortcutItem) {
// Add to local state
if !shortcutOrder.contains(where: { $0.id == card.id }) {
shortcutOrder.append(card)
shortcutVisibility[card] = true // Visible by default
}
// Persist to settings
switch card {
case .instanceContent(let instanceID, let contentType):
settingsManager?.addToHome(instanceID: instanceID, contentType: contentType, asCard: true)
case .mediaSource(let sourceID):
settingsManager?.addToHome(sourceID: sourceID, asCard: true)
default:
break
}
// Reload available items
loadSettings()
}
private func addSection(_ section: HomeSectionItem) {
// Add to local state
if !sectionOrder.contains(where: { $0.id == section.id }) {
sectionOrder.append(section)
sectionVisibility[section] = true // Visible by default
}
// Persist to settings
switch section {
case .instanceContent(let instanceID, let contentType):
settingsManager?.addToHome(instanceID: instanceID, contentType: contentType, asCard: false)
case .mediaSource(let sourceID):
settingsManager?.addToHome(sourceID: sourceID, asCard: false)
default:
break
}
// Reload available items
loadSettings()
}
private func removeShortcut(_ card: HomeShortcutItem) {
// Remove from local state
shortcutOrder.removeAll { $0.id == card.id }
shortcutVisibility.removeValue(forKey: card)
// Persist to settings
switch card {
case .instanceContent(let instanceID, let contentType):
settingsManager?.removeFromHome(instanceID: instanceID, contentType: contentType)
case .mediaSource(let sourceID):
settingsManager?.removeFromHome(sourceID: sourceID)
default:
break
}
// Reload available items
loadSettings()
}
private func removeSection(_ section: HomeSectionItem) {
// Remove from local state
sectionOrder.removeAll { $0.id == section.id }
sectionVisibility.removeValue(forKey: section)
// Persist to settings
switch section {
case .instanceContent(let instanceID, let contentType):
settingsManager?.removeFromHome(instanceID: instanceID, contentType: contentType)
case .mediaSource(let sourceID):
settingsManager?.removeFromHome(sourceID: sourceID)
default:
break
}
// Reload available items
loadSettings()
}
private func canDelete(shortcut: HomeShortcutItem) -> Bool {
if case .instanceContent = shortcut {
return true
}
if case .mediaSource = shortcut {
return true
}
return false
}
private func canDelete(section: HomeSectionItem) -> Bool {
if case .instanceContent = section {
return true
}
if case .mediaSource = section {
return true
}
return false
}
// MARK: - Card and Section Row Views
@ViewBuilder
private func shortcutRowView(for card: HomeShortcutItem) -> some View {
switch card {
case .instanceContent(let instanceID, let contentType):
if let instance = instanceFromID(instanceID) {
let isDisabled = !instance.isEnabled || (contentType == .feed && !isLoggedIn(instance))
HomeItemRow(
icon: contentType.icon,
title: "\(instance.displayName) - \(contentType.localizedTitle)",
isVisible: shortcutBinding(for: card),
isDisabled: isDisabled,
disabledReason: disabledReason(instance: instance, contentType: contentType)
)
#if os(iOS)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
removeShortcut(card)
} label: {
Label(String(localized: "home.settings.remove"), systemImage: "trash")
}
.tint(.red)
}
#endif
.contextMenu {
Button(role: .destructive) {
removeShortcut(card)
} label: {
Label(String(localized: "home.settings.remove"), systemImage: "trash")
}
}
}
case .mediaSource(let sourceID):
if let source = appEnvironment?.mediaSourcesManager.sources.first(where: { $0.id == sourceID }) {
let isDisabled = !source.isEnabled
HomeItemRow(
icon: source.type.systemImage,
title: "\(source.name) (\(source.type.displayName))",
isVisible: shortcutBinding(for: card),
isDisabled: isDisabled,
disabledReason: isDisabled ? String(localized: "home.settings.sourceDisabled") : nil
)
#if os(iOS)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
removeShortcut(card)
} label: {
Label(String(localized: "home.settings.remove"), systemImage: "trash")
}
.tint(.red)
}
#endif
.contextMenu {
Button(role: .destructive) {
removeShortcut(card)
} label: {
Label(String(localized: "home.settings.remove"), systemImage: "trash")
}
}
}
default:
HomeItemRow(
icon: card.icon,
title: card.localizedTitle,
isVisible: shortcutBinding(for: card)
)
}
}
@ViewBuilder
private func sectionRowView(for section: HomeSectionItem) -> some View {
switch section {
case .instanceContent(let instanceID, let contentType):
if let instance = instanceFromID(instanceID) {
let isDisabled = !instance.isEnabled || (contentType == .feed && !isLoggedIn(instance))
HomeItemRow(
icon: contentType.icon,
title: "\(instance.displayName) - \(contentType.localizedTitle)",
isVisible: sectionBinding(for: section),
isDisabled: isDisabled,
disabledReason: disabledReason(instance: instance, contentType: contentType)
)
#if os(iOS)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
removeSection(section)
} label: {
Label(String(localized: "home.settings.remove"), systemImage: "trash")
}
.tint(.red)
}
#endif
.contextMenu {
Button(role: .destructive) {
removeSection(section)
} label: {
Label(String(localized: "home.settings.remove"), systemImage: "trash")
}
}
}
case .mediaSource(let sourceID):
if let source = appEnvironment?.mediaSourcesManager.sources.first(where: { $0.id == sourceID }) {
let isDisabled = !source.isEnabled
HomeItemRow(
icon: source.type.systemImage,
title: "\(source.name) (\(source.type.displayName))",
isVisible: sectionBinding(for: section),
isDisabled: isDisabled,
disabledReason: isDisabled ? String(localized: "home.settings.sourceDisabled") : nil
)
#if os(iOS)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
removeSection(section)
} label: {
Label(String(localized: "home.settings.remove"), systemImage: "trash")
}
.tint(.red)
}
#endif
.contextMenu {
Button(role: .destructive) {
removeSection(section)
} label: {
Label(String(localized: "home.settings.remove"), systemImage: "trash")
}
}
}
default:
HomeItemRow(
icon: section.icon,
title: section.localizedTitle,
isVisible: sectionBinding(for: section)
)
}
}
@ViewBuilder
private func availableShortcutRow(for card: HomeShortcutItem, instance: Instance) -> some View {
if case .instanceContent(_, let contentType) = card {
let isDisabled = !instance.isEnabled || (contentType == .feed && !isLoggedIn(instance))
HStack {
Image(systemName: contentType.icon)
.frame(width: 24)
.foregroundStyle(isDisabled ? .tertiary : .secondary)
Text("\(instance.displayName) - \(contentType.localizedTitle)")
.foregroundStyle(isDisabled ? .tertiary : .primary)
Spacer()
Button {
addShortcut(card)
} label: {
Image(systemName: "plus.circle.fill")
.foregroundStyle(.green)
.imageScale(.large)
}
.buttonStyle(.plain)
.disabled(isDisabled)
}
#if !os(tvOS)
.help(isDisabled ? (disabledReason(instance: instance, contentType: contentType) ?? "") : "")
#endif
}
}
@ViewBuilder
private func availableSectionRow(for section: HomeSectionItem, instance: Instance) -> some View {
if case .instanceContent(_, let contentType) = section {
let isDisabled = !instance.isEnabled || (contentType == .feed && !isLoggedIn(instance))
HStack {
Image(systemName: contentType.icon)
.frame(width: 24)
.foregroundStyle(isDisabled ? .tertiary : .secondary)
Text("\(instance.displayName) - \(contentType.localizedTitle)")
.foregroundStyle(isDisabled ? .tertiary : .primary)
Spacer()
Button {
addSection(section)
} label: {
Image(systemName: "plus.circle.fill")
.foregroundStyle(.green)
.imageScale(.large)
}
.buttonStyle(.plain)
.disabled(isDisabled)
}
#if !os(tvOS)
.help(isDisabled ? (disabledReason(instance: instance, contentType: contentType) ?? "") : "")
#endif
}
}
private func instanceFromID(_ id: UUID) -> Instance? {
appEnvironment?.instancesManager.instances.first(where: { $0.id == id })
}
private func isLoggedIn(_ instance: Instance) -> Bool {
guard instance.supportsFeed else { return false }
return appEnvironment?.credentialsManager(for: instance)?.isLoggedIn(for: instance) ?? false
}
private func disabledReason(instance: Instance, contentType: InstanceContentType) -> String? {
if !instance.isEnabled {
return String(localized: "home.settings.instanceDisabled")
}
if contentType == .feed && !isLoggedIn(instance) {
return String(localized: "home.settings.feedRequiresLogin")
}
return nil
}
@ViewBuilder
private func availableMediaSourceShortcutRow(for card: HomeShortcutItem, source: MediaSource) -> some View {
if case .mediaSource = card {
let isDisabled = !source.isEnabled
HStack {
Image(systemName: source.type.systemImage)
.frame(width: 24)
.foregroundStyle(isDisabled ? .tertiary : .secondary)
Text("\(source.name) (\(source.type.displayName))")
.foregroundStyle(isDisabled ? .tertiary : .primary)
Spacer()
Button {
addShortcut(card)
} label: {
Image(systemName: "plus.circle.fill")
.foregroundStyle(.green)
.imageScale(.large)
}
.buttonStyle(.plain)
.disabled(isDisabled)
}
#if !os(tvOS)
.help(isDisabled ? String(localized: "home.settings.sourceDisabled") : "")
#endif
}
}
@ViewBuilder
private func availableMediaSourceSectionRow(for section: HomeSectionItem, source: MediaSource) -> some View {
if case .mediaSource = section {
let isDisabled = !source.isEnabled
HStack {
Image(systemName: source.type.systemImage)
.frame(width: 24)
.foregroundStyle(isDisabled ? .tertiary : .secondary)
Text("\(source.name) (\(source.type.displayName))")
.foregroundStyle(isDisabled ? .tertiary : .primary)
Spacer()
Button {
addSection(section)
} label: {
Image(systemName: "plus.circle.fill")
.foregroundStyle(.green)
.imageScale(.large)
}
.buttonStyle(.plain)
.disabled(isDisabled)
}
#if !os(tvOS)
.help(isDisabled ? String(localized: "home.settings.sourceDisabled") : "")
#endif
}
}
}
// MARK: - Home Item Row
#if os(tvOS)
private struct TVHomeItemRow: View {
let icon: String
let title: String
@Binding var isVisible: Bool
let canMoveUp: Bool
let canMoveDown: Bool
let onMoveUp: () -> Void
let onMoveDown: () -> Void
var canDelete: Bool = false
var onDelete: (() -> Void)? = nil
@State private var showingDeleteConfirmation = false
var body: some View {
HStack(spacing: 12) {
// Move buttons - compact style
VStack(spacing: 4) {
Button {
onMoveUp()
} label: {
Image(systemName: "chevron.up")
.font(.caption)
.foregroundStyle(canMoveUp ? .primary : .tertiary)
.frame(width: 30, height: 24)
}
.buttonStyle(TVCompactButtonStyle())
.disabled(!canMoveUp)
Button {
onMoveDown()
} label: {
Image(systemName: "chevron.down")
.font(.caption)
.foregroundStyle(canMoveDown ? .primary : .tertiary)
.frame(width: 30, height: 24)
}
.buttonStyle(TVCompactButtonStyle())
.disabled(!canMoveDown)
}
// Main content - toggle visibility
Button {
isVisible.toggle()
} label: {
HStack {
Image(systemName: icon)
.frame(width: 24)
.foregroundStyle(.secondary)
Text(title)
.foregroundStyle(.primary)
Spacer()
Image(systemName: isVisible ? "checkmark.circle.fill" : "circle")
.foregroundColor(isVisible ? .green : .secondary)
.font(.title3)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(TVFormRowButtonStyle())
// Delete button (only for instance content)
if canDelete {
Button {
showingDeleteConfirmation = true
} label: {
Image(systemName: "trash")
.font(.caption)
.foregroundStyle(.red)
.frame(width: 30, height: 24)
}
.buttonStyle(TVCompactButtonStyle())
.alert(String(localized: "home.removeConfirmation.title"), isPresented: $showingDeleteConfirmation) {
Button(String(localized: "common.cancel"), role: .cancel) { }
Button("Remove", role: .destructive) {
onDelete?()
}
}
}
}
.padding(.vertical, 4)
}
}
/// Compact button style for small controls like up/down arrows
private struct TVCompactButtonStyle: ButtonStyle {
@Environment(\.isFocused) private var isFocused
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isFocused ? Color.white.opacity(0.2) : Color.clear)
)
.scaleEffect(configuration.isPressed ? 0.9 : (isFocused ? 1.1 : 1.0))
.animation(.easeInOut(duration: 0.1), value: isFocused)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
#endif
private struct HomeItemRow: View {
let icon: String
let title: String
@Binding var isVisible: Bool
var isDisabled: Bool = false
var disabledReason: String? = nil
var body: some View {
#if os(tvOS)
Button {
if !isDisabled {
isVisible.toggle()
}
} label: {
HStack {
Image(systemName: icon)
.frame(width: 24)
.foregroundStyle(isDisabled ? .tertiary : .secondary)
Text(title)
.foregroundStyle(isDisabled ? .tertiary : .primary)
Spacer()
Image(systemName: isVisible ? "checkmark.circle.fill" : "circle")
.foregroundColor(isDisabled ? .secondary.opacity(0.5) : (isVisible ? .green : .secondary))
.font(.title3)
}
.contentShape(Rectangle())
}
.buttonStyle(TVFormRowButtonStyle())
.disabled(isDisabled)
#else
HStack {
Image(systemName: icon)
.frame(width: 24)
.foregroundStyle(isDisabled ? .tertiary : .secondary)
Text(title)
.foregroundStyle(isDisabled ? .tertiary : .primary)
Spacer()
Toggle("", isOn: $isVisible)
.labelsHidden()
.disabled(isDisabled)
}
.help(disabledReason ?? "")
#endif
}
}
// MARK: - Preview
#Preview {
HomeSettingsView()
.appEnvironment(.preview)
}