diff --git a/Package.swift b/Package.swift index 1d7fc58c..728f58b0 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,7 @@ let package = Package( .library(name: "WishKit", targets: ["WishKit"]) ], dependencies: [ - .package(url: "https://github.com/wishkit/wishkit-ios-shared.git", exact: "1.4.3") + .package(url: "https://github.com/wishkit/wishkit-ios-shared.git", branch: "private-feedback") ], targets: [ .target(name: "WishKit", dependencies: [ diff --git a/Sources/WishKit/API/Api.swift b/Sources/WishKit/API/Api.swift index 938f4007..3a361a49 100644 --- a/Sources/WishKit/API/Api.swift +++ b/Sources/WishKit/API/Api.swift @@ -15,17 +15,12 @@ struct Api: RequestCreatable { } } -enum ApiResult { - case success(Success) - case failure(Error) -} - // MARK: - Generic Send Functions extension Api { /// Generic Send Function. You need to specify the Result type to help inferring it. /// e.g: Api.send(request: resetRequest) { (result: Result) in ... } - static func send(request: URLRequest, completionHandler: @escaping (ApiResult) -> Void) { + static func send(request: URLRequest, completionHandler: @escaping (Result) -> Void) { let method = request.httpMethod ?? "" print("🌐 API | \(method) | \(request.url?.absoluteString ?? "nil")") @@ -69,7 +64,7 @@ extension Api { /// Generic Send Function. You need to specify the Result type to help inferring it. /// e.g: Api.send(request: resetRequest) { (result: Result) in ... } - static func send(request: URLRequest) async -> ApiResult { + static func send(request: URLRequest) async -> Result { let method = request.httpMethod ?? "" print("🌐 API | \(method) | \(request.url?.absoluteString ?? "nil")") diff --git a/Sources/WishKit/API/CommentApi.swift b/Sources/WishKit/API/CommentApi.swift index a5c6fb22..e0a3d2be 100644 --- a/Sources/WishKit/API/CommentApi.swift +++ b/Sources/WishKit/API/CommentApi.swift @@ -11,19 +11,19 @@ import WishKitShared struct CommentApi: RequestCreatable { - private static let baseUrl = "\(ProjectSettings.apiUrl)" + private static let baseUrl = ProjectSettings.apiUrl - private static var endpoint = URL(string: "\(baseUrl)/comment") + private static let endpoint = URL(string: "\(baseUrl)/comment") // MARK: - URLRequests private static func createComment(_ request: CreateCommentRequest) -> URLRequest? { guard var url = endpoint else { return nil } url.appendPathComponent("create") - return createAuthedPOSTReuqest(to: url, with: request) + return createAuthedPOSTRequest(to: url, with: request) } - static func createComment(request: CreateCommentRequest) async -> ApiResult { + static func createComment(request: CreateCommentRequest) async -> Result { guard let request = createComment(request) else { return .failure(ApiError(reason: .couldNotCreateRequest)) diff --git a/Sources/WishKit/API/PrivateFeedbackApi.swift b/Sources/WishKit/API/PrivateFeedbackApi.swift new file mode 100644 index 00000000..02aa7ced --- /dev/null +++ b/Sources/WishKit/API/PrivateFeedbackApi.swift @@ -0,0 +1,36 @@ +// +// PrivateFeedbackApi.swift +// wishkit-ios +// +// Created by Martin Lasek on 4/6/24. +// Copyright © 2024 Martin Lasek. All rights reserved. +// + +import Foundation +import WishKitShared + +struct PrivateFeedbackApi: RequestCreatable { + + private static let baseUrl = ProjectSettings.apiUrl + + private static let endpoint = URL(string: "\(baseUrl)/private-feedback") + + // MARK: - URLRequests + + private static func createPrivateFeedback(_ createRequest: CreatePrivateFeedbackRequest) -> URLRequest? { + guard var url = endpoint else { return nil } + url.appendPathComponent("create") + return createAuthedPOSTRequest(to: url, with: createRequest) + } + + // MARK: - Api Requests + + static func createPrivateFeedback(createRequest: CreatePrivateFeedbackRequest) async -> Result { + + guard let request = createPrivateFeedback(createRequest) else { + return .failure(ApiError(reason: .couldNotCreateRequest)) + } + + return await Api.send(request: request) + } +} diff --git a/Sources/WishKit/API/RequestCreatable.swift b/Sources/WishKit/API/RequestCreatable.swift index 6db0ca12..2437f7c4 100644 --- a/Sources/WishKit/API/RequestCreatable.swift +++ b/Sources/WishKit/API/RequestCreatable.swift @@ -8,7 +8,7 @@ import Foundation -protocol RequestCreatable {} +protocol RequestCreatable { } extension RequestCreatable { @@ -44,14 +44,14 @@ extension RequestCreatable { // MARK: - Authed URLRequests - static func createAuthedPOSTReuqest(to url: URL, with body: T) -> URLRequest { + static func createAuthedPOSTRequest(to url: URL, with body: T) -> URLRequest { var request = createPOSTRequest(to: url, with: body) request.addAuth() request.addSdkInfo() return request } - static func createAuthedGETReuqest(to url: URL) -> URLRequest { + static func createAuthedGETRequest(to url: URL) -> URLRequest { var request = createGETRequest(to: url) request.addAuth() request.addSdkInfo() diff --git a/Sources/WishKit/API/UserApi.swift b/Sources/WishKit/API/UserApi.swift index 757e9784..1446d07f 100644 --- a/Sources/WishKit/API/UserApi.swift +++ b/Sources/WishKit/API/UserApi.swift @@ -11,19 +11,19 @@ import WishKitShared struct UserApi: RequestCreatable { - private static let baseUrl = "\(ProjectSettings.apiUrl)" + private static let baseUrl = ProjectSettings.apiUrl - private static var endpoint = URL(string: "\(baseUrl)/user") + private static let endpoint = URL(string: "\(baseUrl)/user") // MARK: - URLRequests private static func updateUser(_ userRequest: UserRequest) -> URLRequest? { guard var url = endpoint else { return nil } url.appendPathComponent("update") - return createAuthedPOSTReuqest(to: url, with: userRequest) + return createAuthedPOSTRequest(to: url, with: userRequest) } - static func updateUser(userRequest: UserRequest) async -> ApiResult { + static func updateUser(userRequest: UserRequest) async -> Result { guard let request = updateUser(userRequest) else { return .failure(ApiError(reason: .couldNotCreateRequest)) diff --git a/Sources/WishKit/API/WishApi.swift b/Sources/WishKit/API/WishApi.swift index 41beb0c1..4c7e54a2 100644 --- a/Sources/WishKit/API/WishApi.swift +++ b/Sources/WishKit/API/WishApi.swift @@ -9,68 +9,85 @@ import Foundation import WishKitShared +protocol WishApiProvider { + func fetchWishList(completion: @escaping (Result) -> Void) + + func createWish( + createRequest: CreateWishRequest, + completion: @escaping (Result) -> Void + ) + + func voteWish( + voteRequest: VoteWishRequest, + completion: @escaping (Result) -> Void + ) +} + struct WishApi: RequestCreatable { - private static let baseUrl = "\(ProjectSettings.apiUrl)" + private static let baseUrl = ProjectSettings.apiUrl - private static var endpoint = URL(string: "\(baseUrl)/wish") + private static let endpoint = URL(string: "\(baseUrl)/wish") // MARK: - URLRequests private static func fetchWishList() -> URLRequest? { guard var url = endpoint else { return nil } url.appendPathComponent("list") - return createAuthedGETReuqest(to: url) + return createAuthedGETRequest(to: url) } private static func createWish(_ createRequest: CreateWishRequest) -> URLRequest? { guard var url = endpoint else { return nil } url.appendPathComponent("create") - return createAuthedPOSTReuqest(to: url, with: createRequest) + return createAuthedPOSTRequest(to: url, with: createRequest) } private static func voteWish(_ voteRequest: VoteWishRequest) -> URLRequest? { guard var url = endpoint else { return nil } url.appendPathComponent("vote") - return createAuthedPOSTReuqest(to: url, with: voteRequest) + return createAuthedPOSTRequest(to: url, with: voteRequest) } +} + +// MARK: - WishApiProvider - // MARK: - Api Requests +extension WishApi: WishApiProvider { - static func fetchWishList( - completionHandler: @escaping (ApiResult) -> Void + func fetchWishList( + completion: @escaping (Result) -> Void ) { - guard let request = fetchWishList() else { - completionHandler(.failure(ApiError(reason: .couldNotCreateRequest))) + guard let request = WishApi.fetchWishList() else { + completion(.failure(ApiError(reason: .couldNotCreateRequest))) return } - Api.send(request: request, completionHandler: completionHandler) + Api.send(request: request, completionHandler: completion) } - static func createWish( + func createWish( createRequest: CreateWishRequest, - completionHandler: @escaping (ApiResult) -> Void + completion: @escaping (Result) -> Void ) { - guard let request = createWish(createRequest) else { - completionHandler(.failure(ApiError(reason: .couldNotCreateRequest))) + guard let request = WishApi.createWish(createRequest) else { + completion(.failure(ApiError(reason: .couldNotCreateRequest))) return } - Api.send(request: request, completionHandler: completionHandler) + Api.send(request: request, completionHandler: completion) } - static func voteWish( + func voteWish( voteRequest: VoteWishRequest, - completionHandler: @escaping (ApiResult) -> Void + completion: @escaping (Result) -> Void ) { - guard let request = voteWish(voteRequest) else { - completionHandler(.failure(ApiError(reason: .couldNotCreateRequest))) + guard let request = WishApi.voteWish(voteRequest) else { + completion(.failure(ApiError(reason: .couldNotCreateRequest))) return } - Api.send(request: request, completionHandler: completionHandler) + Api.send(request: request, completionHandler: completion) } } diff --git a/Sources/WishKit/Configuration+Localization.swift b/Sources/WishKit/Configuration/Configuration+Localization.swift similarity index 100% rename from Sources/WishKit/Configuration+Localization.swift rename to Sources/WishKit/Configuration/Configuration+Localization.swift diff --git a/Sources/WishKit/Extensions/ToolbarCompat.swift b/Sources/WishKit/Extensions/ToolbarCompat.swift index ce7cdd3f..7886ba4d 100644 --- a/Sources/WishKit/Extensions/ToolbarCompat.swift +++ b/Sources/WishKit/Extensions/ToolbarCompat.swift @@ -9,7 +9,9 @@ import SwiftUI extension View { + @ViewBuilder + /// If the toolBar is placed in a view that's inside a NavigationStack it doesn't work and this is a bug. func toolbarKeyboardDoneButton() -> some View { #if canImport(UIKit) && !os(visionOS) if #available(macOS 13.0, iOS 15, *) { @@ -17,8 +19,10 @@ extension View { ToolbarItem(placement: .keyboard) { HStack { Spacer() - Button(action: { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) }, label: { Text("Done") }) - + Button( + action: { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) }, + label: { Text("Done") } + ) } } } diff --git a/Sources/WishKit/Model/AlertModel.swift b/Sources/WishKit/Model/AlertModel.swift index f7df9c63..0ce0d8bc 100644 --- a/Sources/WishKit/Model/AlertModel.swift +++ b/Sources/WishKit/Model/AlertModel.swift @@ -10,10 +10,17 @@ import SwiftUI final class AlertModel: ObservableObject { + @Published + var showAlert = false + + @Published + var alertReason: AlertReason = .none + enum AlertReason { case alreadyVoted case alreadyImplemented case voteReturnedError(String) + case descriptionRequired case successfullyCreated case createReturnedError(String) @@ -22,10 +29,4 @@ final class AlertModel: ObservableObject { case none } - - @Published - var showAlert = false - - @Published - var alertReason: AlertReason = .none } diff --git a/Sources/WishKit/Model/CommentModel.swift b/Sources/WishKit/Model/CommentModel.swift index 8ed864a9..347c6c33 100644 --- a/Sources/WishKit/Model/CommentModel.swift +++ b/Sources/WishKit/Model/CommentModel.swift @@ -9,6 +9,7 @@ import SwiftUI final class CommentModel: ObservableObject { + @Published var newCommentValue = "" diff --git a/Sources/WishKit/Model/WishModel.swift b/Sources/WishKit/Model/WishModel.swift index a3b9fa7c..2c84a1de 100644 --- a/Sources/WishKit/Model/WishModel.swift +++ b/Sources/WishKit/Model/WishModel.swift @@ -29,11 +29,17 @@ final class WishModel: ObservableObject { @Published var hasFetched: Bool = false + let wishApi: WishApiProvider + + init(wishApi: WishApiProvider) { + self.wishApi = wishApi + } + @MainActor func fetchList(completion: (() -> ())? = nil) { isLoading = true - WishApi.fetchWishList { result in + wishApi.fetchWishList { result in switch result { case .success(let response): DispatchQueue.main.async { diff --git a/Sources/WishKit/ProjectSettings.swift b/Sources/WishKit/ProjectSettings.swift index c6b7e48a..e6de5bdc 100644 --- a/Sources/WishKit/ProjectSettings.swift +++ b/Sources/WishKit/ProjectSettings.swift @@ -9,9 +9,11 @@ import Foundation struct ProjectSettings { - static var apiUrl: String { + + static let apiUrl = { let wishKitUrl = ProcessInfo.processInfo.environment["wishkit-url"] return wishKitUrl ?? "https://www.wishkit.io/api" - } + }() + static let sdkVersion = "4.1.1" } diff --git a/Sources/WishKit/Theme.swift b/Sources/WishKit/Theme.swift index 0e794392..d393c195 100644 --- a/Sources/WishKit/Theme.swift +++ b/Sources/WishKit/Theme.swift @@ -105,12 +105,12 @@ extension Theme { } /// Convenience function to set ligth and dark mode colors. - static public func `set`(light: Color, dark: Color) -> Scheme { + public static func `set`(light: Color, dark: Color) -> Scheme { return Scheme(light: light, dark: dark) } /// Sets the same color for light and dark mode. - static public func `setBoth`(to color: Color) -> Scheme { + public static func `setBoth`(to color: Color) -> Scheme { return Scheme(light: color, dark: color) } } diff --git a/Sources/WishKit/Utils/WKHostingController.swift b/Sources/WishKit/Utils/WKHostingController.swift deleted file mode 100644 index d08667b6..00000000 --- a/Sources/WishKit/Utils/WKHostingController.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// WKHostingController.swift -// wishkit-ios -// -// Created by Martin Lasek on 8/15/23. -// Copyright © 2023 Martin Lasek. All rights reserved. -// -#if canImport(UIKit) -import SwiftUI - -final class WKHostingController: UIHostingController where Content: View { - override init(rootView: Content) { - super.init(rootView: rootView) - applyTheme() - } - - @MainActor required dynamic init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func applyTheme() { - let backgroundColorLight = WishKit.theme.tertiaryColor?.light ?? PrivateTheme.systemBackgroundColor.light - let backgroundColorDark = WishKit.theme.tertiaryColor?.dark ?? PrivateTheme.systemBackgroundColor.dark - - if traitCollection.userInterfaceStyle == .light { - view.backgroundColor = UIColor(backgroundColorLight) - } - - if traitCollection.userInterfaceStyle == .dark { - view.backgroundColor = UIColor(backgroundColorDark) - } - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - guard - let previousTraitCollection = previousTraitCollection - else { - return - } - - // Needed this case where it's the same, there's a weird behaviour otherwise. - if traitCollection.userInterfaceStyle == previousTraitCollection.userInterfaceStyle { - if let bgColor = WishKit.theme.tertiaryColor { - if previousTraitCollection.userInterfaceStyle == .light { - view.backgroundColor = UIColor(bgColor.light) - } else if previousTraitCollection.userInterfaceStyle == .dark { - view.backgroundColor = UIColor(bgColor.dark) - } - } - } else { - if let bgColor = WishKit.theme.tertiaryColor { - if previousTraitCollection.userInterfaceStyle == .dark { - view.backgroundColor = UIColor(bgColor.light) - } else if previousTraitCollection.userInterfaceStyle == .light { - view.backgroundColor = UIColor(bgColor.dark) - } - } else { - if traitCollection.userInterfaceStyle == .light { - view.backgroundColor = UIColor(PrivateTheme.systemBackgroundColor.light) - } else if traitCollection.userInterfaceStyle == .dark { - view.backgroundColor = UIColor(PrivateTheme.systemBackgroundColor.dark) - } - } - } - } -} -#endif diff --git a/Sources/WishKit/SwiftUI/AddButton.swift b/Sources/WishKit/Views/AddButton.swift similarity index 100% rename from Sources/WishKit/SwiftUI/AddButton.swift rename to Sources/WishKit/Views/AddButton.swift diff --git a/Sources/WishKit/SwiftUI/CloseButton.swift b/Sources/WishKit/Views/CloseButton.swift similarity index 100% rename from Sources/WishKit/SwiftUI/CloseButton.swift rename to Sources/WishKit/Views/CloseButton.swift diff --git a/Sources/WishKit/SwiftUI/CommentFieldView.swift b/Sources/WishKit/Views/CommentFieldView.swift similarity index 100% rename from Sources/WishKit/SwiftUI/CommentFieldView.swift rename to Sources/WishKit/Views/CommentFieldView.swift diff --git a/Sources/WishKit/SwiftUI/CommentListView.swift b/Sources/WishKit/Views/CommentListView.swift similarity index 100% rename from Sources/WishKit/SwiftUI/CommentListView.swift rename to Sources/WishKit/Views/CommentListView.swift diff --git a/Sources/WishKit/SwiftUI/CreateWishView.swift b/Sources/WishKit/Views/CreateWishView.swift similarity index 72% rename from Sources/WishKit/SwiftUI/CreateWishView.swift rename to Sources/WishKit/Views/CreateWishView.swift index 18fb9e30..d403faa8 100644 --- a/Sources/WishKit/SwiftUI/CreateWishView.swift +++ b/Sources/WishKit/Views/CreateWishView.swift @@ -18,7 +18,7 @@ struct CreateWishView: View { @Environment(\.colorScheme) private var colorScheme - @ObservedObject + @StateObject private var alertModel = AlertModel() @State @@ -39,13 +39,18 @@ struct CreateWishView: View { @State private var isButtonLoading: Bool? = false - @State - private var showConfirmationAlert = false - let createActionCompletion: () -> Void var closeAction: (() -> Void)? = nil + private let wishApi: WishApiProvider + + init(createActionCompletion: @escaping (() -> Void), closeAction: (() -> Void)? = nil, wishApi: WishApiProvider) { + self.createActionCompletion = createActionCompletion + self.closeAction = closeAction + self.wishApi = wishApi + } + var saveButtonSize: CGSize { #if os(macOS) || os(visionOS) return CGSize(width: 100, height: 30) @@ -59,7 +64,7 @@ struct CreateWishView: View { if showCloseButton() { HStack { Spacer() - CloseButton(closeAction: dismissViewAction) + CloseButton(closeAction: crossPlatformDismiss) } } @@ -143,64 +148,7 @@ struct CreateWishView: View { size: saveButtonSize ) .disabled(isButtonDisabled) - .alert(isPresented: $alertModel.showAlert) { - - switch alertModel.alertReason { - case .successfullyCreated: - let button = Alert.Button.default( - Text(WishKit.config.localization.ok), - action: { - createActionCompletion() - dismissAction() - } - ) - - return Alert( - title: Text(WishKit.config.localization.info), - message: Text(WishKit.config.localization.successfullyCreated), - dismissButton: button - ) - case .createReturnedError(let errorText): - let button = Alert.Button.default(Text(WishKit.config.localization.ok)) - - return Alert( - title: Text(WishKit.config.localization.info), - message: Text(errorText), - dismissButton: button - ) - case .emailRequired: - let button = Alert.Button.default(Text(WishKit.config.localization.ok)) - - return Alert( - title: Text(WishKit.config.localization.info), - message: Text(WishKit.config.localization.emailRequiredText), - dismissButton: button - ) - case .emailFormatWrong: - let button = Alert.Button.default(Text(WishKit.config.localization.ok)) - - return Alert( - title: Text(WishKit.config.localization.info), - message: Text(WishKit.config.localization.emailFormatWrongText), - dismissButton: button - ) - case .none: - let button = Alert.Button.default(Text(WishKit.config.localization.ok)) - return Alert(title: Text(""), dismissButton: button) - default: - let button = Alert.Button.default(Text(WishKit.config.localization.ok)) - return Alert(title: Text(""), dismissButton: button) - } - - } - } - .alert(isPresented: $showConfirmationAlert) { - let button = Alert.Button.default(Text(WishKit.config.localization.ok), action: crossPlatformDismiss) - return Alert( - title: Text(WishKit.config.localization.info), - message: Text(WishKit.config.localization.discardEnteredInformation), - dismissButton: button - ) + .alert(isPresented: $alertModel.showAlert, content: getAlertContent) } .frame(maxWidth: 700) .padding() @@ -216,6 +164,59 @@ struct CreateWishView: View { .toolbarKeyboardDoneButton() } + // MARK: - Views + + private func getAlertContent() -> Alert { + switch alertModel.alertReason { + case .successfullyCreated: + let button = Alert.Button.default( + Text(WishKit.config.localization.ok), + action: { + createActionCompletion() + crossPlatformDismiss() + } + ) + + return Alert( + title: Text(WishKit.config.localization.info), + message: Text(WishKit.config.localization.successfullyCreated), + dismissButton: button + ) + case .createReturnedError(let errorText): + let button = Alert.Button.default(Text(WishKit.config.localization.ok)) + + return Alert( + title: Text(WishKit.config.localization.info), + message: Text(errorText), + dismissButton: button + ) + case .emailRequired: + let button = Alert.Button.default(Text(WishKit.config.localization.ok)) + + return Alert( + title: Text(WishKit.config.localization.info), + message: Text(WishKit.config.localization.emailRequiredText), + dismissButton: button + ) + case .emailFormatWrong: + let button = Alert.Button.default(Text(WishKit.config.localization.ok)) + + return Alert( + title: Text(WishKit.config.localization.info), + message: Text(WishKit.config.localization.emailFormatWrongText), + dismissButton: button + ) + case .none: + let button = Alert.Button.default(Text(WishKit.config.localization.ok)) + return Alert(title: Text(""), dismissButton: button) + default: + let button = Alert.Button.default(Text(WishKit.config.localization.ok)) + return Alert(title: Text(""), dismissButton: button) + } + } + + // MARK: - Logic + private func showCloseButton() -> Bool { #if os(macOS) || os(visionOS) return true @@ -260,7 +261,7 @@ struct CreateWishView: View { isButtonLoading = true let createRequest = CreateWishRequest(title: titleText, description: descriptionText, email: emailText) - WishApi.createWish(createRequest: createRequest) { result in + wishApi.createWish(createRequest: createRequest) { result in isButtonLoading = false DispatchQueue.main.async { switch result { @@ -275,19 +276,11 @@ struct CreateWishView: View { } } - private func dismissViewAction() { - if !titleText.isEmpty || !descriptionText.isEmpty || !emailText.isEmpty { - showConfirmationAlert = true - } else { - crossPlatformDismiss() - } - } - private func crossPlatformDismiss() { #if os(macOS) || os(visionOS) closeAction?() #else - dismissViewAction() + dismissAction() #endif } diff --git a/Sources/WishKit/SwiftUI/DetailWishView.swift b/Sources/WishKit/Views/DetailWishView.swift similarity index 98% rename from Sources/WishKit/SwiftUI/DetailWishView.swift rename to Sources/WishKit/Views/DetailWishView.swift index 595e1cbd..4bf69119 100644 --- a/Sources/WishKit/SwiftUI/DetailWishView.swift +++ b/Sources/WishKit/Views/DetailWishView.swift @@ -54,7 +54,7 @@ struct DetailWishView: View { Spacer(minLength: 15) - WishView(wishResponse: wishResponse, viewKind: .detail, voteActionCompletion: voteActionCompletion) + WishView(wishResponse: wishResponse, viewKind: .detail, voteActionCompletion: voteActionCompletion, wishApi: WishApi()) .frame(maxWidth: 700) Spacer(minLength: 15) diff --git a/Sources/WishKit/Views/PrivateFeedbackView.swift b/Sources/WishKit/Views/PrivateFeedbackView.swift new file mode 100644 index 00000000..95ca730d --- /dev/null +++ b/Sources/WishKit/Views/PrivateFeedbackView.swift @@ -0,0 +1,286 @@ +// +// PrivateFeedbackView.swift +// wishkit-ios +// +// Created by Martin Lasek on 4/6/24. +// Copyright © 2024 Martin Lasek. All rights reserved. +// + +import SwiftUI +import Combine +import WishKitShared + +/// A private feedback view to gather one time feedback. +/// It allows you to receive feedback privately that is only +/// visible in the dashboard. No votes. No display within the app. +/// Use case: When you don't want to share feedback publicly. +struct PrivateFeedbackView: View { + + @Environment(\.colorScheme) + private var colorScheme + + @Environment(\.presentationMode) + var presentationMode + + @StateObject + private var alertModel = AlertModel() + + @State + private var emailText = "" + + @State + private var descriptionText = "" + + @State + private var isButtonLoading: Bool? = false + + var closeAction: (() -> Void)? = nil + + var saveButtonSize: CGSize { + #if os(macOS) || os(visionOS) + return CGSize(width: 100, height: 30) + #else + return CGSize(width: 200, height: 45) + #endif + } + + var body: some View { + ScrollView { + VStack { + Text("Kindly share your feedback") + .font(.headline) + .padding(.bottom, 5) + + HStack { + Text("This feedback is private and is sent directly to the developer.") + .font(.callout) + .padding(.bottom, 30) + + Spacer() + } + + VStack(spacing: 15) { + if WishKit.config.emailField != .none { + VStack(alignment: .leading, spacing: 0) { + HStack { + if WishKit.config.emailField == .optional { + Text(WishKit.config.localization.emailOptional) + .font(.caption2) + .padding([.leading, .trailing, .bottom], 5) + } + + if WishKit.config.emailField == .required { + Text(WishKit.config.localization.emailRequired) + .font(.caption2) + .padding([.leading, .trailing, .bottom], 5) + } + } + + TextField("", text: $emailText) + .padding(10) + .textFieldStyle(.plain) + .foregroundColor(textColor) + .background(fieldBackgroundColor) + .clipShape(RoundedRectangle(cornerRadius: WishKit.config.cornerRadius, style: .continuous)) + } + } + + VStack(alignment: .leading, spacing: 0) { + Text(WishKit.config.localization.description) + .font(.caption2) + .padding([.leading, .trailing, .bottom], 5) + + TextEditor(text: $descriptionText) + .padding([.leading, .trailing], 5) + .padding([.top, .bottom], 10) + .lineSpacing(3) + .frame(height: 200) + .foregroundColor(textColor) + .scrollContentBackgroundCompat(.hidden) + .background(fieldBackgroundColor) + .clipShape(RoundedRectangle(cornerRadius: WishKit.config.cornerRadius, style: .continuous)) + .toolbarKeyboardDoneButton() + } + } + + Spacer(minLength: 30) + + WKButton(text: "Submit", action: { + Task { + await submitPrivateFeedback() + } + }, isLoading: $isButtonLoading, size: saveButtonSize) + .alert(isPresented: $alertModel.showAlert, content: alertView) + }.padding() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(backgroundColor) + .ignoresSafeArea(edges: [.leading, .trailing]) + .toolbarKeyboardDoneButton() + } + + private func submitPrivateFeedback() async { + if descriptionText.isEmpty { + alertModel.alertReason = .descriptionRequired + alertModel.showAlert = true + return + } + + if WishKit.config.emailField == .required && emailText.isEmpty { + alertModel.alertReason = .emailRequired + alertModel.showAlert = true + return + } + + let request = CreatePrivateFeedbackRequest(email: emailText.isEmpty ? nil : emailText, description: descriptionText) + + isButtonLoading = true + + _ = await PrivateFeedbackApi.createPrivateFeedback(createRequest: request) + + isButtonLoading = false + + alertModel.alertReason = .successfullyCreated + alertModel.showAlert = true + } + + private func crossPlatformDismiss() { + #if os(macOS) || os(visionOS) + closeAction?() + #else + dismissAction() + #endif + } + + private func dismissAction() { + presentationMode.wrappedValue.dismiss() + } + + private func alertView() -> Alert { + switch alertModel.alertReason { + case .successfullyCreated: + let button = Alert.Button.default( + Text(WishKit.config.localization.ok), + action: { + crossPlatformDismiss() + } + ) + + return Alert( + title: Text(WishKit.config.localization.info), + message: Text(WishKit.config.localization.successfullyCreated), + dismissButton: button + ) + case .createReturnedError(let errorText): + let button = Alert.Button.default(Text(WishKit.config.localization.ok)) + + return Alert( + title: Text(WishKit.config.localization.info), + message: Text(errorText), + dismissButton: button + ) + case .emailRequired: + let button = Alert.Button.default(Text(WishKit.config.localization.ok)) + + return Alert( + title: Text(WishKit.config.localization.info), + message: Text(WishKit.config.localization.emailRequiredText), + dismissButton: button + ) + case .emailFormatWrong: + let button = Alert.Button.default(Text(WishKit.config.localization.ok)) + + return Alert( + title: Text(WishKit.config.localization.info), + message: Text(WishKit.config.localization.emailFormatWrongText), + dismissButton: button + ) + case .descriptionRequired: + let button = Alert.Button.default(Text(WishKit.config.localization.ok)) + + return Alert( + title: Text(WishKit.config.localization.info), + message: Text("Description cannot be empty"), + dismissButton: button + ) + default: + let button = Alert.Button.default(Text(WishKit.config.localization.ok)) + return Alert(title: Text(""), dismissButton: button) + } + } +} + +// MARK: - Color Scheme + +extension PrivateFeedbackView { + + var textColor: Color { + switch colorScheme { + case .light: + + if let color = WishKit.theme.textColor { + return color.light + } + + return .black + case .dark: + if let color = WishKit.theme.textColor { + return color.dark + } + + return .white + @unknown default: + if let color = WishKit.theme.textColor { + return color.light + } + + return .black + } + } + + var backgroundColor: Color { + switch colorScheme { + case .light: + if let color = WishKit.theme.tertiaryColor { + return color.light + } + + return PrivateTheme.systemBackgroundColor.light + case .dark: + if let color = WishKit.theme.tertiaryColor { + return color.dark + } + + return PrivateTheme.systemBackgroundColor.dark + @unknown default: + if let color = WishKit.theme.tertiaryColor { + return color.light + } + + return PrivateTheme.systemBackgroundColor.light + } + } + + var fieldBackgroundColor: Color { + switch colorScheme { + case .light: + if let color = WishKit.theme.secondaryColor { + return color.light + } + + return PrivateTheme.elementBackgroundColor.light + case .dark: + if let color = WishKit.theme.secondaryColor { + return color.dark + } + + return PrivateTheme.elementBackgroundColor.dark + @unknown default: + if let color = WishKit.theme.tertiaryColor { + return color.light + } + + return PrivateTheme.systemBackgroundColor.light + } + } +} diff --git a/Sources/WishKit/SwiftUI/SegmentedView.swift b/Sources/WishKit/Views/SegmentedView.swift similarity index 100% rename from Sources/WishKit/SwiftUI/SegmentedView.swift rename to Sources/WishKit/Views/SegmentedView.swift diff --git a/Sources/WishKit/SwiftUI/SeparatorView.swift b/Sources/WishKit/Views/SeparatorView.swift similarity index 100% rename from Sources/WishKit/SwiftUI/SeparatorView.swift rename to Sources/WishKit/Views/SeparatorView.swift diff --git a/Sources/WishKit/SwiftUI/SingleCommentView.swift b/Sources/WishKit/Views/SingleCommentView.swift similarity index 100% rename from Sources/WishKit/SwiftUI/SingleCommentView.swift rename to Sources/WishKit/Views/SingleCommentView.swift diff --git a/Sources/WishKit/SwiftUI/WKButton.swift b/Sources/WishKit/Views/WKButton.swift similarity index 100% rename from Sources/WishKit/SwiftUI/WKButton.swift rename to Sources/WishKit/Views/WKButton.swift diff --git a/Sources/WishKit/SwiftUI/WishView.swift b/Sources/WishKit/Views/WishView.swift similarity index 97% rename from Sources/WishKit/SwiftUI/WishView.swift rename to Sources/WishKit/Views/WishView.swift index 38a9509d..e71e5c5d 100644 --- a/Sources/WishKit/SwiftUI/WishView.swift +++ b/Sources/WishKit/Views/WishView.swift @@ -31,9 +31,11 @@ struct WishView: View { private let wishResponse: WishResponse + private let viewKind: ViewKind + private let voteActionCompletion: () -> Void - private let viewKind: ViewKind + private let wishApi: WishApiProvider private var descriptionLineLimit: Int? { if viewKind == .detail { @@ -43,10 +45,11 @@ struct WishView: View { return WishKit.config.expandDescriptionInList ? nil : 1 } - init(wishResponse: WishResponse, viewKind: ViewKind, voteActionCompletion: @escaping (() -> Void)) { + init(wishResponse: WishResponse, viewKind: ViewKind, voteActionCompletion: @escaping (() -> Void), wishApi: WishApiProvider) { self.wishResponse = wishResponse self.viewKind = viewKind self.voteActionCompletion = voteActionCompletion + self.wishApi = wishApi self._voteCount = State(wrappedValue: wishResponse.votingUsers.count) } @@ -179,7 +182,7 @@ struct WishView: View { } let request = VoteWishRequest(wishId: wishResponse.id) - WishApi.voteWish(voteRequest: request) { result in + wishApi.voteWish(voteRequest: request) { result in switch result { case .success: voteCount += 1 diff --git a/Sources/WishKit/SwiftUI/WishlistView.swift b/Sources/WishKit/Views/WishlistView.swift similarity index 96% rename from Sources/WishKit/SwiftUI/WishlistView.swift rename to Sources/WishKit/Views/WishlistView.swift index bdc44d5b..aa8bb0ca 100644 --- a/Sources/WishKit/SwiftUI/WishlistView.swift +++ b/Sources/WishKit/Views/WishlistView.swift @@ -57,7 +57,7 @@ struct WishlistView: View { if getList().count > 0 { List(getList(), id: \.id) { wish in Button(action: { selectWish(wish: wish) }) { - WishView(wishResponse: wish, viewKind: .list, voteActionCompletion: { wishModel.fetchList() }) + WishView(wishResponse: wish, viewKind: .list, voteActionCompletion: { wishModel.fetchList() }, wishApi: WishApi()) .padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)) } .listRowSeparatorCompat(.hidden) @@ -97,7 +97,8 @@ struct WishlistView: View { .sheet(isPresented: $showingCreateSheet) { CreateWishView( createActionCompletion: { wishModel.fetchList() }, - closeAction: { self.showingCreateSheet = false } + closeAction: { self.showingCreateSheet = false }, + wishApi: WishApi() ) .frame(minWidth: 500, idealWidth: 500, minHeight: 400, maxHeight: 600) .background(backgroundColor) diff --git a/Sources/WishKit/SwiftUI/iOS+Catalyst/WishlistView+iOS.swift b/Sources/WishKit/Views/iOS+Catalyst/WishlistView+iOS.swift similarity index 90% rename from Sources/WishKit/SwiftUI/iOS+Catalyst/WishlistView+iOS.swift rename to Sources/WishKit/Views/iOS+Catalyst/WishlistView+iOS.swift index b44db1d0..5bd28443 100644 --- a/Sources/WishKit/SwiftUI/iOS+Catalyst/WishlistView+iOS.swift +++ b/Sources/WishKit/Views/iOS+Catalyst/WishlistView+iOS.swift @@ -12,13 +12,11 @@ import WishKitShared import Combine extension View { - // MARK: Public - Wrap in Navigation + // MARK: - Wrap in Navigation @ViewBuilder public func withNavigation() -> some View { - NavigationView { - self - }.navigationViewStyle(.stack) + NavigationView { self }.navigationViewStyle(.stack) } } @@ -30,15 +28,15 @@ struct WishlistViewIOS: View { @State private var selectedWishState: WishState = .approved - @ObservedObject - var wishModel: WishModel - @State var selectedWish: WishResponse? = nil @State private var currentWishList: [WishResponse] = [] + @ObservedObject + var wishModel: WishModel + private var isInTabBar: Bool { let rootViewController = if #available(iOS 15, *) { UIApplication @@ -106,7 +104,7 @@ struct WishlistViewIOS: View { NavigationLink(destination: { DetailWishView(wishResponse: wish, voteActionCompletion: { wishModel.fetchList() }) }, label: { - WishView(wishResponse: wish, viewKind: .list, voteActionCompletion: { wishModel.fetchList() }) + WishView(wishResponse: wish, viewKind: .list, voteActionCompletion: { wishModel.fetchList() }, wishApi: WishApi()) .padding(.all, 5) .frame(maxWidth: 700) }) @@ -119,7 +117,6 @@ struct WishlistViewIOS: View { .refreshableCompat(action: { await wishModel.fetchList() }) .padding([.leading, .bottom, .trailing]) - HStack { Spacer() @@ -130,7 +127,7 @@ struct WishlistViewIOS: View { if WishKit.config.buttons.addButton.display == .show { NavigationLink( destination: { - CreateWishView(createActionCompletion: { wishModel.fetchList() }) + CreateWishView(createActionCompletion: { wishModel.fetchList() }, wishApi: WishApi()) }, label: { AddButton(size: CGSize(width: 60, height: 60)) } @@ -190,6 +187,8 @@ extension WishlistViewIOS { return WishKit.config.buttons.voteButton.arrowColor.light case .dark: return WishKit.config.buttons.voteButton.arrowColor.dark + @unknown default: + return WishKit.config.buttons.voteButton.arrowColor.light } } @@ -208,6 +207,12 @@ extension WishlistViewIOS { } return PrivateTheme.elementBackgroundColor.dark + @unknown default: + if let color = WishKit.theme.secondaryColor { + return color.light + } + + return PrivateTheme.elementBackgroundColor.light } } @@ -225,6 +230,12 @@ extension WishlistViewIOS { } return PrivateTheme.systemBackgroundColor.dark + @unknown default: + if let color = WishKit.theme.tertiaryColor { + return color.light + } + + return PrivateTheme.systemBackgroundColor.light } } } diff --git a/Sources/WishKit/SwiftUI/macOS/WishlistContainer+macOS.swift b/Sources/WishKit/Views/macOS/WishlistContainer+macOS.swift similarity index 100% rename from Sources/WishKit/SwiftUI/macOS/WishlistContainer+macOS.swift rename to Sources/WishKit/Views/macOS/WishlistContainer+macOS.swift diff --git a/Sources/WishKit/WishKit.swift b/Sources/WishKit/WishKit.swift index d06ef1ee..54e5dbaf 100644 --- a/Sources/WishKit/WishKit.swift +++ b/Sources/WishKit/WishKit.swift @@ -29,16 +29,32 @@ public struct WishKit { #if canImport(UIKit) && !os(visionOS) /// (UIKit) The WishList viewcontroller. public static var viewController: UIViewController { - UIHostingController(rootView: WishlistViewIOS(wishModel: WishModel())) + UIHostingController(rootView: WishlistViewIOS(wishModel: WishModel(wishApi: WishApi()))) } #endif /// (SwiftUI) The WishList view. public static var view: some View { #if os(macOS) || os(visionOS) - return WishlistContainer(wishModel: WishModel()) + return WishlistContainer(wishModel: WishModel(wishApi: WishApi())) #else - return WishlistViewIOS(wishModel: WishModel()) + return WishlistViewIOS(wishModel: WishModel(wishApi: WishApi())) + #endif + } + + #if canImport(UIKit) && !os(visionOS) + /// (UIKit) The WishList viewcontroller. + public static var privateFeedbackViewController: UIViewController { + UIHostingController(rootView: PrivateFeedbackView()) + } + #endif + + /// (SwiftUI) The WishList view. + public static var privateFeedbackView: some View { + #if os(macOS) || os(visionOS) + return PrivateFeedbackView() + #else + return PrivateFeedbackView() #endif } diff --git a/Tests/WishKitTests/MainTest.swift b/Tests/WishKitTests/MainTest.swift deleted file mode 100644 index 9237be6c..00000000 --- a/Tests/WishKitTests/MainTest.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// MainTest.swift -// -// -// Created by Martin Lasek on 2/9/23. -// - -import XCTest - -@testable import WishKit - -class MainTest: XCTestCase { - - override class func setUp() { - // setup code - } - - override func tearDown() { - // tear down code - } -} diff --git a/Sources/WishKit/Model/MockData.swift b/Tests/WishKitTests/Mock/MockData.swift similarity index 61% rename from Sources/WishKit/Model/MockData.swift rename to Tests/WishKitTests/Mock/MockData.swift index fed47ac7..2eb7fe0a 100644 --- a/Sources/WishKit/Model/MockData.swift +++ b/Tests/WishKitTests/Mock/MockData.swift @@ -52,4 +52,44 @@ struct MockData { commentList: [] ) } + + static let pendingWish = WishResponse( + id: UUID(), + userUUID: UUID(), + title: "📸 Transformation Challenge", + description: "It would be awesome to be able to take a picture after every workout and after 30 days it creates a video out of them.", + state: .pending, + votingUsers: [], + commentList: [] + ) + + static let approvedWish = WishResponse( + id: UUID(), + userUUID: UUID(), + title: "🎥 Exercise Video Example.", + description: "When doing an exercise it would be great if I could see a video example that shows me how to do it properly", + state: .approved, + votingUsers: [], + commentList: [] + ) + + static let implementedWish = WishResponse( + id: UUID(), + userUUID: UUID(), + title: "Health App Connection.", + description: "If this app would also let Health App know about your exercises then this would be awesome!", + state: .implemented, + votingUsers: [], + commentList: [] + ) + + static let rejectedWish = WishResponse( + id: UUID(), + userUUID: UUID(), + title: "Browser exercise list", + description: "I would like to see exercises in a list and be able to chose from then when creating my workouts instead of coming up with them myself.", + state: .rejected, + votingUsers: [], + commentList: [] + ) } diff --git a/Tests/WishKitTests/Mock/MockWishApi.swift b/Tests/WishKitTests/Mock/MockWishApi.swift new file mode 100644 index 00000000..bf84abae --- /dev/null +++ b/Tests/WishKitTests/Mock/MockWishApi.swift @@ -0,0 +1,34 @@ +// +// MockWishApi.swift +// wishkit-ios +// +// Created by Martin Lasek on 4/15/24. +// Copyright © 2024 Martin Lasek. All rights reserved. +// + +import XCTest + +@testable import WishKit +@testable import WishKitShared + +struct MockWishApi: WishApiProvider { + + func fetchWishList(completion: @escaping (Result) -> Void) { + let list = [ + MockData.pendingWish, + MockData.approvedWish, + MockData.implementedWish, + MockData.rejectedWish + ] + + completion(.success(ListWishResponse(list: list, shouldShowWatermark: true))) + } + + func createWish(createRequest: CreateWishRequest, completion: @escaping (Result) -> Void) { + fatalError() + } + + func voteWish(voteRequest: VoteWishRequest, completion: @escaping (Result) -> Void) { + fatalError() + } +} diff --git a/Tests/WishKitTests/ProjectSettingsTest.swift b/Tests/WishKitTests/ProjectSettingsTest.swift new file mode 100644 index 00000000..cf7f12e7 --- /dev/null +++ b/Tests/WishKitTests/ProjectSettingsTest.swift @@ -0,0 +1,19 @@ +// +// ProjectSettingsTest.swift +// wishkit-ios +// +// Created by Martin Lasek on 4/14/24. +// Copyright © 2024 Martin Lasek. All rights reserved. +// + +import XCTest + +@testable import WishKit + +class ProjectSettingsTest: XCTestCase { + + func testProjectSettings() { + XCTAssertEqual(ProjectSettings.apiUrl, "https://www.wishkit.io/api") + } +} + diff --git a/Tests/WishKitTests/UUIDManagerTest.swift b/Tests/WishKitTests/UUIDManagerTest.swift new file mode 100644 index 00000000..35e1eb91 --- /dev/null +++ b/Tests/WishKitTests/UUIDManagerTest.swift @@ -0,0 +1,36 @@ +// +// UUIDManagerTest.swift +// wishkit-ios +// +// Created by Martin Lasek on 4/15/24. +// Copyright © 2024 Martin Lasek. All rights reserved. +// + +import XCTest + +@testable import WishKit + +class UUIDManagerTest: XCTestCase { + + func testGetUUID() { + let uuid = UUIDManager.getUUID() + + XCTAssertNotNil(uuid) + + let uuidAgain = UUIDManager.getUUID() + + XCTAssertEqual(uuid, uuidAgain) + } + + func testDeleteUUID() { + let uuid = UUIDManager.getUUID() + + XCTAssertNotNil(uuid) + + UUIDManager.deleteUUID() + + let uuidNew = UUIDManager.getUUID() + + XCTAssertNotEqual(uuid, uuidNew) + } +} diff --git a/Tests/WishKitTests/WishKitTest.swift b/Tests/WishKitTests/WishKitTest.swift new file mode 100644 index 00000000..cdcd0edf --- /dev/null +++ b/Tests/WishKitTests/WishKitTest.swift @@ -0,0 +1,63 @@ +// +// WishKitTest.swift +// wishkit-ios +// +// Created by Martin Lasek on 2/9/23. +// + +import XCTest + +@testable import WishKit + +class WishKitTest: XCTestCase { + + func testApiKeyConfiguration() { + let apiKey = "642EF81A-6763-490C-904B-DDAA588B0B23" + + XCTAssertNotEqual(WishKit.apiKey, apiKey) + + WishKit.configure(with: apiKey) + + XCTAssertEqual(WishKit.apiKey, apiKey) + } + + func testUpdateUserCustomID() { + let customID = "23" + + XCTAssertNil(WishKit.user.customID) + + WishKit.updateUser(customID: customID) + + XCTAssertEqual(WishKit.user.customID, customID) + } + + func testUpdateUserEmail() { + let email = "hello@world.com" + + XCTAssertNil(WishKit.user.email) + + WishKit.updateUser(email: email) + + XCTAssertEqual(WishKit.user.email, email) + } + + func testUpdateUserName() { + let name = "Martin Lasek" + + XCTAssertNil(WishKit.user.name) + + WishKit.updateUser(name: name) + + XCTAssertEqual(WishKit.user.name, name) + } + + func testUpdateUserPayment() { + let payment: Payment = .monthly(23) + + XCTAssertNil(WishKit.user.payment) + + WishKit.updateUser(payment: payment) + + XCTAssertEqual(WishKit.user.payment?.amount, payment.amount) + } +} diff --git a/Tests/WishKitTests/WishModelTest.swift b/Tests/WishKitTests/WishModelTest.swift new file mode 100644 index 00000000..aacb8c74 --- /dev/null +++ b/Tests/WishKitTests/WishModelTest.swift @@ -0,0 +1,50 @@ +// +// WishModelTest.swift +// wishkit-ios +// +// Created by Martin Lasek on 4/15/24. +// Copyright © 2024 Martin Lasek. All rights reserved. +// + +import XCTest + +@testable import WishKit + +class WishModelTest: XCTestCase { + + let wishModel = WishModel(wishApi: MockWishApi()) + + func testApprovedWishList() { + XCTAssertTrue(wishModel.approvedWishlist.isEmpty) + } + + func testImplementedWishList() { + XCTAssertTrue(wishModel.implementedWishlist.isEmpty) + } + + func testFetchWishList() async throws { + + // Uses mock data. Zero delay. Just potentially slower thread than assert. + await wishModel.fetchList() + + try await Task.sleep(for: .seconds(0.2)) + + XCTAssertEqual(wishModel.approvedWishlist.count, 1) + XCTAssertEqual(wishModel.implementedWishlist.count, 1) + } + + func testFilteringCorrectWishesIntoApprovedAndImplementedLists() async throws { + + // Uses mock data. Zero delay. Just potentially slower thread than assert. + await wishModel.fetchList() + + try await Task.sleep(for: .seconds(0.2)) + + let approvedWish = wishModel.approvedWishlist[0] + XCTAssertEqual(approvedWish.title, MockData.approvedWish.title) + + let implementedWish = wishModel.implementedWishlist[0] + XCTAssertEqual(implementedWish.title, MockData.implementedWish.title) + } +} +