From 736765180c1563af1cbe4745d41ac40e54511628 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 6 Oct 2026 08:58:01 +0100 Subject: [PATCH] phase two --- PHASE-2-IOS.md | 34 ++++++++ SonarDesk-iOS/SonarDesk-iOS.entitlements | 16 ++++ SonarDesk-iOS/SonarDeskIOSApp.swift | 15 ++++ SonarDesk-iOS/Views/IOSDashboardView.swift | 86 +++++++++++++++++++ .../Views/IOSProjectDetailView.swift | 54 ++++++++++++ SonarDesk-iOS/Views/IOSProjectsView.swift | 41 +++++++++ SonarDesk-iOS/Views/IOSRootView.swift | 54 ++++++++++++ SonarDesk-iOS/Views/IOSSettingsView.swift | 32 +++++++ 8 files changed, 332 insertions(+) create mode 100644 PHASE-2-IOS.md create mode 100644 SonarDesk-iOS/SonarDesk-iOS.entitlements create mode 100644 SonarDesk-iOS/SonarDeskIOSApp.swift create mode 100644 SonarDesk-iOS/Views/IOSDashboardView.swift create mode 100644 SonarDesk-iOS/Views/IOSProjectDetailView.swift create mode 100644 SonarDesk-iOS/Views/IOSProjectsView.swift create mode 100644 SonarDesk-iOS/Views/IOSRootView.swift create mode 100644 SonarDesk-iOS/Views/IOSSettingsView.swift diff --git a/PHASE-2-IOS.md b/PHASE-2-IOS.md new file mode 100644 index 0000000..7428d52 --- /dev/null +++ b/PHASE-2-IOS.md @@ -0,0 +1,34 @@ +# SonarDesk iOS + Team — Phase 2 + +Phase 2 adds the first native iPhone/iPad client source while leaving the existing macOS target and licensing implementation untouched. + +## Added +- Native iOS app entry point (`SonarDeskIOSApp`) +- Adaptive `NavigationSplitView` root interface +- iOS Dashboard +- Failed Quality Gate projects highlighted in red +- Project browser with search and pull-to-refresh +- Project detail metrics +- Workspace selector using the Phase 1 Personal/Team model +- iOS settings showing workspace/server and the device-local credential boundary +- Separate iOS entitlements file with the existing iCloud container and CloudKit capability + +## Shared files for the iOS target +Add these existing source groups to iOS target membership: +- `SonarDesk/Models/Models.swift` +- `SonarDesk/Services/SonarQubeClient.swift` +- `SonarDesk/Services/KeychainService.swift` +- `SonarDesk/Services/ICloudDriveStore.swift` +- `SonarDesk/Stores/AppState.swift` +- `SonarDesk/Shared/Workspaces/WorkspaceModels.swift` +- `SonarDesk/Shared/Workspaces/WorkspaceStore.swift` + +Do not add the macOS `SonarDesk/App/SonarDeskApp.swift` to the iOS target. Use `SonarDesk-iOS/SonarDeskIOSApp.swift` as the iOS app entry point. + +## Xcode target +Create an iOS App target named `SonarDesk-iOS` with bundle identifier `net.absdev.SonarDesk.iOS`, iPhone + iPad destinations, and the deployment target you want to support. Assign `SonarDesk-iOS/SonarDesk-iOS.entitlements` to that target. + +The existing macOS entitlements, licensing files, bundle identifier, and licence verification code are unchanged. + +## Phase 3 +Replace workspace JSON mirroring with a CloudKit repository, implement CKShare-backed team workspaces/invitations, member roles, and server metadata synchronization. Credentials remain in each member's Keychain. diff --git a/SonarDesk-iOS/SonarDesk-iOS.entitlements b/SonarDesk-iOS/SonarDesk-iOS.entitlements new file mode 100644 index 0000000..d497392 --- /dev/null +++ b/SonarDesk-iOS/SonarDesk-iOS.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + com.apple.security.network.client + com.apple.developer.icloud-container-identifiers + iCloud.net.absdev.SonarDesk + com.apple.developer.ubiquity-container-identifiers + iCloud.net.absdev.SonarDesk + com.apple.developer.icloud-services + CloudDocumentsCloudKit + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)net.absdev.SonarDesk + + diff --git a/SonarDesk-iOS/SonarDeskIOSApp.swift b/SonarDesk-iOS/SonarDeskIOSApp.swift new file mode 100644 index 0000000..030f1ae --- /dev/null +++ b/SonarDesk-iOS/SonarDeskIOSApp.swift @@ -0,0 +1,15 @@ +import SwiftUI + +@main +struct SonarDeskIOSApp: App { + @StateObject private var appState = AppState() + @StateObject private var workspaceStore = WorkspaceStore() + + var body: some Scene { + WindowGroup { + IOSRootView() + .environmentObject(appState) + .environmentObject(workspaceStore) + } + } +} diff --git a/SonarDesk-iOS/Views/IOSDashboardView.swift b/SonarDesk-iOS/Views/IOSDashboardView.swift new file mode 100644 index 0000000..1e4486d --- /dev/null +++ b/SonarDesk-iOS/Views/IOSDashboardView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct IOSDashboardView: View { + @EnvironmentObject private var appState: AppState + + private var failedProjects: [ProjectSnapshot] { + appState.projects.filter { $0.qualityGate.uppercased() != "OK" } + } + + private var bugs: Int { total("bugs") } + private var vulnerabilities: Int { total("vulnerabilities") } + private var codeSmells: Int { total("code_smells") } + + var body: some View { + NavigationStack { + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 155), spacing: 12)], spacing: 12) { + metricCard("Projects", value: appState.projects.count, icon: "square.stack.3d.up") + metricCard("Failed", value: failedProjects.count, icon: "xmark.octagon.fill", isFailure: !failedProjects.isEmpty) + metricCard("Bugs", value: bugs, icon: "ladybug") + metricCard("Vulnerabilities", value: vulnerabilities, icon: "lock.trianglebadge.exclamationmark") + metricCard("Code Smells", value: codeSmells, icon: "exclamationmark.bubble") + } + .padding() + + if !failedProjects.isEmpty { + VStack(alignment: .leading, spacing: 10) { + Text("Needs attention") + .font(.headline) + ForEach(failedProjects) { snapshot in + NavigationLink { + IOSProjectDetailView(snapshot: snapshot) + } label: { + HStack { + Image(systemName: "xmark.octagon.fill") + .foregroundStyle(.red) + VStack(alignment: .leading) { + Text(snapshot.project.name).fontWeight(.semibold) + Text(snapshot.qualityGate).font(.caption) + } + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + .padding() + .background(.red.opacity(0.10), in: RoundedRectangle(cornerRadius: 14)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + } + } + .navigationTitle("Dashboard") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { Task { await appState.refresh() } } label: { + if appState.isLoading { ProgressView() } else { Image(systemName: "arrow.clockwise") } + } + .disabled(appState.isLoading || appState.selectedServer == nil) + } + } + .overlay { + if appState.selectedServer == nil { + ContentUnavailableView("No SonarQube Server", systemImage: "server.rack", description: Text("Add or sync a server to start using SonarDesk.")) + } + } + } + } + + private func total(_ key: String) -> Int { + appState.projects.reduce(0) { $0 + (Int($1.measures[key] ?? "0") ?? 0) } + } + + @ViewBuilder + private func metricCard(_ title: String, value: Int, icon: String, isFailure: Bool = false) -> some View { + VStack(alignment: .leading, spacing: 12) { + Image(systemName: icon).font(.title2).foregroundStyle(isFailure ? .red : .primary) + Text("\(value)").font(.system(.title, design: .rounded, weight: .bold)) + Text(title).font(.subheadline).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(isFailure ? .red.opacity(0.10) : Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 16)) + } +} diff --git a/SonarDesk-iOS/Views/IOSProjectDetailView.swift b/SonarDesk-iOS/Views/IOSProjectDetailView.swift new file mode 100644 index 0000000..867f951 --- /dev/null +++ b/SonarDesk-iOS/Views/IOSProjectDetailView.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct IOSProjectDetailView: View { + let snapshot: ProjectSnapshot + + private let metrics: [(String, String, String)] = [ + ("bugs", "Bugs", "ladybug"), + ("vulnerabilities", "Vulnerabilities", "lock.trianglebadge.exclamationmark"), + ("code_smells", "Code Smells", "exclamationmark.bubble"), + ("coverage", "Coverage", "chart.bar"), + ("duplicated_lines_density", "Duplications", "doc.on.doc") + ] + + var body: some View { + List { + Section("Quality Gate") { + HStack { + Label(snapshot.qualityGate.uppercased() == "OK" ? "Passed" : "Failed", + systemImage: snapshot.qualityGate.uppercased() == "OK" ? "checkmark.circle.fill" : "xmark.octagon.fill") + .foregroundStyle(snapshot.qualityGate.uppercased() == "OK" ? .green : .red) + Spacer() + Text(snapshot.qualityGate) + .foregroundStyle(.secondary) + } + } + + Section("Metrics") { + ForEach(metrics, id: \.0) { metric in + HStack { + Label(metric.1, systemImage: metric.2) + Spacer() + Text(displayValue(metric.0)) + .foregroundStyle(.secondary) + } + } + } + + Section("Project") { + LabeledContent("Key", value: snapshot.project.key) + if let date = snapshot.project.lastAnalysisDate { + LabeledContent("Last analysis", value: date) + } + } + } + .navigationTitle(snapshot.project.name) + .navigationBarTitleDisplayMode(.inline) + } + + private func displayValue(_ metric: String) -> String { + guard let value = snapshot.measures[metric] else { return "—" } + if metric == "coverage" || metric == "duplicated_lines_density" { return "\(value)%" } + return value + } +} diff --git a/SonarDesk-iOS/Views/IOSProjectsView.swift b/SonarDesk-iOS/Views/IOSProjectsView.swift new file mode 100644 index 0000000..db7fa26 --- /dev/null +++ b/SonarDesk-iOS/Views/IOSProjectsView.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct IOSProjectsView: View { + @EnvironmentObject private var appState: AppState + @State private var searchText = "" + + private var filtered: [ProjectSnapshot] { + guard !searchText.isEmpty else { return appState.projects } + return appState.projects.filter { + $0.project.name.localizedCaseInsensitiveContains(searchText) || + $0.project.key.localizedCaseInsensitiveContains(searchText) + } + } + + var body: some View { + NavigationStack { + List(filtered) { snapshot in + NavigationLink { + IOSProjectDetailView(snapshot: snapshot) + } label: { + HStack(spacing: 12) { + Image(systemName: snapshot.qualityGate.uppercased() == "OK" ? "checkmark.circle.fill" : "xmark.octagon.fill") + .foregroundStyle(snapshot.qualityGate.uppercased() == "OK" ? .green : .red) + VStack(alignment: .leading, spacing: 3) { + Text(snapshot.project.name).fontWeight(.semibold) + Text(snapshot.project.key).font(.caption).foregroundStyle(.secondary) + } + } + } + } + .navigationTitle("Projects") + .searchable(text: $searchText, prompt: "Search projects") + .refreshable { await appState.refresh() } + .overlay { + if appState.projects.isEmpty && !appState.isLoading { + ContentUnavailableView("No Projects", systemImage: "square.stack.3d.up", description: Text(appState.errorMessage ?? "No SonarQube projects are available.")) + } + } + } + } +} diff --git a/SonarDesk-iOS/Views/IOSRootView.swift b/SonarDesk-iOS/Views/IOSRootView.swift new file mode 100644 index 0000000..bceb4dd --- /dev/null +++ b/SonarDesk-iOS/Views/IOSRootView.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct IOSRootView: View { + @EnvironmentObject private var appState: AppState + @EnvironmentObject private var workspaceStore: WorkspaceStore + @State private var selection: IOSSection? = .dashboard + + enum IOSSection: Hashable { + case dashboard + case projects + case settings + } + + var body: some View { + NavigationSplitView { + List(selection: $selection) { + Section { + Label("Dashboard", systemImage: "gauge.with.dots.needle.50percent") + .tag(IOSSection.dashboard) + Label("Projects", systemImage: "square.stack.3d.up") + .tag(IOSSection.projects) + } + + Section("Workspace") { + Picker("Workspace", selection: $workspaceStore.selectedWorkspaceID) { + ForEach(workspaceStore.workspaces) { workspace in + Text(workspace.name).tag(workspace.id) + } + } + } + + Section { + Label("Settings", systemImage: "gearshape") + .tag(IOSSection.settings) + } + } + .navigationTitle("SonarDesk") + } detail: { + switch selection ?? .dashboard { + case .dashboard: + IOSDashboardView() + case .projects: + IOSProjectsView() + case .settings: + IOSSettingsView() + } + } + .task { + if appState.selectedServer != nil && appState.projects.isEmpty { + await appState.refresh() + } + } + } +} diff --git a/SonarDesk-iOS/Views/IOSSettingsView.swift b/SonarDesk-iOS/Views/IOSSettingsView.swift new file mode 100644 index 0000000..8868cbc --- /dev/null +++ b/SonarDesk-iOS/Views/IOSSettingsView.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct IOSSettingsView: View { + @EnvironmentObject private var appState: AppState + @EnvironmentObject private var workspaceStore: WorkspaceStore + + var body: some View { + NavigationStack { + Form { + Section("Workspace") { + LabeledContent("Name", value: workspaceStore.selectedWorkspace.name) + LabeledContent("Type", value: workspaceStore.selectedWorkspace.kind == .team ? "Team" : "Personal") + LabeledContent("Role", value: workspaceStore.selectedWorkspace.role.rawValue.capitalized) + } + + Section("SonarQube") { + if let server = appState.selectedServer { + LabeledContent("Server", value: server.name) + LabeledContent("Address", value: server.baseURL) + Text("The SonarQube access token is stored only in this device's Keychain.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + Text("No server is configured on this device.") + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("Settings") + } + } +}