Add back local profile for tvOS

This commit is contained in:
世界
2026-01-01 19:47:49 +08:00
parent 9b179fe5cd
commit 55e01a89b5
7 changed files with 200 additions and 184 deletions
@@ -6,11 +6,13 @@
/// Using UIButton via UIViewRepresentable bypasses this issue.
struct TVToolbarButton: UIViewRepresentable {
let title: String
var isEnabled: Bool = true
let action: () -> Void
func makeUIView(context: Context) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.isEnabled = isEnabled
button.setContentHuggingPriority(.required, for: .horizontal)
button.setContentCompressionResistancePriority(.required, for: .horizontal)
button.addTarget(context.coordinator, action: #selector(Coordinator.buttonTapped), for: .primaryActionTriggered)
@@ -19,6 +21,7 @@
func updateUIView(_ uiView: UIButton, context: Context) {
uiView.setTitle(title, for: .normal)
uiView.isEnabled = isEnabled
uiView.invalidateIntrinsicContentSize()
uiView.sizeToFit()
context.coordinator.action = action
@@ -1,109 +1,129 @@
#if os(iOS) || os(macOS)
import Foundation
import Library
import SwiftUI
import Foundation
import Library
import SwiftUI
@MainActor
public struct EditProfileContentView: View {
public struct Context: Codable, Hashable {
public let profileID: Int64
public let readOnly: Bool
@MainActor
public struct EditProfileContentView: View {
public struct Context: Codable, Hashable {
public let profileID: Int64
public let readOnly: Bool
}
private let readOnly: Bool
@StateObject private var viewModel: EditProfileContentViewModel
public init(_ context: Context?) {
readOnly = context?.readOnly == true
_viewModel = StateObject(wrappedValue: EditProfileContentViewModel(profileID: context?.profileID))
}
@Environment(\.dismiss) private var dismiss
@Environment(\.profileEditor) private var profileEditor
public var body: some View {
Group {
if viewModel.isLoading {
ProgressView().onAppear {
Task {
await viewModel.loadContent()
}
}
} else {
editorView
.onChangeCompat(of: viewModel.profileContent) {
viewModel.markAsChanged()
}
}
}
private let readOnly: Bool
@StateObject private var viewModel: EditProfileContentViewModel
public init(_ context: Context?) {
readOnly = context?.readOnly == true
_viewModel = StateObject(wrappedValue: EditProfileContentViewModel(profileID: context?.profileID))
}
@Environment(\.dismiss) private var dismiss
@Environment(\.profileEditor) private var profileEditor
public var body: some View {
Group {
if viewModel.isLoading {
ProgressView().onAppear {
Task {
await viewModel.loadContent()
.alert($viewModel.alert)
.navigationTitle(navigationTitle)
#if os(macOS)
.toolbar {
ToolbarItemGroup(placement: .navigation) {
if !readOnly {
Button {
Task {
await viewModel.saveContent()
}
} label: {
Label("Save", image: "save")
}
.disabled(!viewModel.isChanged)
} else {
Button {
NSPasteboard.general.setString(viewModel.profileContent, forType: .fileContents)
} label: {
Label("Copy", systemImage: "clipboard.fill")
}
}
} else {
editorView
.onChangeCompat(of: viewModel.profileContent) {
viewModel.markAsChanged()
}
}
}
.alert($viewModel.alert)
.navigationTitle(navigationTitle)
#elseif os(iOS)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if !readOnly {
Button("Save") {
Task {
await viewModel.saveContent()
}
}.disabled(!viewModel.isChanged)
} else {
Button("Copy") {
UIPasteboard.general.string = viewModel.profileContent
}
}
}
}
.navigationBarTitleDisplayMode(.inline)
#elseif os(tvOS)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if !readOnly {
TVToolbarButton(title: String(localized: "Save"), isEnabled: viewModel.isChanged) {
Task {
await viewModel.saveContent()
}
}
}
}
}
#endif
}
private var navigationTitle: String {
if readOnly {
return String(localized: "View Content")
} else {
return String(localized: "Edit Content")
}
}
@ViewBuilder
private var editorView: some View {
if let profileEditor {
profileEditor(
readOnly ? .constant(viewModel.profileContent) : $viewModel.profileContent,
!readOnly
)
#if os(macOS)
.toolbar {
ToolbarItemGroup(placement: .navigation) {
if !readOnly {
Button {
Task {
await viewModel.saveContent()
}
} label: {
Label("Save", image: "save")
}
.disabled(!viewModel.isChanged)
} else {
Button {
NSPasteboard.general.setString(viewModel.profileContent, forType: .fileContents)
} label: {
Label("Copy", systemImage: "clipboard.fill")
}
}
}
}
#elseif os(iOS)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if !readOnly {
Button("Save") {
Task {
await viewModel.saveContent()
}
}.disabled(!viewModel.isChanged)
} else {
Button("Copy") {
UIPasteboard.general.string = viewModel.profileContent
}
}
}
}
.navigationBarTitleDisplayMode(.inline)
.frame(maxWidth: .infinity, maxHeight: .infinity)
#endif
} else {
defaultEditorView
}
}
private var navigationTitle: String {
if readOnly {
return String(localized: "View Content")
} else {
return String(localized: "Edit Content")
@ViewBuilder
private var defaultEditorView: some View {
#if os(tvOS)
ScrollView {
TextField("", text: readOnly ? .constant(viewModel.profileContent) : $viewModel.profileContent, axis: .vertical)
.lineLimit(1000)
.font(Font.system(.caption2, design: .monospaced))
.autocorrectionDisabled(true)
.disabled(readOnly)
}
}
@ViewBuilder
private var editorView: some View {
if let profileEditor {
profileEditor(
readOnly ? .constant(viewModel.profileContent) : $viewModel.profileContent,
!readOnly
)
#if os(macOS)
.frame(maxWidth: .infinity, maxHeight: .infinity)
#endif
} else {
defaultEditorView
}
}
@ViewBuilder
private var defaultEditorView: some View {
#else
Group {
if readOnly {
TextEditor(text: .constant(viewModel.profileContent))
@@ -117,7 +137,6 @@
.textContentType(.init(rawValue: ""))
.padding()
#endif
}
#endif
}
#endif
}
@@ -1,74 +1,71 @@
#if os(iOS) || os(macOS)
import Foundation
import Library
import SwiftUI
import Foundation
import Library
import SwiftUI
@MainActor
public final class EditProfileContentViewModel: BaseViewModel {
@Published public var profile: Profile?
@Published public var profileContent = ""
@Published public var isChanged = false
@MainActor
public final class EditProfileContentViewModel: BaseViewModel {
@Published public var profile: Profile?
@Published public var profileContent = ""
@Published public var isChanged = false
private let profileID: Int64?
private let profileID: Int64?
public init(profileID: Int64?) {
self.profileID = profileID
super.init()
isLoading = true
public init(profileID: Int64?) {
self.profileID = profileID
super.init()
isLoading = true
}
public func markAsChanged() {
isChanged = true
}
public func reset() {
isLoading = true
profile = nil
profileContent = ""
isChanged = false
alert = nil
}
public func loadContent() async {
do {
try await loadContentBackground()
} catch {
alert = AlertState(error: error)
}
isLoading = false
}
public func markAsChanged() {
isChanged = true
private nonisolated func loadContentBackground() async throws {
guard let profileID else {
throw NSError(domain: "Context destroyed", code: 0)
}
public func reset() {
isLoading = true
profile = nil
profileContent = ""
isChanged = false
alert = nil
guard let profile = try await ProfileManager.get(profileID) else {
throw NSError(domain: "Profile missing", code: 0)
}
public func loadContent() async {
do {
try await loadContentBackground()
} catch {
alert = AlertState(error: error)
}
isLoading = false
}
private nonisolated func loadContentBackground() async throws {
guard let profileID else {
throw NSError(domain: "Context destroyed", code: 0)
}
guard let profile = try await ProfileManager.get(profileID) else {
throw NSError(domain: "Profile missing", code: 0)
}
let profileContent = try profile.read()
await MainActor.run {
self.profile = profile
self.profileContent = profileContent
}
}
public func saveContent() async {
guard let profile else {
return
}
do {
try await saveContentBackground(profile)
} catch {
alert = AlertState(error: error)
return
}
isChanged = false
}
private nonisolated func saveContentBackground(_ profile: Profile) async throws {
let profileContent = await profileContent
try profile.write(profileContent)
let profileContent = try profile.read()
await MainActor.run {
self.profile = profile
self.profileContent = profileContent
}
}
#endif
public func saveContent() async {
guard let profile else {
return
}
do {
try await saveContentBackground(profile)
} catch {
alert = AlertState(error: error)
return
}
isChanged = false
}
private nonisolated func saveContentBackground(_ profile: Profile) async throws {
let profileContent = await profileContent
try profile.write(profileContent)
}
}
@@ -51,8 +51,8 @@ public struct NewProfileView: View {
.multilineTextAlignment(.trailing)
}
Picker(selection: $viewModel.profileType) {
Text("Local").tag(ProfileType.local)
#if !os(tvOS)
Text("Local").tag(ProfileType.local)
Text("iCloud").tag(ProfileType.icloud)
#endif
Text("Remote").tag(ProfileType.remote)
@@ -190,6 +190,11 @@ public struct NewProfileView: View {
.navigationTitle("New Profile")
.disabled(viewModel.isSaving)
.alert($viewModel.alert)
.onChangeCompat(of: viewModel.createSucceeded) { newValue in
if newValue {
dismiss()
}
}
#if os(iOS)
.fileImporter(
isPresented: $viewModel.pickerPresented,
@@ -6,6 +6,7 @@ import SwiftUI
@MainActor
public final class NewProfileViewModel: BaseViewModel {
@Published public var isSaving = false
@Published public var createSucceeded = false
@Published public var profileName = ""
#if !os(tvOS)
@Published public var profileType = ProfileType.local
@@ -81,6 +82,7 @@ public final class NewProfileViewModel: BaseViewModel {
if sendUpdateNotification {
environments.profileUpdate.send()
}
createSucceeded = true
dismiss?()
#if os(macOS)
@@ -18,16 +18,14 @@ public struct ProfileActionToolbar: View {
}
public var body: some View {
#if os(iOS)
#if os(iOS) || os(tvOS)
iosBody
#elseif os(tvOS)
tvOSBody
#elseif os(macOS)
macOSBody
#endif
}
#if os(iOS)
#if os(iOS) || os(tvOS)
private var iosBody: some View {
Section("Action") {
if profile.type != .remote {
@@ -49,12 +47,6 @@ public struct ProfileActionToolbar: View {
}
#endif
#if os(tvOS)
private var tvOSBody: some View {
EmptyView()
}
#endif
#if os(macOS)
private var macOSBody: some View {
VStack(spacing: 0) {
@@ -1,14 +1,12 @@
#if os(iOS) || os(macOS)
import SwiftUI
import SwiftUI
public struct ProfileEditorKey: EnvironmentKey {
public static let defaultValue: ((Binding<String>, Bool) -> AnyView)? = nil
}
public struct ProfileEditorKey: EnvironmentKey {
public static let defaultValue: ((Binding<String>, Bool) -> AnyView)? = nil
}
public extension EnvironmentValues {
var profileEditor: ((Binding<String>, Bool) -> AnyView)? {
get { self[ProfileEditorKey.self] }
set { self[ProfileEditorKey.self] = newValue }
}
public extension EnvironmentValues {
var profileEditor: ((Binding<String>, Bool) -> AnyView)? {
get { self[ProfileEditorKey.self] }
set { self[ProfileEditorKey.self] = newValue }
}
#endif
}