krz/rune
an ios client for njalla
clone: git clone https://gitbay.org/krz/rune.git
v1.0.0: Rune/Views/Tokens/TokenListView.swift · raw
1import SwiftUI
2
3struct TokenListView: View {
4 @ObservedObject var viewModel: TokenViewModel
5 let client: NjallaClient?
6 let onTokenRemoved: (String) -> Void
7
8 @State private var showingAddToken = false
9 @State private var tokenPendingDeletion: APIToken?
10
11 var body: some View {
12 NavigationStack {
13 Group {
14 if let client {
15 content(client: client)
16 } else {
17 ContentUnavailableView("Sign in required", systemImage: "key.fill", description: Text("Add a valid Njalla API token to load account tokens."))
18 }
19 }
20 .navigationTitle("Tokens")
21 .toolbar {
22 if client != nil {
23 Button {
24 showingAddToken = true
25 } label: {
26 Label("Add Token", systemImage: "plus")
27 }
28 }
29 }
30 }
31 .sheet(isPresented: $showingAddToken) {
32 if let client {
33 NavigationStack {
34 TokenAddView(viewModel: viewModel, client: client)
35 }
36 }
37 }
38 .confirmationDialog(
39 deletionTitle,
40 isPresented: deleteBinding,
41 titleVisibility: .visible
42 ) {
43 Button("Delete Token", role: .destructive) {
44 guard let tokenPendingDeletion, let client else { return }
45 Task {
46 let removed = await viewModel.removeToken(tokenPendingDeletion, client: client)
47 if removed {
48 onTokenRemoved(tokenPendingDeletion.key)
49 }
50 self.tokenPendingDeletion = nil
51 }
52 }
53 }
54 .alert("API Error", isPresented: errorBinding) {
55 Button("OK", role: .cancel) {}
56 } message: {
57 Text(viewModel.errorMessage ?? "")
58 }
59 }
60
61 @ViewBuilder
62 private func content(client: NjallaClient) -> some View {
63 if viewModel.isLoading && viewModel.tokens.isEmpty {
64 ProgressView()
65 } else if viewModel.tokens.isEmpty {
66 ContentUnavailableView("No Tokens", systemImage: "key.horizontal", description: Text("No API tokens were found on this account."))
67 } else {
68 List(viewModel.tokens) { token in
69 Button {
70 tokenPendingDeletion = token
71 } label: {
72 TokenRow(token: token, label: viewModel.tokenLabel(for: token))
73 }
74 .buttonStyle(.plain)
75 }
76 .listStyle(.insetGrouped)
77 .refreshable {
78 await viewModel.loadTokens(client: client)
79 }
80 }
81 }
82
83 private var deletionTitle: String {
84 guard let tokenPendingDeletion else {
85 return ""
86 }
87
88 return "Delete token \(viewModel.tokenLabel(for: tokenPendingDeletion))?"
89 }
90
91 private var deleteBinding: Binding<Bool> {
92 Binding(
93 get: { tokenPendingDeletion != nil },
94 set: { newValue in
95 if !newValue {
96 tokenPendingDeletion = nil
97 }
98 }
99 )
100 }
101
102 private var errorBinding: Binding<Bool> {
103 Binding(
104 get: { viewModel.errorMessage != nil },
105 set: { newValue in
106 if !newValue {
107 viewModel.errorMessage = nil
108 }
109 }
110 )
111 }
112}
113
114private struct TokenRow: View {
115 let token: APIToken
116 let label: String
117
118 var body: some View {
119 VStack(alignment: .leading, spacing: 6) {
120 Text(label)
121 .font(.headline)
122
123 Text(methodsText)
124 .font(.subheadline)
125 .foregroundStyle(.secondary)
126
127 if let from = token.from, !from.isEmpty {
128 Text("From: \(from.joined(separator: ", "))")
129 .font(.subheadline)
130 .foregroundStyle(.secondary)
131 }
132 }
133 .padding(.vertical, 4)
134 }
135
136 private var methodsText: String {
137 guard let methods = token.allowedMethods, !methods.isEmpty else {
138 return "Methods: Unrestricted"
139 }
140
141 return "Methods: \(methods.joined(separator: ", "))"
142 }
143}