iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #16 from grainsocial/chore/settings-cleanup

Redesign settings with cleaner layout and subpages

authored by

Chad Miller and committed by
GitHub
85d9e062 ce991cfa

+178 -126
+4 -8
Grain/Views/Settings/ModerationView.swift
··· 5 5 6 6 var body: some View { 7 7 List { 8 - NavigationLink { 9 - BlockedUsersView(client: client) 10 - } label: { 11 - Label("Blocked Users", systemImage: "nosign") 12 - } 13 - NavigationLink { 8 + NavigationLink("Muted Users") { 14 9 MutedUsersView(client: client) 15 - } label: { 16 - Label("Muted Users", systemImage: "speaker.slash") 10 + } 11 + NavigationLink("Blocked Users") { 12 + BlockedUsersView(client: client) 17 13 } 18 14 } 19 15 .navigationTitle("Moderation")
+174 -118
Grain/Views/Settings/SettingsView.swift
··· 1 1 import Nuke 2 + import SafariServices 2 3 import SwiftUI 3 4 4 5 struct SettingsView: View { ··· 6 7 @Environment(\.dismiss) private var dismiss 7 8 let client: XRPCClient 8 9 @State private var cacheSizeText = "Calculating..." 9 - @State private var includeExif = true 10 - @State private var includeLocation = true 11 - @State private var hasLoadedPrefs = false 12 - @AppStorage("privacy.showSuggestedUsers") private var showSuggestedUsers = true 13 - @State private var showCopiedToast = false 10 + @State private var safariURL: URL? 14 11 15 12 var body: some View { 16 13 List { 17 - Section("Account") { 18 - if let handle = auth.userHandle { 19 - Menu { 20 - Button { copyText("@\(handle)") } label: { 21 - Label("Copy", systemImage: "doc.on.doc") 22 - } 23 - } label: { 24 - LabeledContent("Handle", value: "@\(handle)") 25 - } 26 - .foregroundStyle(.primary) 14 + Section { 15 + NavigationLink("Account") { 16 + AccountDetailView() 27 17 } 28 - if let did = auth.userDID { 29 - Menu { 30 - Button { copyText(did) } label: { 31 - Label("Copy", systemImage: "doc.on.doc") 32 - } 33 - } label: { 34 - LabeledContent("DID", value: did) 35 - .font(.caption) 36 - } 37 - .foregroundStyle(.primary) 38 - } 39 - } 40 - 41 - Section("Notifications") { 42 - NavigationLink { 18 + NavigationLink("Notifications") { 43 19 NotificationSettingsView(client: client) 44 - } label: { 45 - Label("Notifications", systemImage: "bell") 46 20 } 47 - } 48 - 49 - Section("Moderation") { 50 - NavigationLink { 21 + NavigationLink("Moderation") { 51 22 ModerationView(client: client) 52 - } label: { 53 - Label("Moderation", systemImage: "shield") 54 23 } 55 - } 56 - 57 - Section { 58 - Toggle("Include location", isOn: $includeLocation) 59 - .onChange(of: includeLocation) { 60 - guard hasLoadedPrefs else { return } 61 - Task { 62 - guard let authContext = await auth.authContext() else { return } 63 - try? await client.putIncludeLocation(includeLocation, auth: authContext) 64 - } 65 - } 66 - Toggle("Include camera data", isOn: $includeExif) 67 - .onChange(of: includeExif) { 68 - guard hasLoadedPrefs else { return } 69 - Task { 70 - guard let authContext = await auth.authContext() else { return } 71 - try? await client.putIncludeExif(includeExif, auth: authContext) 72 - } 73 - } 74 - Toggle("Show suggested users", isOn: $showSuggestedUsers) 75 - } header: { 76 - Text("Privacy") 77 - } footer: { 78 - Text("Camera data includes make, model, and exposure info. Location is auto-detected from photo metadata when available.") 79 - } 80 - 81 - Section("Storage") { 82 - LabeledContent("Image Cache", value: cacheSizeText) 83 - Button("Clear Image Cache", role: .destructive) { 84 - clearImageCache() 24 + NavigationLink("Appearance") { 25 + AppearanceSettingsView() 26 + } 27 + NavigationLink("Upload Defaults") { 28 + UploadDefaultsView(client: client) 85 29 } 86 30 } 87 - .task { 88 - guard !isPreview else { return } 89 - updateCacheSize() 90 - } 91 31 92 - Section("Legal") { 93 - Link("Privacy Policy", destination: URL(string: "https://grain.social/support/privacy")!) 94 - Link("Terms of Service", destination: URL(string: "https://grain.social/support/terms")!) 95 - Link("Copyright Policy", destination: URL(string: "https://grain.social/support/copyright")!) 96 - } 97 - 98 - Section("About") { 99 - Link("Powered by AT Protocol", destination: URL(string: "https://atproto.com")!) 32 + Section { 33 + settingsLink("Privacy Policy", url: "https://grain.social/support/privacy") 34 + settingsLink("Terms of Service", url: "https://grain.social/support/terms") 35 + settingsLink("Copyright Policy", url: "https://grain.social/support/copyright") 36 + settingsLink("Community Guidelines", url: "https://grain.social/support/community-guidelines") 37 + settingsLink("AT Protocol", url: "https://atproto.com") 100 38 } 101 39 102 40 Section { ··· 105 43 dismiss() 106 44 } 107 45 } 108 - } 109 - .navigationTitle("Settings") 110 - .overlay(alignment: .center) { 111 - if showCopiedToast { CopiedCheckmarkToast() } 112 - } 113 - .animation(.spring(response: 0.4, dampingFraction: 0.7), value: showCopiedToast) 114 - .sensoryFeedback(.impact(weight: .medium), trigger: showCopiedToast) 115 - .task { 116 - if let authContext = await auth.authContext(), 117 - let prefs = try? await client.getPreferences(auth: authContext).preferences 118 - { 119 - if let exif = prefs.includeExif { includeExif = exif } 120 - if let location = prefs.includeLocation { includeLocation = location } 46 + 47 + Section { 48 + Button { 49 + clearImageCache() 50 + } label: { 51 + HStack { 52 + Text("Clear cache") 53 + .foregroundStyle(Color("AccentColor")) 54 + Spacer() 55 + Text(cacheSizeText) 56 + .foregroundStyle(.secondary) 57 + } 58 + } 121 59 } 122 - hasLoadedPrefs = true 60 + .task { 61 + guard !isPreview else { return } 62 + updateCacheSize() 63 + } 123 64 } 124 - } 125 - 126 - private func copyText(_ text: String) { 127 - UIPasteboard.general.string = text 128 - showCopiedToast = true 129 - Task { 130 - try? await Task.sleep(for: .seconds(2)) 131 - showCopiedToast = false 65 + .sheet(item: $safariURL) { url in 66 + SafariView(url: url) 67 + .ignoresSafeArea() 132 68 } 69 + .navigationTitle("Settings") 70 + .tint(.primary) 133 71 } 134 72 135 73 private func updateCacheSize() { ··· 141 79 cacheSizeText = ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file) 142 80 } 143 81 82 + private func settingsLink(_ title: String, url: String) -> some View { 83 + Button { 84 + safariURL = URL(string: url) 85 + } label: { 86 + HStack { 87 + Text(title) 88 + .foregroundStyle(.primary) 89 + Spacer() 90 + Image(systemName: "chevron.right") 91 + .font(.caption.weight(.semibold)) 92 + .foregroundStyle(.tertiary) 93 + } 94 + } 95 + } 96 + 144 97 private func clearImageCache() { 145 98 ImagePipeline.shared.cache.removeAll() 146 99 if let dataCache = ImagePipeline.shared.configuration.dataCache as? DataCache { ··· 150 103 } 151 104 } 152 105 153 - struct CopiedCheckmarkToast: View { 154 - @State private var checkScale = 0.3 106 + extension URL: @retroactive Identifiable { 107 + public var id: String { 108 + absoluteString 109 + } 110 + } 111 + 112 + private struct AccountDetailView: View { 113 + @Environment(AuthManager.self) private var auth 114 + @State private var safariURL: URL? 155 115 156 116 var body: some View { 157 - HStack(spacing: 6) { 158 - Image(systemName: "checkmark.circle.fill") 159 - .font(.subheadline) 160 - .scaleEffect(checkScale) 161 - .onAppear { 162 - withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) { 163 - checkScale = 1.0 117 + List { 118 + Section { 119 + if let handle = auth.userHandle { 120 + LabeledContent("Handle", value: "@\(handle)") 121 + } 122 + if let did = auth.userDID { 123 + LabeledContent("DID", value: did) 124 + .font(.caption) 125 + } 126 + } 127 + 128 + Section { 129 + if let did = auth.userDID { 130 + Button { 131 + safariURL = URL(string: "https://pdsls.dev/at://\(did)") 132 + } label: { 133 + HStack { 134 + Text("Manage your data") 135 + .foregroundStyle(.primary) 136 + Spacer() 137 + Image(systemName: "chevron.right") 138 + .font(.caption.weight(.semibold)) 139 + .foregroundStyle(.tertiary) 140 + } 164 141 } 165 142 } 166 - Text("Copied") 167 - .font(.subheadline.weight(.medium)) 143 + } 168 144 } 169 - .padding(.horizontal, 16) 170 - .padding(.vertical, 10) 171 - .background(.ultraThinMaterial, in: Capsule()) 172 - .shadow(color: .black.opacity(0.15), radius: 8, y: 4) 173 - .transition(.scale.combined(with: .opacity)) 145 + .sheet(item: $safariURL) { url in 146 + SafariView(url: url) 147 + .ignoresSafeArea() 148 + } 149 + .navigationTitle("Account") 150 + .tint(.primary) 174 151 } 152 + } 153 + 154 + private struct AppearanceSettingsView: View { 155 + @AppStorage("privacy.showSuggestedUsers") private var showSuggestedUsers = true 156 + 157 + var body: some View { 158 + List { 159 + Section { 160 + Toggle("Show suggested users", isOn: $showSuggestedUsers) 161 + } 162 + } 163 + .navigationTitle("Appearance") 164 + .tint(Color("AccentColor")) 165 + } 166 + } 167 + 168 + private struct UploadDefaultsView: View { 169 + @Environment(AuthManager.self) private var auth 170 + let client: XRPCClient 171 + @State private var includeExif = true 172 + @State private var includeLocation = true 173 + @State private var hasLoadedPrefs = false 174 + 175 + var body: some View { 176 + List { 177 + Section { 178 + Toggle(isOn: $includeLocation) { 179 + VStack(alignment: .leading, spacing: 2) { 180 + Text("Include location") 181 + Text("Auto-detected from photo metadata") 182 + .font(.caption) 183 + .foregroundStyle(.secondary) 184 + } 185 + } 186 + .onChange(of: includeLocation) { 187 + guard hasLoadedPrefs else { return } 188 + Task { 189 + guard let authContext = await auth.authContext() else { return } 190 + try? await client.putIncludeLocation(includeLocation, auth: authContext) 191 + } 192 + } 193 + Toggle(isOn: $includeExif) { 194 + VStack(alignment: .leading, spacing: 2) { 195 + Text("Include camera data") 196 + Text("Make, model, and exposure info") 197 + .font(.caption) 198 + .foregroundStyle(.secondary) 199 + } 200 + } 201 + .onChange(of: includeExif) { 202 + guard hasLoadedPrefs else { return } 203 + Task { 204 + guard let authContext = await auth.authContext() else { return } 205 + try? await client.putIncludeExif(includeExif, auth: authContext) 206 + } 207 + } 208 + } 209 + } 210 + .navigationTitle("Upload Defaults") 211 + .tint(Color("AccentColor")) 212 + .task { 213 + if let authContext = await auth.authContext(), 214 + let prefs = try? await client.getPreferences(auth: authContext).preferences 215 + { 216 + if let exif = prefs.includeExif { includeExif = exif } 217 + if let location = prefs.includeLocation { includeLocation = location } 218 + } 219 + hasLoadedPrefs = true 220 + } 221 + } 222 + } 223 + 224 + private struct SafariView: UIViewControllerRepresentable { 225 + let url: URL 226 + func makeUIViewController(context _: Context) -> SFSafariViewController { 227 + SFSafariViewController(url: url) 228 + } 229 + 230 + func updateUIViewController(_: SFSafariViewController, context _: Context) {} 175 231 } 176 232 177 233 #Preview {