diff --git a/APIClient/APIClient.swift b/APIClient/APIClient.swift index 7c48de1..410b5d8 100644 --- a/APIClient/APIClient.swift +++ b/APIClient/APIClient.swift @@ -72,7 +72,7 @@ final class APIClient: ObservableObject { /// The end of the currently displayed time window. If nil, defaults to date() @Published var timeWindowEnd: Date? - @Published var user: DTOv1.UserDTO? + @Published var user: UserInfoDTO? @Published var userNotLoggedIn: Bool = true @Published var userLoginFailed: Bool = false var userLoginErrorMessage: String? @@ -197,12 +197,12 @@ extension APIClient { } } - func getUserInformation(callback: ((Result) -> Void)? = nil) { + func getUserInformation(callback: ((Result) -> Void)? = nil) { userLoginFailed = false - let url = urlForPath("users", "me") + let url = urlForPath(apiVersion: .v3, "users", "info") - get(url) { (result: Result) in + get(url) { (result: Result) in switch result { case let .success(userDTO): #if canImport(TelemetryClient) @@ -228,41 +228,6 @@ extension APIClient { } } - func updatePassword(with passwordChangeRequest: PasswordChangeRequestBody, callback: ((Result) -> Void)? = nil) { - let url = urlForPath("users", "updatePassword") - - post(passwordChangeRequest, to: url) { [unowned self] (result: Result) in - switch result { - case let .success(userDTO): - DispatchQueue.main.async { - self.user = userDTO - self.logout() - } - case let .failure(error): - self.handleError(error) - } - - callback?(result) - } - } - - func updateUser(with dto: DTOv1.UserDTO, callback: ((Result) -> Void)? = nil) { - let url = urlForPath("users", "updateUser") - - post(dto, to: url) { [unowned self] (result: Result) in - switch result { - case let .success(userDTO): - DispatchQueue.main.async { - self.user = userDTO - } - case let .failure(error): - self.handleError(error) - } - - callback?(result) - } - } - func getOrganizationAdminEntries(callback: ((Result<[OrganizationAdminListEntry], TransferError>) -> Void)? = nil) { let url = urlForPath("organizationadmin") diff --git a/APIClient/DTOs/AppInfo.swift b/APIClient/DTOs/AppInfo.swift new file mode 100644 index 0000000..eba1847 --- /dev/null +++ b/APIClient/DTOs/AppInfo.swift @@ -0,0 +1,24 @@ +// +// AppInfo.swift +// Telemetry Viewer (iOS) +// +// Created by Lukas on 21.05.24. +// + +import Foundation +import DataTransferObjects +import SwiftUI + +public struct AppInfo: Codable, Hashable, Identifiable { + public var id: UUID + public var name: String + public var organizationID: UUID + public var insightGroups: [InsightGroupInfo] + public var settings: DTOv2.AppSettings + public var insightGroupIDs: [UUID] { + insightGroups.map { group in + group.id + } + } + +} diff --git a/APIClient/DTOs/InsightGroupInfo.swift b/APIClient/DTOs/InsightGroupInfo.swift new file mode 100644 index 0000000..b38a1cb --- /dev/null +++ b/APIClient/DTOs/InsightGroupInfo.swift @@ -0,0 +1,31 @@ +// +// InsightGroupInfo.swift +// Telemetry Viewer (iOS) +// +// Created by Lukas on 21.05.24. +// + +import Foundation + +public struct InsightGroupInfo: Codable, Hashable, Identifiable { + public init(id: UUID, title: String, order: Double? = nil, appID: UUID, insights: [InsightInfo]) { + self.id = id + self.title = title + self.order = order + self.appID = appID + self.insights = insights + } + + public var id: UUID + public var title: String + public var order: Double? + public var appID: UUID + public var insights: [InsightInfo] + + public var insightIDs: [UUID] { + insights.map { insight in + insight.id + } + } + +} diff --git a/APIClient/DTOs/InsightInfo.swift b/APIClient/DTOs/InsightInfo.swift new file mode 100644 index 0000000..5066a46 --- /dev/null +++ b/APIClient/DTOs/InsightInfo.swift @@ -0,0 +1,94 @@ +// +// InsightInfo.swift +// Telemetry Viewer (iOS) +// +// Created by Lukas on 21.05.24. +// + +import Foundation +import DataTransferObjects + +public struct InsightInfo: Codable, Hashable, Identifiable { + public enum InsightType: String, Codable, Hashable { + case timeseries + case topN + case customQuery + case funnel + case experiment + } + + public var id: UUID + public var groupID: UUID + + /// order in which insights appear in the apps (if not expanded) + public var order: Double? + public var title: String + + /// What kind of insight is this? + public var type: InsightType + + /// If set, display the chart with this accent color, otherwise fall back to default color + public var accentColor: String? + + /// If set, use the custom query in this property instead of constructing a query out of the options below + public var customQuery: CustomQuery? + + /// Which signal types are we interested in? If nil, do not filter by signal type + public var signalType: String? + + /// If true, only include at the newest signal from each user + public var uniqueUser: Bool + + /// If set, break down the values in this key + public var breakdownKey: String? + + /// If set, group and count found signals by this time interval. Incompatible with breakdownKey + public var groupBy: QueryGranularity? + + /// How should this insight's data be displayed? + public var displayMode: InsightDisplayMode + + /// If true, the insight will be displayed bigger + public var isExpanded: Bool + + /// The amount of time (in seconds) this query took to calculate last time + public var lastRunTime: TimeInterval? + + /// The date this query was last run + public var lastRunAt: Date? + + public init( + id: UUID, + groupID: UUID, + order: Double?, + title: String, + type: InsightType, + accentColor: String? = nil, + widgetable _: Bool? = false, + customQuery: CustomQuery? = nil, + signalType: String?, + uniqueUser: Bool, + breakdownKey: String?, + groupBy: QueryGranularity?, + displayMode: InsightDisplayMode, + isExpanded: Bool, + lastRunTime: TimeInterval?, + lastRunAt: Date? + ) { + self.id = id + self.groupID = groupID + self.order = order + self.title = title + self.type = type + self.accentColor = accentColor + self.customQuery = customQuery + self.signalType = signalType + self.uniqueUser = uniqueUser + self.breakdownKey = breakdownKey + self.groupBy = groupBy + self.displayMode = displayMode + self.isExpanded = isExpanded + self.lastRunTime = lastRunTime + self.lastRunAt = lastRunAt + } +} diff --git a/APIClient/DTOs/UserInfo.swift b/APIClient/DTOs/UserInfo.swift new file mode 100644 index 0000000..a3e33fb --- /dev/null +++ b/APIClient/DTOs/UserInfo.swift @@ -0,0 +1,20 @@ +// +// UserInfo.swift +// Telemetry Viewer (iOS) +// +// Created by Lukas on 21.05.24. +// + +import Foundation +import DataTransferObjects + +struct UserInfoDTO: Identifiable, Codable { + public let id: UUID + public var firstName: String + public var lastName: String + public var email: String + public let emailIsVerified: Bool + public var receiveMarketingEmails: Bool? + public var receiveReports: ReportSendingRate + +} diff --git a/Services/AppService.swift b/Services/AppService.swift index 6dcd11f..896be1a 100644 --- a/Services/AppService.swift +++ b/Services/AppService.swift @@ -18,8 +18,8 @@ class AppService: ObservableObject { var loadingCancellable: AnyCancellable? - @Published var appDictionary: [DTOv2.App.ID: DTOv2.App] = [:] - @Published var loadingStateDictionary: [DTOv2.App.ID: LoadingState] = [:] + @Published var appDictionary: [AppInfo.ID: AppInfo] = [:] + @Published var loadingStateDictionary: [AppInfo.ID: LoadingState] = [:] init(api: APIClient, errors: ErrorService, orgService: OrgService) { self.api = api @@ -46,23 +46,23 @@ class AppService: ObservableObject { return loadingState } - func app(withID appID: DTOv2.App.ID) -> DTOv2.App? { + func app(withID appID: AppInfo.ID) -> AppInfo? { return appDictionary[appID] } func retrieveApp(with appID: DTOv2.App.ID, callback: ((Result) -> Void)? = nil) { - let url = api.urlForPath(apiVersion: .v2, "apps", appID.uuidString) + let url = api.urlForPath(apiVersion: .v3, "apps", appID.uuidString) api.get(url) { (result: Result) in callback?(result) } } - func retrieveApp(withID appID: DTOv2.App.ID) async throws -> DTOv2.App { + func retrieveApp(withID appID: DTOv2.App.ID) async throws -> AppInfo { // guard loadingStateDictionary[appID] != .loading else { let error: TransferError = .transferFailed; throw error } // loadingStateDictionary[appID] = .loading - return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let url = api.urlForPath(apiVersion: .v2, "apps", appID.uuidString) - api.get(url) { (result: Result) in + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let url = api.urlForPath(apiVersion: .v3, "apps", appID.uuidString) + api.get(url) { (result: Result) in switch result { case let .success(app): @@ -76,10 +76,10 @@ class AppService: ObservableObject { } } - func create(appNamed name: String, callback: ((Result) -> Void)? = nil) { - let url = api.urlForPath(apiVersion: .v2, "apps") + func create(appNamed name: String, callback: ((Result) -> Void)? = nil) { + let url = api.urlForPath(apiVersion: .v3, "apps") - api.post(["name": name], to: url) { [unowned self] (result: Result) in + api.post(["name": name], to: url) { [unowned self] (result: Result) in if let app = try? result.get() { appDictionary[app.id] = app @@ -90,10 +90,10 @@ class AppService: ObservableObject { } } - func update(appID: UUID, newName: String, callback: ((Result) -> Void)? = nil) { - let url = api.urlForPath(apiVersion: .v2, "apps", appID.uuidString) + func update(appID: UUID, newName: String, callback: ((Result) -> Void)? = nil) { + let url = api.urlForPath(apiVersion: .v3, "apps", appID.uuidString) - api.patch(["name": newName], to: url) { [unowned self] (result: Result) in + api.patch(["name": newName], to: url) { [unowned self] (result: Result) in if let app = try? result.get() { appDictionary[app.id] = app diff --git a/Services/GroupService.swift b/Services/GroupService.swift index ac33176..8fb25db 100644 --- a/Services/GroupService.swift +++ b/Services/GroupService.swift @@ -13,11 +13,11 @@ class GroupService: ObservableObject { private let api: APIClient private let errorService: ErrorService - private let loadingState = Cache() + private let loadingState = Cache() var loadingCancellable: AnyCancellable? - @Published var groupsDictionary = [DTOv2.Group.ID: DTOv2.Group]() + @Published var groupsDictionary = [InsightGroupInfo.ID: InsightGroupInfo]() init(api: APIClient, errors: ErrorService) { self.api = api @@ -43,26 +43,26 @@ class GroupService: ObservableObject { return loadingState } - func group(withID groupID: DTOv2.Group.ID) -> DTOv2.Group? { + func group(withID groupID: InsightGroupInfo.ID) -> InsightGroupInfo? { return groupsDictionary[groupID] } - func retrieveGroup(with groupID: DTOv2.Group.ID) { + func retrieveGroup(with groupID: InsightGroupInfo.ID) { performRetrieval(ofGroupWithID: groupID) } - func create(insightGroupNamed: String, for appID: UUID, callback: ((Result) -> Void)? = nil) { - let url = api.urlForPath(apiVersion: .v2, "groups") + func create(insightGroupNamed: String, for appID: UUID, callback: ((Result) -> Void)? = nil) { + let url = api.urlForPath(apiVersion: .v3, "groups") - api.post(["title": insightGroupNamed, "appID": appID.uuidString], to: url) { (result: Result) in + api.post(["title": insightGroupNamed, "appID": appID.uuidString], to: url) { (result: Result) in callback?(result) } } - func update(insightGroup: DTOv2.Group, in appID: UUID, callback: ((Result) -> Void)? = nil) { - let url = api.urlForPath(apiVersion: .v2, "groups", insightGroup.id.uuidString) + func update(insightGroup: InsightGroupInfo, in appID: UUID, callback: ((Result) -> Void)? = nil) { + let url = api.urlForPath(apiVersion: .v3, "groups", insightGroup.id.uuidString) - api.patch(insightGroup, to: url) { (result: Result) in + api.patch(insightGroup, to: url) { (result: Result) in callback?(result) } } @@ -78,7 +78,7 @@ class GroupService: ObservableObject { } private extension GroupService { - func performRetrieval(ofGroupWithID groupID: DTOv2.Group.ID) { + func performRetrieval(ofGroupWithID groupID: InsightGroupInfo.ID) { switch loadingState(for: groupID) { case .loading, .error: return @@ -88,9 +88,9 @@ private extension GroupService { loadingState[groupID] = .loading - let url = api.urlForPath(apiVersion: .v2, "groups", groupID.uuidString) + let url = api.urlForPath(apiVersion: .v3, "groups", groupID.uuidString) - api.get(url) { [weak self] (result: Result) in + api.get(url) { [weak self] (result: Result) in switch result { case let .success(group): diff --git a/Shared/App/AppEditor.swift b/Shared/App/AppEditor.swift deleted file mode 100644 index 4f6f999..0000000 --- a/Shared/App/AppEditor.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// EditAppView.swift -// Telemetry Viewer -// -// Created by Daniel Jilg on 10.09.20. -// - -import SwiftUI -import TelemetryClient - -struct AppEditor: View { - @EnvironmentObject var appService: AppService - @EnvironmentObject var iconFinderService: IconFinderService - - let appID: UUID - - @State var appName: String - @State private var showingAlert = false - @State private var needsSaving = false - - @State var appIconURL: URL? - - func saveToAPI() { - appService.update(appID: appID, newName: appName) - } - - func setNeedsSaving() { - withAnimation { - needsSaving = true - } - } - - func getIconURL() { - iconFinderService.findIcon(forAppName: appName) { appIconURL = $0 } - } - - var padding: CGFloat? { - #if os(macOS) - return nil - #else - return 0 - #endif - } - - var body: some View { - if let app = appService.app(withID: appID) { - Form { -// if #available(iOS 15, macOS 12, *) { -// appIconURL.map { -// AsyncImage(url: $0) { image in -// image.resizable() -// } placeholder: { -// ProgressView() -// } -// .frame(width: 50, height: 50) -// .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) -// } -// } -// -// Button("Get Icon") { -// getIconURL() -// } - - CustomSection(header: Text("App Name"), summary: EmptyView(), footer: EmptyView()) { - TextField("App Name", text: $appName, onEditingChanged: { _ in setNeedsSaving() }) { setNeedsSaving() } - - if needsSaving { - Button("Save") { - saveToAPI() - } - } - } - - CustomSection(header: Text("Unique Identifier"), summary: EmptyView(), footer: EmptyView()) { - VStack(alignment: .leading) { - Button(app.id.uuidString) { - saveToClipBoard(app.id.uuidString) - } - .buttonStyle(SmallPrimaryButtonStyle()) - #if os(macOS) - Text("Click to copy this UUID into your apps for tracking.").font(.footnote) - #else - Text("Tap to copy this UUID into your apps for tracking.").font(.footnote) - #endif - } - } - - CustomSection(header: Text("Delete"), summary: EmptyView(), footer: EmptyView()) { - Button("Delete App \"\(app.name)\"") { - showingAlert = true - } - .buttonStyle(SmallSecondaryButtonStyle()) - .accentColor(.red) - } - - #if os(macOS) - Spacer() - #endif - } - .padding(.horizontal, self.padding) - .padding(.vertical, self.padding) - .navigationTitle("App Settings") - .onDisappear { saveToAPI() } - .alert(isPresented: $showingAlert) { - Alert( - title: Text("Are you sure you want to delete \(app.name)?"), - message: Text("This will delete the app, all insights, and all received Signals for this app. There is no undo."), - primaryButton: .destructive(Text("Delete")) { - appService.delete(appID: appID) - }, - secondaryButton: .cancel() - ) - } - - } else { - Text("No App Selected") - } - } -} - -// struct AppEditor_Previews: PreviewProvider { -// static var previews: some View { -// AppEditor(appID: UUID.empty, appName: "test") -// .environmentObject(AppService(api: APIClient(), errors: ErrorService(), orgService: OrgService())) -// } -// } diff --git a/Shared/App/CreateNewAppViewModel.swift b/Shared/App/CreateNewAppViewModel.swift deleted file mode 100644 index c6a710d..0000000 --- a/Shared/App/CreateNewAppViewModel.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// CreateNewAppViewModel.swift -// Telemetry Viewer -// -// Created by Daniel Jilg on 10.12.21. -// - -import Foundation -import DataTransferObjects -import SwiftUI - -class CreateNewAppViewModel: ObservableObject { - let api: APIClient - let orgService: OrgService - let appService: AppService - - @Published var appName: String = "New App" - @Published var existingApps: [DTOv2.App] = [] - @Published var createDefaultInsights: Bool = true - @Published var createdApp: DTOv2.App? - - @Binding var newAppViewShown: Bool - - public init(api: APIClient, appService: AppService, orgService: OrgService, newAppViewShown: Binding) { - self.api = api - self.orgService = orgService - self.appService = appService - _newAppViewShown = newAppViewShown - existingApps = appsFromAppIDs() - } - - public var isValid: AppCreationValidationState { - if appName.isEmpty { - return .nameEmpty - } - - if (existingApps.filter { - $0.name.lowercased() == appName.lowercased() - } != []) { - return .nameAlreadyUsed - } - - if appName == "New App" { - return .nameNewApp - } - - return .valid - } - - func appsFromAppIDs() -> [DTOv2.App] { - var apps: [DTOv2.App] = [] - guard orgService.organization != nil else { return [] } - for appID in orgService.organization!.appIDs { - guard appService.app(withID: appID) != nil else { continue } - apps.append(appService.app(withID: appID)!) - } - return apps - } - - func createNewApp() { - appService.create(appNamed: appName) { (result: Result) in - switch result { - case let .failure(error): - print(error) - case let .success(newApp): - if self.createDefaultInsights { - let url = self.api.urlForPath(apiVersion: .v2, "apps", newApp.id.uuidString, "createDefaultInsights") - - self.api.post("", to: url, defaultValue: nil) { (_: Result<[String: String], TransferError>) in - DispatchQueue.main.async { - self.createdApp = newApp - } - } - } else { - DispatchQueue.main.async { - self.createdApp = newApp - } - } - } - } - } - - enum AppCreationValidationState { - case valid - case nameEmpty - case nameAlreadyUsed - case nameNewApp - - var string: String? { - switch self { - case .valid: - return nil - case .nameEmpty: - return "Please fill out the app name field." - case .nameAlreadyUsed: - return "You already have an app with that name. Are you sure you want to create another app with the same name? (You can change the app name later.)" - case .nameNewApp: - return "You did not change the app name yet. Do you really want to name your app 'New App'? (You can change the app name later.)" - } - } - } -} diff --git a/Shared/Empty Status Views/EmptyAppView.swift b/Shared/Empty Status Views/EmptyAppView.swift index 3c8670e..e917f37 100644 --- a/Shared/Empty Status Views/EmptyAppView.swift +++ b/Shared/Empty Status Views/EmptyAppView.swift @@ -12,7 +12,7 @@ struct EmptyAppView: View { @EnvironmentObject var appService: AppService let appID: UUID - private var app: DTOv2.App? { appService.app(withID: appID) } + private var app: AppInfo? { appService.app(withID: appID) } var body: some View { VStack(spacing: 20) { diff --git a/Shared/Insight and Groups/InsightGroupEditor.swift b/Shared/Insight and Groups/InsightGroupEditor.swift deleted file mode 100644 index ee5b855..0000000 --- a/Shared/Insight and Groups/InsightGroupEditor.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// InsightGroupEditor.swift -// Telemetry Viewer -// -// Created by Daniel Jilg on 05.03.21. -// - -import SwiftUI -import TelemetryClient -import DataTransferObjects - -struct InsightGroupEditor: View { - @Environment(\.presentationMode) var presentationMode - - @EnvironmentObject var api: APIClient - @EnvironmentObject var insightService: InsightService - @EnvironmentObject var groupService: GroupService - @EnvironmentObject var appService: AppService - - let appID: UUID - let id: UUID - - @State var title: String - @State var order: Double - - @State private var showingAlert = false - - init(groupID: DTOv2.Group.ID, appID: DTOv2.App.ID, title: String, order: Double) { - self.id = groupID - self.appID = appID - self._title = State(initialValue: title) - self._order = State(initialValue: order) - self._showingAlert = State(initialValue: false) - } - - func save() { - groupService.update(insightGroup: DTOv2.Group(id: id, title: title, order: order, appID: appID, insightIDs: []), in: appID) { _ in - groupService.retrieveGroup(with: self.id) - } - } - - func delete() { - groupService.delete(insightGroupID: id, in: appID) { _ in - #if os(iOS) - presentationMode.wrappedValue.dismiss() - #endif - - Task { - if let app = try? await appService.retrieveApp(withID: appID) { - DispatchQueue.main.async { - appService.appDictionary[appID] = app - appService.app(withID: appID)?.insightGroupIDs.forEach({ groupID in - groupService.retrieveGroup(with: groupID) - }) - } - } - } - } - } - - var body: some View { - let form = Form { - CustomSection(header: Text("Insight Group Title"), summary: EmptyView(), footer: EmptyView()) { - TextField("", text: $title, onEditingChanged: { isEditing in if !isEditing { save() } }, onCommit: {}) - } - - CustomSection(header: Text("Ordering"), summary: Text(String(format: "%.0f", order)), footer: Text("Insights are ordered by this number, ascending"), startCollapsed: true) { - OrderSetter(order: $order) - .onChange(of: order) { _ in save() } - } - - CustomSection(header: Text("Delete"), summary: EmptyView(), footer: EmptyView(), startCollapsed: true) { - Button("Delete this Insight Group", action: { showingAlert = true }) - .buttonStyle(SmallSecondaryButtonStyle()) - .accentColor(.red) - } - } - .onAppear { - TelemetryManager.send("InsightGroupEditorAppear") - } - - .alert(isPresented: $showingAlert) { - Alert( - title: Text("Are you sure you want to delete the group \(title)?"), - message: Text("This will delete the Insight Group and all its Insights. Your signals are not affected."), - primaryButton: .destructive(Text("Delete")) { - delete() - - }, - secondaryButton: .cancel() - ) - } - - #if os(macOS) - ScrollView { - form - .padding() - } - #else - form - #endif - } -} diff --git a/Shared/Insight and Groups/InsightsList.swift b/Shared/Insight and Groups/InsightsList.swift index 19262a7..b7f0091 100644 --- a/Shared/Insight and Groups/InsightsList.swift +++ b/Shared/Insight and Groups/InsightsList.swift @@ -12,7 +12,7 @@ struct InsightsList: View { let groupID: DTOv2.Group.ID let isSelectable: Bool - @Binding var selectedInsightID: DTOv2.Insight.ID? + @Binding var selectedInsightID: InsightGroupInfo.ID? @Binding var sidebarVisible: Bool @EnvironmentObject var groupService: GroupService diff --git a/Shared/Login + Register/AskForMarketingEmailsView.swift b/Shared/Login + Register/AskForMarketingEmailsView.swift deleted file mode 100644 index 9dd0344..0000000 --- a/Shared/Login + Register/AskForMarketingEmailsView.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// AskForMarketingEmailsView.swift -// Telemetry Viewer -// -// Created by Daniel Jilg on 16.05.21. -// - -import SwiftUI - -struct AskForMarketingEmailsView: View { - @EnvironmentObject var api: APIClient - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - Text("Hi, quick question!") - .font(.title) - - Text("There will be a free ") + Text("TelemetryDeck newsletter").bold() + Text(" soon with contents like:") - - Label("Tips on how to generate good data with signals", systemImage: "wand.and.stars") - Label("News, new features and best practices regarding TelemetryDeck", systemImage: "flowchart") - Label("Articles about TelemetryDeck's features and how to get the most out of them", systemImage: "chart.pie.fill") - Label("Tutorials on how to improve your revenue and engagement without resorting to dark patterns", systemImage: "chart.bar.xaxis") - - Text("Do you want the newsletter? It's free, low-volume, and you can unsubscribe at any time in the app settings.") - - Button("Send me the Newletter") { - guard let user = api.user else { return } - var userDTO = user - userDTO.receiveMarketingEmails = true - api.updateUser(with: userDTO) - api.needsDecisionForMarketingEmails = false - - } - .buttonStyle(PrimaryButtonStyle()) - - Button("Don't send me the Newsletter") { - guard let user = api.user else { return } - var userDTO = user - userDTO.receiveMarketingEmails = false - api.updateUser(with: userDTO) - api.needsDecisionForMarketingEmails = false - } - .buttonStyle(SecondaryButtonStyle()) - - Group { - Text("If you change your mind, go to the app settings to switch the newsletter on or off.") - .font(.footnote) - .foregroundColor(.grayColor) - - Text("And this is the only time I'm bothering you about this, promise!") - .font(.footnote) - .foregroundColor(.grayColor) - } - } - .frame(maxWidth: 400, minHeight: 500) - .fixedSize(horizontal: false, vertical: true) - .padding() - } - } -} - -struct AskForMarketingEmailsView_Previews: PreviewProvider { - static var previews: some View { - AskForMarketingEmailsView() - } -} diff --git a/Shared/Navigational Structure/LeftSidebarView.swift b/Shared/Navigational Structure/LeftSidebarView.swift index b272949..4859153 100644 --- a/Shared/Navigational Structure/LeftSidebarView.swift +++ b/Shared/Navigational Structure/LeftSidebarView.swift @@ -14,7 +14,6 @@ struct LeftSidebarView: View { @EnvironmentObject var appService: AppService @EnvironmentObject var groupService: GroupService @EnvironmentObject var insightService: InsightService - @State var newAppViewShown: Bool = false @State private var showingAlert = false #if os(macOS) @@ -59,20 +58,6 @@ struct LeftSidebarView: View { } } } - Button { - newAppViewShown = true - } label: { - Label("Add a new app", systemImage: "plus.square.dashed") - } - .sheet(isPresented: $newAppViewShown) { - #if os(macOS) - CreateNewAppView(createNewAppViewModel: .init(api: api, appService: appService, orgService: orgService, newAppViewShown: $newAppViewShown)) - #else - NavigationView { - CreateNewAppView(createNewAppViewModel: .init(api: api, appService: appService, orgService: orgService, newAppViewShown: $newAppViewShown)) - } - #endif - } } header: { Text("Apps") @@ -202,18 +187,10 @@ struct LeftSidebarView: View { } .tag(Selection.recentSignals(app: appID)) - NavigationLink(tag: Selection.editApp(app: app.id), selection: $sidebarSelection) { - AppEditor(appID: app.id, appName: app.name) - } label: { - Label("Edit App", systemImage: "square.and.pencil") - } - .tag(Selection.editApp(app: appID)) - } else { TinyLoadingStateIndicator(loadingState: appService.loadingStateDictionary[appID] ?? .idle, title: "Insights") TinyLoadingStateIndicator(loadingState: appService.loadingStateDictionary[appID] ?? .idle, title: "Signal Types") TinyLoadingStateIndicator(loadingState: appService.loadingStateDictionary[appID] ?? .idle, title: "Recent Signals") - TinyLoadingStateIndicator(loadingState: appService.loadingStateDictionary[appID] ?? .idle, title: "Edit App") } } label: { LabelLoadingStateIndicator(loadingState: appService.loadingStateDictionary[appID] ?? .idle, title: appService.appDictionary[appID]?.name, systemImage: "sensor.tag.radiowaves.forward") diff --git a/Shared/Settings/CreateOrganizationJoinRequestView.swift b/Shared/Settings/CreateOrganizationJoinRequestView.swift deleted file mode 100644 index 895abf2..0000000 --- a/Shared/Settings/CreateOrganizationJoinRequestView.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// CreateOrganizationJoinRequestView.swift -// Telemetry Viewer -// -// Created by Daniel Jilg on 11.11.20. -// - -import SwiftUI - -struct CreateOrganizationJoinRequestView: View { - @EnvironmentObject var api: APIClient - @Environment(\.presentationMode) var presentationMode - - @State var inviteeEmail: String = "" - - private var isValidEmail: Bool { - if inviteeEmail.count < 3 { return false } - if !inviteeEmail.contains("@") { return false } - if !inviteeEmail.contains(".") { return false } - - return true - } - - var body: some View { - VStack(alignment: .leading) { - Text("Invite People to join \(api.user?.organization?.name ?? "your organization")") - .font(.title) - - Text("Please enter your collaborator's email address here. We'll send them an email with an invitation code and instructions on how to download the app.") - - Spacer() - - #if os(iOS) - TextField("Email", text: $inviteeEmail) - .textContentType(.emailAddress) - .autocapitalization(.none) - .disableAutocorrection(true) - #else - TextField("Email", text: $inviteeEmail) - .padding() - #endif - - Spacer() - - Button("Send Email") { - api.createOrganizationJoinRequest(for: inviteeEmail) { _ in - self.presentationMode.wrappedValue.dismiss() - } - } - .keyboardShortcut(.defaultAction) - .disabled(!isValidEmail) - .saturation(isValidEmail ? 1 : 0) - .animation(.easeOut) - .buttonStyle(PrimaryButtonStyle()) - - if !isValidEmail { - Text("Please enter a valid email address") - .font(.footnote) - .foregroundColor(.grayColor) - .animation(.easeOut) - } - - Button("Cancel") { - self.presentationMode.wrappedValue.dismiss() - } - .buttonStyle(SmallSecondaryButtonStyle()) - } - .padding() - .frame(maxWidth: 400, minHeight: 400) - } -} - -struct CreateOrganizationJoinRequestView_Previews: PreviewProvider { - static var previews: some View { - CreateOrganizationJoinRequestView() - .environmentObject(APIClient()) - } -} diff --git a/Shared/Settings/OrganizationSettingsView.swift b/Shared/Settings/OrganizationSettingsView.swift deleted file mode 100644 index 63ae0de..0000000 --- a/Shared/Settings/OrganizationSettingsView.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// OrganizationSettingsView.swift -// Telemetry Viewer -// -// Created by Daniel Jilg on 07.09.20. -// - -import SwiftUI -import TelemetryClient -import DataTransferObjects - -struct UserInfoView: View { - var user: DTOv1.UserDTO - - var body: some View { - VStack { - Text(user.firstName) - Text(user.lastName) - Text(user.email) - Text(user.isFoundingUser ? "Founding User" : "") - } - } -} - -struct OrganizationSettingsView: View { - @EnvironmentObject var api: APIClient - - #if os(iOS) - @Environment(\.horizontalSizeClass) var sizeClass - #else - enum SizeClassNoop { - case compact - case notCompact - } - - var sizeClass: SizeClassNoop = .notCompact - #endif - - @State private var showingSheet = false - @State private var isLoadingOrganizationJoinRequests: Bool = false - @State private var isLoadingOrganizationUsers: Bool = false - - var body: some View { - VStack { - List { - Section(header: Text("Organization Users")) { - ForEach(api.organizationUsers) { organizationUser in - NavigationLink(destination: UserInfoView(user: organizationUser)) { - HStack { - Text(organizationUser.firstName) - Text(organizationUser.lastName) - Text(organizationUser.email) - Spacer() - Text(organizationUser.isFoundingUser ? "Founding User" : "") - } - } - } - } - - Section(header: Text("Join Requests")) { - ForEach(api.organizationJoinRequests) { joinRequest in - - ListItemView { - Text(joinRequest.email) - Spacer() - Text(joinRequest.registrationToken) - - Button(action: { - api.delete(organizationJoinRequest: joinRequest) - }, label: { - Image(systemName: "minus.circle.fill") - }) - .buttonStyle(IconButtonStyle()) - } - } - - Button("Create an Invitation to Join \(api.user?.organization?.name ?? "noorg")") { - showingSheet.toggle() - } - .buttonStyle(SmallSecondaryButtonStyle()) - } - } - } - .navigationTitle(api.user?.organization?.name ?? "Organization") - .onAppear { - TelemetryManager.shared.send(TelemetrySignal.organizationSettingsShown.rawValue, for: api.user?.email) - isLoadingOrganizationUsers = true - api.getOrganizationUsers { _ in - isLoadingOrganizationUsers = false - } - - isLoadingOrganizationJoinRequests = true - api.getOrganizationJoinRequests { _ in - isLoadingOrganizationJoinRequests = false - } - } - .sheet(isPresented: $showingSheet) { - CreateOrganizationJoinRequestView() - } - } -} - -struct OrganizationSettingsView_Previews: PreviewProvider { - static var previews: some View { - OrganizationSettingsView().environmentObject(APIClient()) - } -} diff --git a/iOS/CreateNewAppView.swift b/iOS/CreateNewAppView.swift deleted file mode 100644 index e647c10..0000000 --- a/iOS/CreateNewAppView.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// CreateNewAppView.swift -// Telemetry Viewer -// -// Created by Charlotte Böhm on 01.12.21. -// - -import DataTransferObjects -import SwiftUI - -struct CreateNewAppView: View { - @StateObject var createNewAppViewModel: CreateNewAppViewModel - - var sondrine: some View { - Image("sidebarBackground") - .resizable() - .scaledToFit() - .listRowBackground(Color.clear) - } - - var createDefaultAppsSection: some View { - Section { - Toggle(isOn: $createNewAppViewModel.createDefaultInsights) { - Text("Add Default Insights") - } - } footer: { - Text("We'll automatically create a number of Groups and Insights for you, that will fit most apps. You can change or delete these later, but they're usually a good starting point.") - } - } - - var nameSection: some View { - Section { - TextField("New App Name", text: $createNewAppViewModel.appName) - } footer: { - Text(createNewAppViewModel.isValid.string ?? "") - .foregroundColor(.grayColor) - } - .navigationTitle("Create a new App") - .toolbar { - ToolbarItemGroup(placement: .cancellationAction) { - Button("Cancel") { createNewAppViewModel.newAppViewShown = false } - .keyboardShortcut(.cancelAction) - } - - ToolbarItemGroup(placement: .confirmationAction) { - Button("Create App") { createNewAppViewModel.createNewApp() } - .keyboardShortcut(.defaultAction) - .disabled(createNewAppViewModel.isValid == .nameEmpty) - .help("Create App") - } - } - } - - func copyAppIDSection(newApp: DTOv2.App) -> some View { - Section { - Button(newApp.id.uuidString) { - saveToClipBoard(newApp.id.uuidString) - } - } footer: { - Text("Tap to copy this UUID into your apps for tracking. You can look up this ID later by going to the app settings.") - } - } - - func documentationSection(newApp: DTOv2.App) -> some View { - Section { - Button("Open Documentation") { - URL(string: "https://telemetrydeck.com/pages/quickstart.html")?.open() - } - } footer: { - VStack { - Text("We've created your new app.") - if createNewAppViewModel.createDefaultInsights { - Text("We also created a number of default Insights and Groups for you.") - } else { - Text("You'll be able to create new Groups and Insights by navigating to the new app and using the buttons in the toolbar.") - } - Text("The next step is implementing the TelemetryDeck SDK. Hit the Documentation button to read more.") - } - } - .navigationTitle("App Created") - .toolbar { - ToolbarItemGroup(placement: .confirmationAction) { - Button("Dismiss") { createNewAppViewModel.newAppViewShown = false } - .keyboardShortcut(.defaultAction) - } - } - } - - var body: some View { - Form { - if let newApp = createNewAppViewModel.createdApp { - documentationSection(newApp: newApp) - copyAppIDSection(newApp: newApp) - } else { - sondrine - nameSection - createDefaultAppsSection - } - } - } -} diff --git a/iOS/EditorModeView.swift b/iOS/EditorModeView.swift deleted file mode 100644 index a7e1c31..0000000 --- a/iOS/EditorModeView.swift +++ /dev/null @@ -1,157 +0,0 @@ -// -// EditorModeView.swift -// EditorModeView -// -// Created by Daniel Jilg on 08.10.21. -// - -import DataTransferObjects -import SwiftUI - -struct EditorModeView: View { - @EnvironmentObject var appService: AppService - @EnvironmentObject var groupService: GroupService - @EnvironmentObject var insightService: InsightService - - let appID: DTOv2.App.ID - - var body: some View { - List { - ForEach(appService.app(withID: appID)?.insightGroupIDs ?? [], id: \.self) { insightGroupID in - Section { - EditorModeGroupEditor(appID: appID, insightGroupID: insightGroupID) - } header: { - TinyLoadingStateIndicator( - loadingState: groupService.loadingState(for: insightGroupID), - title: groupService.group(withID: insightGroupID)?.title - ) - } - } - - Button("New Insight Group") { - groupService.create(insightGroupNamed: "New Insight Group", for: appID) { _ in - Task { - if let app = try? await appService.retrieveApp(withID: appID) { - DispatchQueue.main.async { - appService.appDictionary[appID] = app - appService.app(withID: appID)?.insightGroupIDs.forEach { groupID in - groupService.retrieveGroup(with: groupID) - } - } - } - } - } - } - } - .toolbar { - EditButton() - } - .navigationTitle("Edit Insights") - - } -} - -struct EditorModeGroupEditor: View { - @EnvironmentObject var appService: AppService - @EnvironmentObject var groupService: GroupService - @EnvironmentObject var insightService: InsightService - @EnvironmentObject var lexiconService: LexiconService - - @State private var showingAlert = false - - let appID: DTOv2.App.ID - let insightGroupID: DTOv2.Group.ID - - // Unused, this is just here for API compatibility with EditorView - @State var selectedInsightID: UUID? - - var body: some View { - ForEach(groupService.groupsDictionary[insightGroupID]?.insightIDs ?? [], id: \.self) { insightID in - if let insight = insightService.insightDictionary[insightID] { - linkToInsightEditor(insight: insight) - } - } - .onDelete(perform: delete) - - if let group = groupService.groupsDictionary[insightGroupID] { - linkToGroupEditor(group: group) - .foregroundColor(.accentColor) - - newInsightInGroupButton(group: group) - deleteGroupButton(group: group) - } - } - - func delete(at offsets: IndexSet) { - let ids = offsets.compactMap { groupService.group(withID: insightGroupID)?.insightIDs[$0] } - for id in ids { - insightService.delete(insightID: id) { _ in - groupService.retrieveGroup(with: insightGroupID) - } - } - } - - func linkToInsightEditor(insight: DTOv2.Insight) -> some View { - NavigationLink(insight.title, destination: { - EditorView( - viewModel: EditorViewModel( - insight: insight, - appID: appID, - insightService: insightService, - groupService: groupService, - lexiconService: lexiconService - ), - selectedInsightID: $selectedInsightID - ) - - }) - } - - func linkToGroupEditor(group: DTOv2.Group) -> some View { - NavigationLink { - InsightGroupEditor(groupID: group.id, appID: group.appID, title: group.title, order: group.order ?? 0) - } label: { - Label("Edit \(group.title)", systemImage: "square.and.pencil") - } - } - - func newInsightInGroupButton(group: DTOv2.Group) -> some View { - NewInsightMenu(appID: appID, selectedInsightGroupID: group.id) - } - - func deleteGroupButton(group: DTOv2.Group) -> some View { - Button { - showingAlert = true - } label: { - Label("Delete \(group.title) and all its Insights", systemImage: "trash") - } - .alert(isPresented: $showingAlert) { - Alert( - title: Text("Are you sure you want to delete the group \(group.title)?"), - message: Text("This will delete the Insight Group and all its Insights. Your signals are not affected."), - primaryButton: .destructive(Text("Delete")) { - groupService.delete(insightGroupID: insightGroupID, in: appID) { _ in - Task { - if let app = try? await appService.retrieveApp(withID: appID) { - DispatchQueue.main.async { - appService.appDictionary[appID] = app - appService.app(withID: appID)?.insightGroupIDs.forEach { groupID in - groupService.retrieveGroup(with: groupID) - } - } - } - } - } - - }, - secondaryButton: .cancel() - ) - } - } -} - -struct EditorModeView_Previews: PreviewProvider { - static var previews: some View { - EditorModeView(appID: UUID.empty) - } -} diff --git a/iOS/InsightGroupsView.swift b/iOS/InsightGroupsView.swift index 6b2fc6f..f4dd1b7 100644 --- a/iOS/InsightGroupsView.swift +++ b/iOS/InsightGroupsView.swift @@ -24,7 +24,6 @@ struct InsightGroupsView: View { @State var selectedInsightGroupID: DTOv2.Group.ID? @State var selectedInsightID: DTOv2.Insight.ID? @State private var showDatePicker: Bool = false - @State private var showEditMode: Bool = false @Environment(\.horizontalSizeClass) var sizeClass @@ -66,10 +65,6 @@ struct InsightGroupsView: View { } .alwaysHideNavigationBar() - .background( - NavigationLink(destination: EditorModeView(appID: appID), isActive: $showEditMode) { - EmptyView() - }) .onAppear { selectedInsightGroupID = appService.app(withID: appID)?.insightGroupIDs.first TelemetryManager.send("InsightGroupsAppear") @@ -93,14 +88,6 @@ struct InsightGroupsView: View { } } .toolbar { - ToolbarItem { - // We're hiding all editors on iOS because we want the iOS app to be a - // Viewer App only. Remove the .hidden() modifier to show the button to - // toggle editor mode, but beware this is unsupported. - editModeButton - .hidden() - } - ToolbarItemGroup(placement: .bottomBar) { Toggle("Test Mode", isOn: $queryService.isTestingMode.animation()) datePickerButton @@ -160,14 +147,6 @@ struct InsightGroupsView: View { Label("New Group", systemImage: "plus") } } - - private var editModeButton: some View { - Button { - self.showEditMode = true - } label: { - Label("Edit Insights", systemImage: "square.and.pencil") - } - } } @available(iOS 16, *) diff --git a/iOS/InsightsGrid.swift b/iOS/InsightsGrid.swift index 7786639..caa52f3 100644 --- a/iOS/InsightsGrid.swift +++ b/iOS/InsightsGrid.swift @@ -10,10 +10,10 @@ import SwiftUI struct InsightsGrid: View { @EnvironmentObject var insightService: InsightService - @Binding var selectedInsightID: DTOv2.Insight.ID? + @Binding var selectedInsightID: InsightInfo.ID? @Binding var sidebarVisible: Bool - let insightGroup: DTOv2.Group + let insightGroup: InsightGroupInfo let isSelectable: Bool var body: some View {