phase two

This commit is contained in:
Mark Gregory
2026-10-06 08:58:01 +01:00
parent 6bb62ebf69
commit 736765180c
8 changed files with 332 additions and 0 deletions
+34
View File
@@ -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.
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.network.client</key><true/>
<key>com.apple.developer.icloud-container-identifiers</key>
<array><string>iCloud.net.absdev.SonarDesk</string></array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array><string>iCloud.net.absdev.SonarDesk</string></array>
<key>com.apple.developer.icloud-services</key>
<array><string>CloudDocuments</string><string>CloudKit</string></array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)net.absdev.SonarDesk</string>
</dict>
</plist>
+15
View File
@@ -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)
}
}
}
@@ -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))
}
}
@@ -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
}
}
+41
View File
@@ -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."))
}
}
}
}
}
+54
View File
@@ -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()
}
}
}
}
+32
View File
@@ -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")
}
}
}