248 lines
8.8 KiB
Swift
248 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 are not 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("Support for self-hosted servers is currently limited. To ensure instant delivery, be sure to set upstream-base-url in your server's config, otherwise messages may arrive with significant delay. Auth is not yet supported.") : 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 login 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 : Config.appBaseUrl
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|