Use atproto actions with ease in iOS shortcuts
1//
2// AccountsView.swift
3// shortcut
4//
5// Created by Bailey Townsend on 6/24/25.
6//
7
8import ATProtoKit
9import SwiftData
10import SwiftUI
11
12struct AccountsView: View {
13
14 @EnvironmentObject var atProtocolManager: AtProtocolManager
15 @Query(sort: [SortDescriptor(\UserSessionModel.handle, comparator: .localizedStandard)])
16 var userSessions: [UserSessionModel]
17
18 @State var itemToDelete: UserSessionModel? = nil
19 @State private var showDeleteConfirmation: Bool = false
20 @State private var errorMessage: String?
21 @State private var showAlert = false
22
23 var body: some View {
24 NavigationStack {
25 Group {
26 if self.userSessions.isEmpty {
27 VStack(spacing: 16) {
28 Image(systemName: "person.3")
29 .font(.system(size: 50))
30 .foregroundColor(.gray)
31
32 Text("No ATProto Accounts Yet")
33 .font(.title2)
34 .fontWeight(.medium)
35 .foregroundColor(.primary)
36
37 Text("Add your first account to get started")
38 .font(.body)
39 .foregroundColor(.secondary)
40 .multilineTextAlignment(.center)
41 }
42 .padding()
43 } else {
44 List {
45 ForEach(self.userSessions) { session in
46 AccountRow(
47 handle: session.handle, profilePicture: session.profilePicture
48 )
49 .swipeActions {
50 Button {
51 Task {
52 await self.refreshSession(session: session)
53 }
54 } label: {
55 Label("Refresh", systemImage: "arrow.clockwise.circle")
56 }
57 Button(role: .destructive) {
58 showDeleteConfirmation = true
59 itemToDelete = session
60 } label: {
61 Label("Delete", systemImage: "trash.fill")
62 }
63
64 }
65 }
66
67 }
68 }
69 }
70 .navigationTitle("Accounts")
71 .toolbar {
72 ToolbarItem(placement: .navigationBarTrailing) {
73 NavigationLink {
74 LoginView()
75 .environmentObject(atProtocolManager)
76 } label: {
77 Image(systemName: "plus")
78 }
79 }
80 }
81 .alert(isPresented: $showAlert) {
82 Alert(
83 title: Text("Error"), message: Text(errorMessage ?? "Unknown error"),
84 dismissButton: .default(Text("OK")))
85 }
86
87 }
88 .confirmationDialog(
89 Text(
90 "Are you sure you want to remove the handle \(self.itemToDelete?.handle ?? "")? You will no longer be able to access this account in your shortcuts."
91 ),
92 isPresented: $showDeleteConfirmation,
93 titleVisibility: .visible
94 ) {
95 Button("Delete", role: .destructive) {
96 withAnimation {
97 if let session = itemToDelete {
98
99 deleteAccount(id: session.id)
100 itemToDelete = nil
101 }
102 }
103 }
104 }
105 }
106
107 private func deleteAccount(id: UUID) {
108 Task {
109 do {
110 try await self.atProtocolManager.deleteSession(sessionID: id)
111 } catch {
112 self.errorMessage = error.localizedDescription
113 self.showAlert = true
114 }
115 }
116 }
117
118 private func refreshSession(session: UserSessionModel) async {
119 do {
120 let atProtoKit = try await self.atProtocolManager.getAtProtoKit(sessionModel: session)
121 try await self.atProtocolManager.refreshUserSession(atProtoKit)
122 } catch {
123 self.errorMessage = error.localizedDescription
124 self.showAlert = true
125 }
126
127 }
128
129}
130
131struct AccountRow: View {
132 let handle: String
133 let profilePicture: URL?
134
135 var body: some View {
136 HStack(spacing: 12) {
137 // Profile picture circle
138 Group {
139 if let imageURL = self.profilePicture {
140 AsyncImage(url: imageURL) { image in
141 image
142 .resizable()
143 .aspectRatio(contentMode: .fill)
144 } placeholder: {
145 Circle()
146 .fill(.blue.gradient)
147 .overlay {
148 Text(String(self.handle.prefix(1).uppercased()))
149 .font(.headline)
150 .fontWeight(.medium)
151 .foregroundColor(.white)
152 }
153 }
154 } else {
155 Circle()
156 .fill(.blue.gradient)
157 .overlay {
158 Text(String(self.handle.prefix(1).uppercased()))
159 .font(.headline)
160 .fontWeight(.medium)
161 .foregroundColor(.white)
162 }
163 }
164 }
165 .frame(width: 44, height: 44)
166 .clipShape(Circle())
167
168 VStack(alignment: .leading, spacing: 2) {
169 Text(self.handle)
170 .font(.headline)
171 .foregroundColor(.primary)
172
173 }
174
175 Spacer()
176 }
177 .padding(.vertical, 4)
178 }
179}
180
181#Preview {
182
183 let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
184 let container = try! ModelContainer(
185 for: UserSessionModel.self,
186 configurations: configuration
187 )
188 // var userSessions: [UserSessionModel] = [
189 // UserSessionModel(
190 // sessionId: UUID(), handle: "alice.bsky.social", sessionDID: "",
191 // serviceEndpoint: URL(string: "https://localhost")!,
192 // profilePicture: URL(
193 // string: "https://www.placeholderimage.online/images/avatar/avatar-image-22.png")!),
194 // UserSessionModel(
195 // sessionId: UUID(), handle: "jonah.bsky.social", sessionDID: "",
196 // serviceEndpoint: URL(string: "https://localhost")!,
197 // profilePicture: URL(
198 // string: "https://www.placeholderimage.online/images/avatar/avatar-image-05.png")!),
199 // UserSessionModel(
200 // sessionId: UUID(), handle: "earl.bsky.social", sessionDID: "",
201 // serviceEndpoint: URL(string: "https://localhost")!,
202 // ),
203 // ]
204
205 AccountsView()
206 .environmentObject(AtProtocolManager(modelContext: container.mainContext))
207}