Merge pull request #35 from am7590/settings-bug

Settings bug fix + reorganization
This commit is contained in:
Philipp C. Heckel 2026-04-14 21:50:53 -04:00 committed by GitHub
commit 3fa7c56942
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 445 additions and 360 deletions

View file

@ -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)
}
}

View file

@ -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<Preference>
@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
}
}

View file

@ -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())
}
}

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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<User>
@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)
}
}
}

View file

@ -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<Preference>
@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<User>
@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())
}
}