SettingsView.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. import Foundation
  2. import SwiftUI
  3. import StoreKit
  4. struct SettingsView: View {
  5. @EnvironmentObject private var store: Store
  6. var body: some View {
  7. NavigationView {
  8. Form {
  9. Section(
  10. header: Text("General"),
  11. footer: Text("When subscribing to new topics, this server will be used as a default.")
  12. ) {
  13. DefaultServerView()
  14. }
  15. Section(
  16. header: Text("Users"),
  17. 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.")
  18. ) {
  19. UserTableView()
  20. }
  21. Section(header: Text("About")) {
  22. AboutView()
  23. }
  24. }
  25. .navigationTitle("Settings")
  26. }
  27. .navigationViewStyle(StackNavigationViewStyle())
  28. }
  29. }
  30. struct DefaultServerView: View {
  31. @EnvironmentObject private var store: Store
  32. @FetchRequest(sortDescriptors: []) var prefs: FetchedResults<Preference>
  33. @State private var showDialog = false
  34. @State private var newDefaultBaseUrl: String = ""
  35. private var defaultBaseUrl: String {
  36. prefs
  37. .filter { $0.key == Store.prefKeyDefaultBaseUrl }
  38. .first?
  39. .value ?? Config.appBaseUrl
  40. }
  41. var body: some View {
  42. Button(action: {
  43. if defaultBaseUrl == Config.appBaseUrl {
  44. newDefaultBaseUrl = ""
  45. } else {
  46. newDefaultBaseUrl = defaultBaseUrl
  47. }
  48. showDialog = true
  49. }) {
  50. HStack {
  51. let _ = newDefaultBaseUrl
  52. Text("Default server")
  53. .foregroundColor(.primary)
  54. Spacer()
  55. Text(shortUrl(url: defaultBaseUrl))
  56. .foregroundColor(.gray)
  57. }
  58. .contentShape(Rectangle())
  59. }
  60. .sheet(isPresented: $showDialog) {
  61. NavigationView {
  62. Form {
  63. Section(
  64. 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.")
  65. ) {
  66. HStack {
  67. TextField(Config.appBaseUrl, text: $newDefaultBaseUrl)
  68. .disableAutocapitalization()
  69. .disableAutocorrection(true)
  70. if !newDefaultBaseUrl.isEmpty {
  71. Button {
  72. newDefaultBaseUrl = ""
  73. } label: {
  74. Image(systemName: "clear.fill")
  75. }
  76. }
  77. }
  78. }
  79. }
  80. .navigationTitle("Default server")
  81. .navigationBarTitleDisplayMode(.inline)
  82. .toolbar {
  83. ToolbarItem(placement: .navigationBarLeading) {
  84. Button(action: cancelAction) {
  85. Text("Cancel")
  86. }
  87. }
  88. ToolbarItem(placement: .navigationBarTrailing) {
  89. Button(action: saveAction) {
  90. Text("Save")
  91. }
  92. .disabled(!isValid())
  93. }
  94. }
  95. }
  96. }
  97. }
  98. private func saveAction() {
  99. if newDefaultBaseUrl == "" {
  100. store.saveDefaultBaseUrl(baseUrl: nil)
  101. } else {
  102. store.saveDefaultBaseUrl(baseUrl: newDefaultBaseUrl)
  103. }
  104. resetAndHide()
  105. }
  106. private func cancelAction() {
  107. resetAndHide()
  108. }
  109. private func isValid() -> Bool {
  110. if !newDefaultBaseUrl.isEmpty && newDefaultBaseUrl.range(of: "^https?://.+", options: .regularExpression, range: nil, locale: nil) == nil {
  111. return false
  112. }
  113. return true
  114. }
  115. private func resetAndHide() {
  116. showDialog = false
  117. }
  118. }
  119. struct UserTableView: View {
  120. @EnvironmentObject private var store: Store
  121. @FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \User.baseUrl, ascending: true)]) var users: FetchedResults<User>
  122. @State private var selectedUser: User?
  123. @State private var showDialog = false
  124. @State private var baseUrl: String = ""
  125. @State private var username: String = ""
  126. @State private var password: String = ""
  127. var body: some View {
  128. let _ = selectedUser?.username // Workaround for FB7823148, see https://developer.apple.com/forums/thread/652080
  129. List {
  130. ForEach(users) { user in
  131. Button(action: {
  132. selectedUser = user
  133. baseUrl = user.baseUrl ?? "?"
  134. username = user.username ?? "?"
  135. showDialog = true
  136. }) {
  137. UserRowView(user: user)
  138. .foregroundColor(.primary)
  139. }
  140. }
  141. Button(action: {
  142. showDialog = true
  143. }) {
  144. HStack {
  145. Image(systemName: "plus")
  146. Text("Add user")
  147. }
  148. .foregroundColor(.primary)
  149. }
  150. .padding(.all, 4)
  151. }
  152. .sheet(isPresented: $showDialog) {
  153. NavigationView {
  154. Form {
  155. Section(
  156. footer: (selectedUser == nil)
  157. ? Text("You can add a user here. All topics for the given server will use this user.")
  158. : 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.")
  159. ) {
  160. if selectedUser == nil {
  161. TextField("Service URL, e.g. https://ntfy.home.io", text: $baseUrl)
  162. .disableAutocapitalization()
  163. .disableAutocorrection(true)
  164. }
  165. TextField("Username", text: $username)
  166. .disableAutocapitalization()
  167. .disableAutocorrection(true)
  168. SecureField("Password", text: $password)
  169. }
  170. }
  171. .navigationTitle(selectedUser == nil ? "Add user" : "Edit user")
  172. .navigationBarTitleDisplayMode(.inline)
  173. .toolbar {
  174. ToolbarItem(placement: .navigationBarLeading) {
  175. if selectedUser == nil {
  176. Button("Cancel") {
  177. cancelAction()
  178. }
  179. } else {
  180. Menu {
  181. Button("Cancel") {
  182. cancelAction()
  183. }
  184. if #available(iOS 15.0, *) {
  185. Button(role: .destructive) {
  186. deleteAction()
  187. } label: {
  188. Text("Delete")
  189. }
  190. } else {
  191. Button("Delete") {
  192. deleteAction()
  193. }
  194. }
  195. } label: {
  196. Image(systemName: "ellipsis.circle")
  197. .padding([.leading], 40)
  198. }
  199. }
  200. }
  201. ToolbarItem(placement: .navigationBarTrailing) {
  202. Button(action: saveAction) {
  203. Text("Save")
  204. }
  205. .disabled(!isValid())
  206. }
  207. }
  208. }
  209. }
  210. }
  211. private func saveAction() {
  212. var password = password
  213. if let user = selectedUser, password == "" {
  214. password = user.password ?? "?" // If password is blank, leave unchanged
  215. }
  216. store.saveUser(baseUrl: baseUrl, username: username, password: password)
  217. resetAndHide()
  218. }
  219. private func cancelAction() {
  220. resetAndHide()
  221. }
  222. private func deleteAction() {
  223. store.delete(user: selectedUser!)
  224. resetAndHide()
  225. }
  226. private func isValid() -> Bool {
  227. if selectedUser == nil { // New user
  228. if baseUrl.range(of: "^https?://.+", options: .regularExpression, range: nil, locale: nil) == nil {
  229. return false
  230. } else if username.isEmpty || password.isEmpty {
  231. return false
  232. } else if store.getUser(baseUrl: baseUrl) != nil {
  233. return false
  234. }
  235. } else { // Existing user
  236. if username.isEmpty {
  237. return false
  238. }
  239. }
  240. return true
  241. }
  242. private func resetAndHide() {
  243. showDialog = false
  244. DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
  245. // Hide first and then reset, otherwise we'll see the text fields change
  246. selectedUser = nil
  247. baseUrl = ""
  248. username = ""
  249. password = ""
  250. }
  251. }
  252. }
  253. struct UserRowView: View {
  254. @EnvironmentObject private var store: Store
  255. @ObservedObject var user: User
  256. var body: some View {
  257. // I tried to add a swipe action here to delete, but for some strange reason it doesn't work,
  258. // even though in the subscription list it does.
  259. HStack {
  260. Image(systemName: "person.fill")
  261. VStack(alignment: .leading, spacing: 0) {
  262. VStack(alignment: .leading, spacing: 0) {
  263. Text(user.username ?? "?")
  264. Text(user.baseUrl ?? "?")
  265. .font(.subheadline)
  266. .foregroundColor(.gray)
  267. }
  268. }
  269. Spacer()
  270. Image(systemName: "chevron.forward")
  271. .font(.system(size: 12.0))
  272. .foregroundColor(.gray)
  273. }
  274. .padding(.all, 4)
  275. }
  276. }
  277. struct AboutView: View {
  278. var body: some View {
  279. Group {
  280. Button(action: {
  281. open(url: "https://ntfy.sh/docs")
  282. }) {
  283. HStack {
  284. Text("Read the docs")
  285. Spacer()
  286. Text("ntfy.sh/docs")
  287. .foregroundColor(.gray)
  288. Image(systemName: "link")
  289. }
  290. }
  291. Button(action: {
  292. open(url: "https://github.com/binwiederhier/ntfy/issues")
  293. }) {
  294. HStack {
  295. Text("Report a bug")
  296. Spacer()
  297. Text("github.com")
  298. .foregroundColor(.gray)
  299. Image(systemName: "link")
  300. }
  301. }
  302. Button(action: {
  303. open(url: "itms-apps://itunes.apple.com/app/id1625396347")
  304. }) {
  305. HStack {
  306. Text("Rate the app")
  307. Spacer()
  308. Text("App Store")
  309. .foregroundColor(.gray)
  310. Image(systemName: "star.fill")
  311. }
  312. }
  313. HStack {
  314. Text("Version")
  315. Spacer()
  316. Text("ntfy \(Config.version) (\(Config.build))")
  317. .foregroundColor(.gray)
  318. }
  319. }
  320. .foregroundColor(.primary)
  321. }
  322. private func open(url: String) {
  323. guard let url = URL(string: url) else { return }
  324. UIApplication.shared.open(url, options: [:], completionHandler: nil)
  325. }
  326. }
  327. struct SettingsView_Previews: PreviewProvider {
  328. static var previews: some View {
  329. let store = Store.preview // Store.previewEmpty
  330. SettingsView()
  331. .environment(\.managedObjectContext, store.context)
  332. .environmentObject(store)
  333. .environmentObject(AppDelegate())
  334. }
  335. }