phase two
This commit is contained in:
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user