123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247 |
- import SwiftUI
- struct SubscriptionAddView: View {
- private let tag = "SubscriptionAddView"
-
- @Binding var isShowing: Bool
-
- @EnvironmentObject private var store: Store
- @State private var topic: String = ""
- @State private var useAnother: Bool = false
- @State private var baseUrl: String = ""
-
- @State private var showLogin: Bool = false
- @State private var username: String = ""
- @State private var password: String = ""
-
- @State private var loading = false
- @State private var addError: String?
- @State private var loginError: String?
- private var subscriptionManager: SubscriptionManager {
- return SubscriptionManager(store: store)
- }
-
- var body: some View {
- NavigationView {
- // This is a little weird, but it works. The nagivation link for the login view
- // is rendered in the backgroun (it's hidden), abd we toggle it manually.
- // If anyone has a better way to do a two-page layout let me know.
-
- addView
- .background(Group {
- NavigationLink(
- destination: loginView,
- isActive: $showLogin
- ) {
- EmptyView()
- }
- })
- }
- }
-
- private var addView: some View {
- VStack(alignment: .leading, spacing: 0) {
- Form {
- Section(
- footer: Text("Topics may not be password-protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications")
- ) {
- TextField("Topic name, e.g. phil_alerts", text: $topic)
- .disableAutocapitalization()
- .disableAutocorrection(true)
- }
- Section(
- footer:
- (useAnother) ? Text("To ensure instant delivery from your self-hosted server, be sure to set upstream-base-url in your server's config, otherwise messages may arrive with significant delay.") : Text("")
- ) {
- Toggle("Use another server", isOn: $useAnother)
- if useAnother {
- TextField("Service URL, e.g. https://ntfy.home.io", text: $baseUrl)
- .disableAutocapitalization()
- .disableAutocorrection(true)
- }
- }
- }
- if let error = addError {
- ErrorView(error: error)
- }
- }
- .navigationTitle("Add subscription")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .navigationBarLeading) {
- Button(action: cancelAction) {
- Text("Cancel")
- }
- }
- ToolbarItem(placement: .navigationBarTrailing) {
- Button(action: subscribeOrShowLoginAction) {
- VStack {
- if loading {
- ProgressView()
- .progressViewStyle(CircularProgressViewStyle())
- } else {
- Text("Subscribe")
- }
- }
- .fixedSize(horizontal: true, vertical: false)
- }
- .disabled(!isAddViewValid())
- }
- }
- }
-
- private var loginView: some View {
- VStack(alignment: .leading, spacing: 0) {
- Form {
- Section(
- footer: Text("This topic requires that you log in with username and password. The user will be stored on your device, and will be re-used for other topics.")
- ) {
- TextField("Username", text: $username)
- .disableAutocapitalization()
- .disableAutocorrection(true)
- SecureField("Password", text: $password)
- }
- }
- if let error = loginError {
- ErrorView(error: error)
- }
- }
- .navigationTitle("Login required")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .navigationBarTrailing) {
- Button(action: subscribeWithUserAction) {
- if loading {
- ProgressView()
- .progressViewStyle(CircularProgressViewStyle())
- } else {
- Text("Subscribe")
- }
- }
- .disabled(!isLoginViewValid())
- }
- }
- }
-
- private var sanitizedTopic: String {
- return topic.trimmingCharacters(in: .whitespaces)
- }
-
- private func isAddViewValid() -> Bool {
- if sanitizedTopic.isEmpty {
- return false
- } else if sanitizedTopic.range(of: "^[-_A-Za-z0-9]{1,64}$", options: .regularExpression, range: nil, locale: nil) == nil {
- return false
- } else if selectedBaseUrl.range(of: "^https?://.+", options: .regularExpression, range: nil, locale: nil) == nil {
- return false
- } else if store.getSubscription(baseUrl: selectedBaseUrl, topic: topic) != nil {
- return false
- }
- return true
- }
-
- private func isLoginViewValid() -> Bool {
- if username.isEmpty || password.isEmpty {
- return false
- }
- return true
- }
-
- private func subscribeOrShowLoginAction() {
- loading = true
- addError = nil
- let user = store.getUser(baseUrl: selectedBaseUrl)?.toBasicUser()
- ApiService.shared.checkAuth(baseUrl: selectedBaseUrl, topic: topic, user: user) { result in
- switch result {
- case .Success:
- DispatchQueue.global(qos: .background).async {
- subscriptionManager.subscribe(baseUrl: selectedBaseUrl, topic: sanitizedTopic)
- resetAndHide()
- }
- // Do not reset "loading", because resetAndHide() will do that after everything is done
- case .Unauthorized:
- if let user = user {
- addError = "User \(user.username) is not authorized to read this topic"
- } else {
- addError = nil // Reset
- showLogin = true
- }
- loading = false
- case .Error(let err):
- addError = err
- loading = false
- }
- }
- }
-
- private func subscribeWithUserAction() {
- loading = true
- loginError = nil
- let user = BasicUser(username: username, password: password)
- ApiService.shared.checkAuth(baseUrl: selectedBaseUrl, topic: topic, user: user) { result in
- switch result {
- case .Success:
- DispatchQueue.global(qos: .background).async {
- store.saveUser(baseUrl: selectedBaseUrl, username: username, password: password)
- subscriptionManager.subscribe(baseUrl: selectedBaseUrl, topic: sanitizedTopic)
- resetAndHide()
- }
- // Do not reset "loading", because resetAndHide() will do that after everything is done
- case .Unauthorized:
- loginError = "Invalid credentials, or user \(username) is not authorized to read this topic"
- loading = false
- case .Error(let err):
- loginError = err
- loading = false
- }
- }
- }
-
- private func cancelAction() {
- resetAndHide()
- }
-
- private var selectedBaseUrl: String {
- return (useAnother) ? baseUrl : store.getDefaultBaseUrl()
- }
-
- private func resetAndHide() {
- isShowing = false
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
- // Hide first and then reset, otherwise we'll see the text fields change
- addError = nil
- loginError = nil
- loading = false
- baseUrl = ""
- topic = ""
- useAnother = false
- }
- }
- }
- struct ErrorView: View {
- var error: String
- var body: some View {
- HStack {
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundColor(.red)
- .font(.title2)
- Text(error)
- .font(.subheadline)
- }
- .padding([.leading, .trailing], 20)
- .padding([.top, .bottom], 10)
- }
- }
- struct SubscriptionAddView_Previews: PreviewProvider {
- @State static var isShowing = true
-
- static var previews: some View {
- let store = Store.preview
- SubscriptionAddView(isShowing: $isShowing)
- .environmentObject(store)
- }
- }
|