diff --git a/ntfy/Views/Settings/AboutView.swift b/ntfy/Views/Settings/AboutView.swift new file mode 100644 index 0000000..1704dc3 --- /dev/null +++ b/ntfy/Views/Settings/AboutView.swift @@ -0,0 +1,60 @@ +// +// AboutView.swift +// ntfy +// +// Created by Alek Michelson on 4/10/26. +// + +import SwiftUI + +struct AboutView: View { + var body: some View { + Group { + Button(action: { + open(url: "https://ntfy.sh/docs") + }) { + HStack { + Text("Read the docs") + Spacer() + Text("ntfy.sh/docs") + .foregroundColor(.gray) + Image(systemName: "link") + } + } + Button(action: { + open(url: "https://github.com/binwiederhier/ntfy/issues") + }) { + HStack { + Text("Report a bug") + Spacer() + Text("github.com") + .foregroundColor(.gray) + Image(systemName: "link") + } + } + Button(action: { + open(url: "itms-apps://itunes.apple.com/app/id1625396347") + }) { + HStack { + Text("Rate the app") + Spacer() + Text("App Store") + .foregroundColor(.gray) + Image(systemName: "star.fill") + } + } + HStack { + Text("Version") + Spacer() + Text("ntfy \(Config.version) (\(Config.build))") + .foregroundColor(.gray) + } + } + .foregroundColor(.primary) + } + + private func open(url: String) { + guard let url = URL(string: url) else { return } + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } +} diff --git a/ntfy/Views/Settings/DefaultServerView.swift b/ntfy/Views/Settings/DefaultServerView.swift new file mode 100644 index 0000000..564ee3f --- /dev/null +++ b/ntfy/Views/Settings/DefaultServerView.swift @@ -0,0 +1,104 @@ +// +// DefaultServerView.swift +// ntfy +// +// Created by Alek Michelson on 4/10/26. +// + +import SwiftUI + +struct DefaultServerView: View { + @EnvironmentObject private var store: Store + @FetchRequest(sortDescriptors: []) var prefs: FetchedResults + @State private var showDialog = false + @State private var newDefaultBaseUrl: String = "" + + private var defaultBaseUrl: String { + prefs + .filter { $0.key == Store.prefKeyDefaultBaseUrl } + .first? + .value ?? Config.appBaseUrl + } + + var body: some View { + Button(action: { + if defaultBaseUrl == Config.appBaseUrl { + newDefaultBaseUrl = "" + } else { + newDefaultBaseUrl = defaultBaseUrl + } + showDialog = true + }) { + HStack { + let _ = newDefaultBaseUrl + Text("Default server") + .foregroundColor(.primary) + Spacer() + Text(shortUrl(url: defaultBaseUrl)) + .foregroundColor(.gray) + } + .contentShape(Rectangle()) + } + .sheet(isPresented: $showDialog) { + NavigationView { + Form { + Section( + footer: Text("When subscribing to new topics, this server will be used as a default. Note that if you pick your own ntfy server, you must configure upstream-base-url to receive instant push notifications.") + ) { + HStack { + TextField(Config.appBaseUrl, text: $newDefaultBaseUrl) + .disableAutocapitalization() + .disableAutocorrection(true) + if !newDefaultBaseUrl.isEmpty { + Button { + newDefaultBaseUrl = "" + } label: { + Image(systemName: "clear.fill") + } + } + } + } + } + .navigationTitle("Default server") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: cancelAction) { + Text("Cancel") + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: saveAction) { + Text("Save") + } + .disabled(!isValid()) + } + } + } + } + } + + private func saveAction() { + if newDefaultBaseUrl == "" { + store.saveDefaultBaseUrl(baseUrl: nil) + } else { + store.saveDefaultBaseUrl(baseUrl: normalizeBaseUrl(newDefaultBaseUrl)) + } + resetAndHide() + } + + private func cancelAction() { + resetAndHide() + } + + private func isValid() -> Bool { + if !newDefaultBaseUrl.isEmpty && newDefaultBaseUrl.range(of: "^https?://.+", options: .regularExpression, range: nil, locale: nil) == nil { + return false + } + return true + } + + private func resetAndHide() { + showDialog = false + } +} diff --git a/ntfy/Views/Settings/SettingsView.swift b/ntfy/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..5443461 --- /dev/null +++ b/ntfy/Views/Settings/SettingsView.swift @@ -0,0 +1,59 @@ +import Foundation +import SwiftUI +import StoreKit + +struct SettingsView: View { + @EnvironmentObject private var store: Store + @State private var userDialog: UserDialog? + + var body: some View { + NavigationView { + Form { + Section( + header: Text("General"), + footer: Text("When subscribing to new topics, this server will be used as a default.") + ) { + DefaultServerView() + } + Section( + header: Text("Users"), + footer: Text("To access read-protected topics, you may add or edit users here. All topics for a given server will use the same user.") + ) { + UserTableView(dialog: $userDialog) + } + Section(header: Text("About")) { + AboutView() + } + } + .navigationTitle("Settings") + } + .sheet(item: $userDialog) { dialog in + UserEditorView( + selectedUser: dialog.user, + onSave: { baseUrl, username, password in + store.saveUser(baseUrl: baseUrl, username: username, password: password) + userDialog = nil + }, + onDelete: { user in + store.delete(user: user) + userDialog = nil + }, + onCancel: { + userDialog = nil + } + ) + } + .navigationViewStyle(StackNavigationViewStyle()) + } +} + + +struct SettingsView_Previews: PreviewProvider { + static var previews: some View { + let store = Store.preview // Store.previewEmpty + SettingsView() + .environment(\.managedObjectContext, store.context) + .environmentObject(store) + .environmentObject(AppDelegate()) + } +} diff --git a/ntfy/Views/Settings/UserEditorView.swift b/ntfy/Views/Settings/UserEditorView.swift new file mode 100644 index 0000000..5b4a361 --- /dev/null +++ b/ntfy/Views/Settings/UserEditorView.swift @@ -0,0 +1,129 @@ +// +// UserEditorView.swift +// ntfy +// +// Created by Alek Michelson on 4/10/26. +// + +import SwiftUI + +struct UserEditorView: View { + @EnvironmentObject private var store: Store + + let selectedUser: User? + let onSave: (String, String, String) -> Void + let onDelete: (User) -> Void + let onCancel: () -> Void + + @State private var baseUrl: String + @State private var username: String + @State private var password: String + + init( + selectedUser: User?, + onSave: @escaping (String, String, String) -> Void, + onDelete: @escaping (User) -> Void, + onCancel: @escaping () -> Void + ) { + self.selectedUser = selectedUser + self.onSave = onSave + self.onDelete = onDelete + self.onCancel = onCancel + _baseUrl = State(initialValue: selectedUser?.baseUrl ?? "") + _username = State(initialValue: selectedUser?.username ?? "") + _password = State(initialValue: "") + } + + var body: some View { + NavigationView { + Form { + Section( + footer: isNewUser + ? Text("You can add a user here. All topics for the given server will use this user.") + : Text("Edit the username or password for \(shortUrl(url: baseUrl)) here. This user is used for all topics of this server. Leave the password blank to leave it unchanged.") + ) { + if isNewUser { + TextField("Service URL, e.g. https://ntfy.home.io", text: $baseUrl) + .disableAutocapitalization() + .disableAutocorrection(true) + } + TextField("Username", text: $username) + .disableAutocapitalization() + .disableAutocorrection(true) + SecureField("Password", text: $password) + } + } + .navigationTitle(isNewUser ? "Add user" : "Edit user") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + if isNewUser { + Button("Cancel") { + onCancel() + } + } else { + Menu { + Button("Cancel") { + onCancel() + } + if #available(iOS 15.0, *) { + Button(role: .destructive) { + deleteAction() + } label: { + Text("Delete") + } + } else { + Button("Delete") { + deleteAction() + } + } + } label: { + Image(systemName: "ellipsis.circle") + .padding([.leading], 40) + } + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: saveAction) { + Text("Save") + } + .disabled(!isValid()) + } + } + } + } + + private var isNewUser: Bool { + selectedUser == nil + } + + private func saveAction() { + let finalPassword: String + if let user = selectedUser, password.isEmpty { + finalPassword = user.password ?? "?" + } else { + finalPassword = password + } + onSave(baseUrl, username, finalPassword) + } + + private func deleteAction() { + guard let selectedUser = selectedUser else { return } + onDelete(selectedUser) + } + + private func isValid() -> Bool { + if isNewUser { + if baseUrl.range(of: "^https?://.+", options: .regularExpression, range: nil, locale: nil) == nil { + return false + } else if username.isEmpty || password.isEmpty { + return false + } else if store.getUser(baseUrl: baseUrl) != nil { + return false + } + } else if username.isEmpty { + return false + } + return true + } +} diff --git a/ntfy/Views/Settings/UserRowView.swift b/ntfy/Views/Settings/UserRowView.swift new file mode 100644 index 0000000..a50d102 --- /dev/null +++ b/ntfy/Views/Settings/UserRowView.swift @@ -0,0 +1,36 @@ +// +// UserRowView.swift +// ntfy +// +// Created by Alek Michelson on 4/10/26. +// + +import SwiftUI + +struct UserRowView: View { + @EnvironmentObject private var store: Store + @ObservedObject var user: User + + var body: some View { + // TODO: swipe to delete action + // I tried to add a swipe action here to delete, but for some strange reason it doesn't work, + // even though in the subscription list it does. + + HStack { + Image(systemName: "person.fill") + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + Text(user.username ?? "?") + Text(user.baseUrl ?? "?") + .font(.subheadline) + .foregroundColor(.gray) + } + } + Spacer() + Image(systemName: "chevron.forward") + .font(.system(size: 12.0)) + .foregroundColor(.gray) + } + .padding(.all, 4) + } +} diff --git a/ntfy/Views/Settings/UserTableView.swift b/ntfy/Views/Settings/UserTableView.swift new file mode 100644 index 0000000..80da35d --- /dev/null +++ b/ntfy/Views/Settings/UserTableView.swift @@ -0,0 +1,57 @@ +// +// UserTableView.swift +// ntfy +// +// Created by Alek Michelson on 4/10/26. +// + +import SwiftUI + +enum UserDialog: Identifiable { + case add + case edit(User) + + var id: String { + switch self { + case .add: + return "add" + case .edit(let user): + return user.objectID.uriRepresentation().absoluteString + } + } + + var user: User? { + switch self { + case .add: + return nil + case .edit(let user): + return user + } + } +} + +struct UserTableView: View { + @FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \User.baseUrl, ascending: true)]) var users: FetchedResults + + @Binding var dialog: UserDialog? + + var body: some View { + ForEach(users) { user in + Button(action: { + dialog = .edit(user) + }) { + UserRowView(user: user) + .foregroundColor(.primary) + } + } + Button(action: { + dialog = .add + }) { + HStack { + Image(systemName: "plus") + Text("Add user") + } + .foregroundColor(.primary) + } + } +} diff --git a/ntfy/Views/SettingsView.swift b/ntfy/Views/SettingsView.swift deleted file mode 100644 index 638edd3..0000000 --- a/ntfy/Views/SettingsView.swift +++ /dev/null @@ -1,360 +0,0 @@ -import Foundation -import SwiftUI -import StoreKit - -struct SettingsView: View { - @EnvironmentObject private var store: Store - - var body: some View { - NavigationView { - Form { - Section( - header: Text("General"), - footer: Text("When subscribing to new topics, this server will be used as a default.") - ) { - DefaultServerView() - } - Section( - header: Text("Users"), - footer: Text("To access read-protected topics, you may add or edit users here. All topics for a given server will use the same user.") - ) { - UserTableView() - } - Section(header: Text("About")) { - AboutView() - } - } - .navigationTitle("Settings") - } - .navigationViewStyle(StackNavigationViewStyle()) - } -} - - -struct DefaultServerView: View { - @EnvironmentObject private var store: Store - @FetchRequest(sortDescriptors: []) var prefs: FetchedResults - @State private var showDialog = false - @State private var newDefaultBaseUrl: String = "" - - private var defaultBaseUrl: String { - prefs - .filter { $0.key == Store.prefKeyDefaultBaseUrl } - .first? - .value ?? Config.appBaseUrl - } - - var body: some View { - Button(action: { - if defaultBaseUrl == Config.appBaseUrl { - newDefaultBaseUrl = "" - } else { - newDefaultBaseUrl = defaultBaseUrl - } - showDialog = true - }) { - HStack { - let _ = newDefaultBaseUrl - Text("Default server") - .foregroundColor(.primary) - Spacer() - Text(shortUrl(url: defaultBaseUrl)) - .foregroundColor(.gray) - } - .contentShape(Rectangle()) - } - .sheet(isPresented: $showDialog) { - NavigationView { - Form { - Section( - footer: Text("When subscribing to new topics, this server will be used as a default. Note that if you pick your own ntfy server, you must configure upstream-base-url to receive instant push notifications.") - ) { - HStack { - TextField(Config.appBaseUrl, text: $newDefaultBaseUrl) - .disableAutocapitalization() - .disableAutocorrection(true) - if !newDefaultBaseUrl.isEmpty { - Button { - newDefaultBaseUrl = "" - } label: { - Image(systemName: "clear.fill") - } - } - } - } - } - .navigationTitle("Default server") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button(action: cancelAction) { - Text("Cancel") - } - } - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: saveAction) { - Text("Save") - } - .disabled(!isValid()) - } - } - } - } - } - - private func saveAction() { - if newDefaultBaseUrl == "" { - store.saveDefaultBaseUrl(baseUrl: nil) - } else { - store.saveDefaultBaseUrl(baseUrl: normalizeBaseUrl(newDefaultBaseUrl)) - } - resetAndHide() - } - - private func cancelAction() { - resetAndHide() - } - - private func isValid() -> Bool { - if !newDefaultBaseUrl.isEmpty && newDefaultBaseUrl.range(of: "^https?://.+", options: .regularExpression, range: nil, locale: nil) == nil { - return false - } - return true - } - - private func resetAndHide() { - showDialog = false - } -} - -struct UserTableView: View { - @EnvironmentObject private var store: Store - @FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \User.baseUrl, ascending: true)]) var users: FetchedResults - - @State private var selectedUser: User? - @State private var showDialog = false - - @State private var baseUrl: String = "" - @State private var username: String = "" - @State private var password: String = "" - - var body: some View { - let _ = selectedUser?.username // Workaround for FB7823148, see https://developer.apple.com/forums/thread/652080 - List { - ForEach(users) { user in - Button(action: { - selectedUser = user - baseUrl = user.baseUrl ?? "?" - username = user.username ?? "?" - showDialog = true - }) { - UserRowView(user: user) - .foregroundColor(.primary) - } - } - Button(action: { - showDialog = true - }) { - HStack { - Image(systemName: "plus") - Text("Add user") - } - .foregroundColor(.primary) - } - .padding(.all, 4) - } - .sheet(isPresented: $showDialog) { - NavigationView { - Form { - Section( - footer: (selectedUser == nil) - ? Text("You can add a user here. All topics for the given server will use this user.") - : Text("Edit the username or password for \(shortUrl(url: baseUrl)) here. This user is used for all topics of this server. Leave the password blank to leave it unchanged.") - ) { - if selectedUser == nil { - TextField("Service URL, e.g. https://ntfy.home.io", text: $baseUrl) - .disableAutocapitalization() - .disableAutocorrection(true) - } - TextField("Username", text: $username) - .disableAutocapitalization() - .disableAutocorrection(true) - SecureField("Password", text: $password) - } - } - .navigationTitle(selectedUser == nil ? "Add user" : "Edit user") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - if selectedUser == nil { - Button("Cancel") { - cancelAction() - } - } else { - Menu { - Button("Cancel") { - cancelAction() - } - if #available(iOS 15.0, *) { - Button(role: .destructive) { - deleteAction() - } label: { - Text("Delete") - } - } else { - Button("Delete") { - deleteAction() - } - } - } label: { - Image(systemName: "ellipsis.circle") - .padding([.leading], 40) - } - } - } - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: saveAction) { - Text("Save") - } - .disabled(!isValid()) - } - } - } - } - } - - private func saveAction() { - var password = password - if let user = selectedUser, password == "" { - password = user.password ?? "?" // If password is blank, leave unchanged - } - store.saveUser(baseUrl: baseUrl, username: username, password: password) - resetAndHide() - } - - private func cancelAction() { - resetAndHide() - } - - private func deleteAction() { - store.delete(user: selectedUser!) - resetAndHide() - } - - private func isValid() -> Bool { - if selectedUser == nil { // New user - if baseUrl.range(of: "^https?://.+", options: .regularExpression, range: nil, locale: nil) == nil { - return false - } else if username.isEmpty || password.isEmpty { - return false - } else if store.getUser(baseUrl: baseUrl) != nil { - return false - } - } else { // Existing user - if username.isEmpty { - return false - } - } - return true - } - - private func resetAndHide() { - showDialog = false - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - // Hide first and then reset, otherwise we'll see the text fields change - selectedUser = nil - baseUrl = "" - username = "" - password = "" - } - } -} - -struct UserRowView: View { - @EnvironmentObject private var store: Store - @ObservedObject var user: User - - var body: some View { - // I tried to add a swipe action here to delete, but for some strange reason it doesn't work, - // even though in the subscription list it does. - - HStack { - Image(systemName: "person.fill") - VStack(alignment: .leading, spacing: 0) { - VStack(alignment: .leading, spacing: 0) { - Text(user.username ?? "?") - Text(user.baseUrl ?? "?") - .font(.subheadline) - .foregroundColor(.gray) - } - } - Spacer() - Image(systemName: "chevron.forward") - .font(.system(size: 12.0)) - .foregroundColor(.gray) - } - .padding(.all, 4) - } -} - -struct AboutView: View { - var body: some View { - Group { - Button(action: { - open(url: "https://ntfy.sh/docs") - }) { - HStack { - Text("Read the docs") - Spacer() - Text("ntfy.sh/docs") - .foregroundColor(.gray) - Image(systemName: "link") - } - } - Button(action: { - open(url: "https://github.com/binwiederhier/ntfy/issues") - }) { - HStack { - Text("Report a bug") - Spacer() - Text("github.com") - .foregroundColor(.gray) - Image(systemName: "link") - } - } - Button(action: { - open(url: "itms-apps://itunes.apple.com/app/id1625396347") - }) { - HStack { - Text("Rate the app") - Spacer() - Text("App Store") - .foregroundColor(.gray) - Image(systemName: "star.fill") - } - } - HStack { - Text("Version") - Spacer() - Text("ntfy \(Config.version) (\(Config.build))") - .foregroundColor(.gray) - } - } - .foregroundColor(.primary) - } - - private func open(url: String) { - guard let url = URL(string: url) else { return } - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } -} - -struct SettingsView_Previews: PreviewProvider { - static var previews: some View { - let store = Store.preview // Store.previewEmpty - SettingsView() - .environment(\.managedObjectContext, store.context) - .environmentObject(store) - .environmentObject(AppDelegate()) - } -}