Merge profile to dashboard
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import QRCode
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct ProfileCard: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@StateObject private var viewModel = ViewModel()
|
||||
|
||||
@Binding private var profileList: [ProfilePreview]
|
||||
@Binding private var selectedProfileID: Int64
|
||||
|
||||
@@ -13,39 +20,431 @@ public struct ProfileCard: View {
|
||||
_selectedProfileID = selectedProfileID
|
||||
}
|
||||
|
||||
private var selectedProfile: ProfilePreview? {
|
||||
profileList.first { $0.id == selectedProfileID }
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
DashboardCardView(title: "Profile", isHalfWidth: false) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
#if os(iOS) || os(tvOS)
|
||||
Picker("", selection: $selectedProfileID) {
|
||||
ForEach(profileList, id: \.id) { profile in
|
||||
Text(profile.name).tag(profile.id)
|
||||
}
|
||||
DashboardCardView(title: "") {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
headerView
|
||||
profileSelectorView
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isUpdating)
|
||||
.sheet(isPresented: $viewModel.showNewProfile, onDismiss: {
|
||||
environments.profileUpdate.send()
|
||||
}) {
|
||||
NewProfileNavigationView()
|
||||
.environmentObject(environments)
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showManageProfiles) {
|
||||
manageProfilesSheet
|
||||
}
|
||||
.sheet(item: $viewModel.profileToEdit) { profile in
|
||||
editProfileSheet(for: profile)
|
||||
}
|
||||
#if os(iOS) || os(tvOS)
|
||||
.sheet(isPresented: $viewModel.showQRCode) {
|
||||
if let profile = selectedProfile, let remoteURL = profile.remoteURL {
|
||||
QRCodeSheet(profileName: profile.name, remoteURL: remoteURL)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.alertBinding($viewModel.alert)
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
HStack {
|
||||
Text("Profile")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
viewModel.showNewProfile = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
|
||||
if !profileList.isEmpty {
|
||||
Button {
|
||||
viewModel.showManageProfiles = true
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var profileSelectorView: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if profileList.isEmpty {
|
||||
Text("Empty profiles")
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 8)
|
||||
} else {
|
||||
ProfileSelectorButton(
|
||||
items: profileList,
|
||||
selectedItem: selectedProfile,
|
||||
onSelect: { selectedProfileID = $0 }
|
||||
)
|
||||
|
||||
if let profile = selectedProfile {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
profileInfo(for: profile)
|
||||
actionButtonsRow(for: profile)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.labelsHidden()
|
||||
#elseif os(macOS)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(profileList, id: \.id) { profile in
|
||||
HStack {
|
||||
Button {
|
||||
selectedProfileID = profile.id
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: selectedProfileID == profile.id ? "circle.fill" : "circle")
|
||||
.font(.system(size: 12))
|
||||
Text(profile.name)
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(selectedProfileID == profile.id ? .accentColor : .primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func actionButtonsRow(for profile: ProfilePreview) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
editButton(for: profile)
|
||||
|
||||
if profile.type == .remote {
|
||||
updateButton(for: profile)
|
||||
qrCodeButton(for: profile)
|
||||
}
|
||||
|
||||
ProfileShareButton($viewModel.alert, profile.origin) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func editButton(for profile: ProfilePreview) -> some View {
|
||||
Button {
|
||||
viewModel.profileToEdit = profile.origin
|
||||
} label: {
|
||||
Image(systemName: "pencil")
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func updateButton(for profile: ProfilePreview) -> some View {
|
||||
Button {
|
||||
viewModel.isUpdating = true
|
||||
Task {
|
||||
await viewModel.updateProfile(profile.origin, environments: environments)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 16))
|
||||
.rotationEffect(.degrees(viewModel.isUpdating ? 360 : 0))
|
||||
.animation(
|
||||
viewModel.isUpdating
|
||||
? .linear(duration: 1).repeatForever(autoreverses: false)
|
||||
: .default,
|
||||
value: viewModel.isUpdating
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
.disabled(viewModel.isUpdating)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func qrCodeButton(for profile: ProfilePreview) -> some View {
|
||||
#if os(iOS) || os(tvOS)
|
||||
Button {
|
||||
viewModel.showQRCode = true
|
||||
} label: {
|
||||
Image(systemName: "qrcode")
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
#elseif os(macOS)
|
||||
Button {
|
||||
viewModel.showQRCode = true
|
||||
} label: {
|
||||
Image(systemName: "qrcode")
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
.popover(isPresented: $viewModel.showQRCode, arrowEdge: .bottom) {
|
||||
if let remoteURL = profile.remoteURL {
|
||||
QRCodeContentView(profileName: profile.name, remoteURL: remoteURL)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func profileInfo(for profile: ProfilePreview) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: profile.type == .remote ? "cloud.fill" : "doc.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
Text(profile.type == .remote ? "Remote" : "Local")
|
||||
.font(.caption)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
if profile.type == .remote, let lastUpdated = profile.lastUpdated {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "clock.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
Text(lastUpdated.myFormat)
|
||||
.font(.caption)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var manageProfilesSheet: some View {
|
||||
NavigationSheet(
|
||||
title: "Profiles",
|
||||
showDoneButton: true,
|
||||
onDismiss: { viewModel.showManageProfiles = false }
|
||||
) {
|
||||
ManageProfilesView()
|
||||
.environmentObject(environments)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func editProfileSheet(for profile: Profile) -> some View {
|
||||
NavigationSheet(title: "Edit Profile") {
|
||||
EditProfileView()
|
||||
.environmentObject(profile)
|
||||
.environmentObject(environments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ViewModel
|
||||
|
||||
extension ProfileCard {
|
||||
@MainActor
|
||||
class ViewModel: ObservableObject {
|
||||
@Published var showNewProfile = false
|
||||
@Published var showManageProfiles = false
|
||||
@Published var showQRCode = false
|
||||
@Published var isUpdating = false
|
||||
@Published var alert: Alert?
|
||||
@Published var profileToEdit: Profile?
|
||||
|
||||
func updateProfile(_ profile: Profile, environments: ExtensionEnvironments) async {
|
||||
defer { isUpdating = false }
|
||||
|
||||
do {
|
||||
try await profile.updateRemoteProfile()
|
||||
environments.profileUpdate.send()
|
||||
} catch {
|
||||
alert = Alert(
|
||||
title: Text("Update Failed"),
|
||||
message: Text(error.localizedDescription)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NewProfileNavigationView
|
||||
|
||||
extension ProfileCard {
|
||||
@MainActor
|
||||
struct NewProfileNavigationView: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@State private var createdProfile: Profile?
|
||||
|
||||
var body: some View {
|
||||
NavigationStackCompat {
|
||||
if let profile = createdProfile {
|
||||
EditProfileView()
|
||||
.environmentObject(profile)
|
||||
.environmentObject(environments)
|
||||
} else {
|
||||
NewProfileView { profile in
|
||||
createdProfile = profile
|
||||
}
|
||||
.environmentObject(environments)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#if os(iOS) || os(tvOS)
|
||||
.presentationDetentsIfAvailable()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ManageProfilesView
|
||||
|
||||
extension ProfileCard {
|
||||
@MainActor
|
||||
struct ManageProfilesView: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@StateObject private var viewModel = ProfileViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if viewModel.isLoading {
|
||||
ProgressView().onAppear {
|
||||
viewModel.setEnvironments(environments)
|
||||
Task {
|
||||
await viewModel.doReload()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FormView {
|
||||
if viewModel.profileList.isEmpty {
|
||||
Text("Empty profiles")
|
||||
} else {
|
||||
List {
|
||||
ForEach(viewModel.profileList, id: \.id) { profile in
|
||||
ManageProfileItem(viewModel, profile)
|
||||
}
|
||||
.onMove(perform: moveProfile)
|
||||
#if os(macOS)
|
||||
.onDelete(perform: deleteProfile)
|
||||
#endif
|
||||
}
|
||||
#if os(iOS) || os(tvOS)
|
||||
.environment(\.editMode, .constant(.active))
|
||||
.deleteDisabled(true)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isUpdating)
|
||||
.alertBinding($viewModel.alert, $viewModel.isLoading)
|
||||
.onReceive(environments.profileUpdate) { _ in
|
||||
Task {
|
||||
await viewModel.doReload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func moveProfile(from source: IndexSet, to destination: Int) {
|
||||
viewModel.moveProfile(from: source, to: destination)
|
||||
}
|
||||
|
||||
private func deleteProfile(where profileIndex: IndexSet) {
|
||||
viewModel.deleteProfile(where: profileIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ManageProfileItem: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@ObservedObject private var viewModel: ProfileViewModel
|
||||
@State private var profile: ProfilePreview
|
||||
@State private var shareLinkPresented = false
|
||||
|
||||
init(_ viewModel: ProfileViewModel, _ profile: ProfilePreview) {
|
||||
self.viewModel = viewModel
|
||||
_profile = State(initialValue: profile)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(profile.name)
|
||||
#if os(macOS)
|
||||
if profile.type == .remote {
|
||||
Spacer(minLength: 4)
|
||||
Text("Last Updated: \(profile.origin.lastUpdated!.myFormat)").font(.caption)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
if profile.type == .remote {
|
||||
Button {
|
||||
viewModel.isUpdating = true
|
||||
Task {
|
||||
await viewModel.updateProfile(profile.origin)
|
||||
profile = ProfilePreview(profile.origin)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 16))
|
||||
.rotationEffect(.degrees(viewModel.isUpdating ? 360 : 0))
|
||||
.animation(
|
||||
viewModel.isUpdating
|
||||
? .linear(duration: 1).repeatForever(autoreverses: false)
|
||||
: .default,
|
||||
value: viewModel.isUpdating
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
.disabled(viewModel.isUpdating)
|
||||
|
||||
Button {
|
||||
shareLinkPresented = true
|
||||
} label: {
|
||||
Image(systemName: "qrcode")
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
#if os(macOS)
|
||||
.popover(isPresented: $shareLinkPresented, arrowEdge: .bottom) {
|
||||
QRCodeContentView(profileName: profile.name, remoteURL: profile.remoteURL!)
|
||||
}
|
||||
#elseif os(iOS) || os(tvOS)
|
||||
.sheet(isPresented: $shareLinkPresented) {
|
||||
QRCodeSheet(profileName: profile.name, remoteURL: profile.remoteURL!)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
ShareButtonCompat($viewModel.alert) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 16))
|
||||
} itemURL: {
|
||||
try profile.origin.toContent().generateShareFile()
|
||||
}
|
||||
.actionButtonStyle()
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.deleteProfile(profile.origin)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.actionButtonStyle()
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding(.vertical, 4)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user