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