oncall-mobile-ios/ntfy/Views/SubscriptionAddView.swift
2023-11-13 11:49:16 -05:00

247 lines
8.8 KiB
Swift

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