Jul 29 - Update codes to support iPhone

This commit is contained in:
Nguyen Ngo
2026-07-29 15:51:08 -04:00
parent 9926739cb6
commit b7990cc9ba
5 changed files with 355 additions and 178 deletions
+198 -134
View File
@@ -34,6 +34,7 @@ struct DashboardView: View {
@EnvironmentObject private var sync: SyncManager
@Environment(\.modelContext) private var context
@Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var hSizeClass
@Query(
filter: #Predicate<LocalInspection> { $0.status != "synced" },
@@ -71,141 +72,26 @@ struct DashboardView: View {
selectedTab = tab
}
/// Sidebar order single source of truth for both the regular-width
/// sidebar and the compact-width root list.
private let sidebarTabs: [SidebarTab] = [
.dashboard, .myInspections, .issues, .facilities,
.pendingSync, .history, .notifications, .settings,
]
var body: some View {
NavigationSplitView {
List {
// Dashboard
Button { selectTab(.dashboard) } label: {
Label("Dashboard", systemImage: "chart.bar.xaxis")
.foregroundStyle(selectedTab == .dashboard ? .blue : .primary)
}
.listRowBackground(selectedTab == .dashboard ? Color.blue.opacity(0.1) : Color.clear)
// My Inspections
Button { selectTab(.myInspections) } label: {
HStack {
Label("My Inspections", systemImage: "checklist")
.foregroundStyle(selectedTab == .myInspections ? .blue : .primary)
Spacer()
if !myInspections.isEmpty {
Text("\(myInspections.count)")
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.blue.opacity(0.15))
.clipShape(Capsule())
}
}
}
.listRowBackground(selectedTab == .myInspections ? Color.blue.opacity(0.1) : Color.clear)
// Issues (all roles)
Button { selectTab(.issues) } label: {
Label("Issues", systemImage: "exclamationmark.triangle")
.foregroundStyle(selectedTab == .issues ? .blue : .primary)
}
.listRowBackground(selectedTab == .issues ? Color.blue.opacity(0.1) : Color.clear)
// Facilities
Button { selectTab(.facilities) } label: {
Label("Facilities", systemImage: "building.2")
.foregroundStyle(selectedTab == .facilities ? .blue : .primary)
}
.listRowBackground(selectedTab == .facilities ? Color.blue.opacity(0.1) : Color.clear)
// Pending Sync
Button { selectTab(.pendingSync) } label: {
HStack {
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
.foregroundStyle(selectedTab == .pendingSync ? .blue : .primary)
Spacer()
if sync.pendingCount > 0 {
Text("\(sync.pendingCount)")
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.orange.opacity(0.2))
.foregroundStyle(.orange)
.clipShape(Capsule())
}
}
}
.listRowBackground(selectedTab == .pendingSync ? Color.blue.opacity(0.1) : Color.clear)
// History (moved sits between Pending Sync and Settings)
Button { selectTab(.history) } label: {
Label("History", systemImage: "clock.arrow.circlepath")
.foregroundStyle(selectedTab == .history ? .blue : .primary)
}
.listRowBackground(selectedTab == .history ? Color.blue.opacity(0.1) : Color.clear)
// Notifications
Button {
selectTab(.notifications)
sync.markNotificationsViewed()
} label: {
HStack {
Label("Notifications", systemImage: "bell")
.foregroundStyle(selectedTab == .notifications ? .blue : .primary)
Spacer()
if sync.unreadNotificationCount > 0 {
Text("\(min(sync.unreadNotificationCount, 99))")
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.red.opacity(0.85))
.foregroundStyle(.white)
.clipShape(Capsule())
}
}
}
.listRowBackground(selectedTab == .notifications ? Color.blue.opacity(0.1) : Color.clear)
// Settings
Button { selectTab(.settings) } label: {
Label("Settings", systemImage: "gear")
.foregroundStyle(selectedTab == .settings ? .blue : .primary)
}
.listRowBackground(selectedTab == .settings ? Color.blue.opacity(0.1) : Color.clear)
}
.navigationTitle("JQC Inspector")
.listStyle(.sidebar)
.safeAreaInset(edge: .bottom) { syncStatusFooter }
} detail: {
switch selectedTab {
case .dashboard:
NavigationStack { DashboardStatsView() }
case .myInspections:
NavigationStack(path: $inspectionsPath) {
MyInspectionsView()
.navigationDestination(for: LocalInspection.self) { inspection in
if inspection.status == "draft" {
ExecuteInspectionView(inspection: inspection)
} else {
CompletedInspectionView(inspection: inspection)
}
}
}
case .issues:
NavigationStack(path: $issuesPath) {
IssuesListView()
.navigationDestination(for: LocalIssue.self) { issue in
IssueDetailView(issue: issue)
}
}
case .facilities:
NavigationStack { FacilitiesListView() }
case .pendingSync:
NavigationStack { SyncStatusView() }
case .history:
NavigationStack(path: $historyPath) {
InspectionHistoryView()
.navigationDestination(for: APIInspectionSummary.self) { inspection in
HistoryDetailView(inspection: inspection)
}
}
case .notifications:
NavigationStack { NotificationsView() }
case .settings:
NavigationStack { SettingsView() }
Group {
// On compact width (iPhone) a NavigationSplitView collapses to show
// ONLY the sidebar: its `detail:` column is never presented, because
// nothing pushes it. The rows here are plain Buttons driving @State
// (rule 2 forbids a `selection:` binding), and a state change alone
// cannot push the detail column so every destination was
// unreachable on iPhone. Compact width therefore gets a real
// NavigationStack whose rows are NavigationLinks.
if hSizeClass == .compact {
compactBody
} else {
regularBody
}
}
.task {
@@ -223,6 +109,181 @@ struct DashboardView: View {
}
}
// Regular width (iPad) unchanged two-column split view
private var regularBody: some View {
NavigationSplitView {
List {
ForEach(sidebarTabs, id: \.self) { tab in
Button {
selectTab(tab)
if tab == .notifications { sync.markNotificationsViewed() }
} label: {
sidebarRowLabel(tab, tinted: selectedTab == tab)
}
.listRowBackground(
selectedTab == tab ? Color.blue.opacity(0.1) : Color.clear
)
}
}
.navigationTitle("JQC Inspector")
.listStyle(.sidebar)
.safeAreaInset(edge: .bottom) { syncStatusFooter }
} detail: {
switch selectedTab {
case .myInspections:
NavigationStack(path: $inspectionsPath) { detailRoot(for: .myInspections) }
case .issues:
NavigationStack(path: $issuesPath) { detailRoot(for: .issues) }
case .history:
NavigationStack(path: $historyPath) { detailRoot(for: .history) }
default:
NavigationStack { detailRoot(for: selectedTab) }
}
}
}
// Compact width (iPhone) push-based stack
// One NavigationStack whose root is the same destination list. Rows are
// NavigationLinks so tapping actually pushes. The per-tab paths used by
// the iPad split view are not needed here: this single stack owns the
// whole hierarchy, and the nested `.navigationDestination`s declared in
// detailRoot(for:) register against it.
private var compactBody: some View {
NavigationStack {
List {
ForEach(sidebarTabs, id: \.self) { tab in
NavigationLink(value: tab) {
sidebarRowLabel(tab, tinted: false)
}
}
}
.navigationTitle("JQC Inspector")
.navigationDestination(for: SidebarTab.self) { detailRoot(for: $0) }
.safeAreaInset(edge: .bottom) { syncStatusFooter }
}
}
// Shared row label
@ViewBuilder
private func sidebarRowLabel(_ tab: SidebarTab, tinted: Bool) -> some View {
let tint: Color = tinted ? .blue : .primary
switch tab {
case .dashboard:
Label("Dashboard", systemImage: "chart.bar.xaxis")
.foregroundStyle(tint)
case .myInspections:
HStack {
Label("My Inspections", systemImage: "checklist")
.foregroundStyle(tint)
Spacer()
if !myInspections.isEmpty {
Text("\(myInspections.count)")
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.blue.opacity(0.15))
.clipShape(Capsule())
}
}
case .issues:
Label("Issues", systemImage: "exclamationmark.triangle")
.foregroundStyle(tint)
case .facilities:
Label("Facilities", systemImage: "building.2")
.foregroundStyle(tint)
case .pendingSync:
HStack {
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
.foregroundStyle(tint)
Spacer()
if sync.pendingCount > 0 {
Text("\(sync.pendingCount)")
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.orange.opacity(0.2))
.foregroundStyle(.orange)
.clipShape(Capsule())
}
}
case .history:
Label("History", systemImage: "clock.arrow.circlepath")
.foregroundStyle(tint)
case .notifications:
HStack {
Label("Notifications", systemImage: "bell")
.foregroundStyle(tint)
Spacer()
if sync.unreadNotificationCount > 0 {
Text("\(min(sync.unreadNotificationCount, 99))")
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.red.opacity(0.85))
.foregroundStyle(.white)
.clipShape(Capsule())
}
}
case .settings:
Label("Settings", systemImage: "gear")
.foregroundStyle(tint)
}
}
// Shared destination root
// The NavigationStack wrapper lives at the call site, so the same content
// serves as a split-view detail root (iPad) and a pushed view (iPhone).
@ViewBuilder
private func detailRoot(for tab: SidebarTab) -> some View {
switch tab {
case .dashboard:
DashboardStatsView()
case .myInspections:
MyInspectionsView()
.navigationDestination(for: LocalInspection.self) { inspection in
if inspection.status == "draft" {
ExecuteInspectionView(inspection: inspection)
} else {
CompletedInspectionView(inspection: inspection)
}
}
case .issues:
IssuesListView()
.navigationDestination(for: LocalIssue.self) { issue in
IssueDetailView(issue: issue)
}
case .facilities:
FacilitiesListView()
case .pendingSync:
SyncStatusView()
case .history:
InspectionHistoryView()
.navigationDestination(for: APIInspectionSummary.self) { inspection in
HistoryDetailView(inspection: inspection)
}
case .notifications:
NotificationsView()
case .settings:
SettingsView()
}
}
private var syncStatusFooter: some View {
VStack(spacing: 0) {
Divider()
@@ -460,6 +521,9 @@ struct DashboardStatsView: View {
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
// Tiles are 2-up, so on a phone each is ~170 pt wide and
// longer labels ("Open / In Progress") would truncate.
.minimumScaleFactor(0.75)
}
Text(value)
.font(.system(size: 32, weight: .bold, design: .rounded))