Jul 29 - Update codes to support iPhone
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -870,6 +870,25 @@ struct GridFormView: View {
|
||||
static let cellAspect: CGFloat = 52/72 // cellH / cellW — matches editor CELL_H/CELL_W
|
||||
static let cardPadding: CGFloat = 16 // card inset on all sides
|
||||
|
||||
// Below this container width the 12-column grid stops being usable: at
|
||||
// 375 pt (iPhone SE/6/7/8) a column is only ~21 pt wide and a row ~15 pt
|
||||
// tall, so a normal 6x2 field renders ~167x35 pt — the label alone eats
|
||||
// most of it. Cells are absolutely positioned and deliberately unclipped
|
||||
// (see body), so the overflow draws on top of the row beneath and the
|
||||
// form becomes an unreadable pile. Under this width we reflow to one
|
||||
// field per line instead. 600 pt keeps a column at >=40 pt.
|
||||
static let minGridWidth: CGFloat = 600
|
||||
|
||||
// Heights for widgets that have no intrinsic size of their own. In the
|
||||
// absolute grid these are driven by rowSpan; in the stacked layout there
|
||||
// is no rowSpan to read, so they would otherwise collapse to nothing.
|
||||
static let stackedMinH: [String: CGFloat] = [
|
||||
"textarea": 96,
|
||||
"signature": 120,
|
||||
"table": 120,
|
||||
"image": 88,
|
||||
]
|
||||
|
||||
// Minimum cell height (points) per field type — ensures 44pt touch targets
|
||||
// on iPad even when the template author assigned a very short rowSpan.
|
||||
static let minCellH: [String: CGFloat] = [
|
||||
@@ -894,54 +913,115 @@ struct GridFormView: View {
|
||||
@State private var containerWidth: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
// ── Card background ────────────────────────────────────────────
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
Group {
|
||||
if containerWidth > 0 && isCompact {
|
||||
// ── Compact: content drives the height ─────────────────────
|
||||
// The card is a .background modifier rather than a ZStack
|
||||
// sibling so it takes its size FROM the stack. As a ZStack
|
||||
// sibling the flexible RoundedRectangle competes with the
|
||||
// VStack for the container's size and the card ends up
|
||||
// shorter than its own content, cutting off the last fields.
|
||||
stackedLayout
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
)
|
||||
} else {
|
||||
// ── Regular: absolute 12-column canvas ─────────────────────
|
||||
ZStack(alignment: .topLeading) {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
|
||||
// ── Width probe — zero-size overlay, reports container width ───
|
||||
// Using a background Color.clear with a GeometryReader that sends
|
||||
// its width via PreferenceKey is the idiomatic SwiftUI pattern that
|
||||
// works correctly inside ScrollView on all iOS versions.
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 0)
|
||||
.background(
|
||||
GeometryReader { geo in
|
||||
Color.clear.preference(
|
||||
key: WidthPreferenceKey.self,
|
||||
value: geo.size.width
|
||||
)
|
||||
}
|
||||
)
|
||||
// Field overlays — only rendered after width is measured.
|
||||
// containerWidth == 0 means the PreferenceKey has not fired
|
||||
// yet (first layout pass). Skipping the overlay pass on the
|
||||
// zero frame prevents fields from being positioned using a
|
||||
// stale width and overflowing the modal on narrow sheet
|
||||
// presentations (iPad 10th gen).
|
||||
if containerWidth > 0 {
|
||||
let cellW = computedCellW
|
||||
let cellH = cellW * Self.cellAspect
|
||||
|
||||
// ── Field overlays — only rendered after width is measured ─────
|
||||
// containerWidth == 0 means the PreferenceKey has not fired yet
|
||||
// (first layout pass). Skipping the overlay pass on the zero frame
|
||||
// prevents fields from being positioned using a stale width and
|
||||
// overflowing the modal on narrow sheet presentations (iPad 10th gen).
|
||||
if containerWidth > 0 {
|
||||
let cellW = computedCellW
|
||||
let cellH = cellW * Self.cellAspect
|
||||
|
||||
ForEach(schema.indices, id: \.self) { idx in
|
||||
let field = schema[idx]
|
||||
let ftype = field["type"] as? String ?? "text"
|
||||
if !["button_submit", "button_print", "button_email"].contains(ftype) {
|
||||
gridCell(field: field, cellW: cellW, cellH: cellH)
|
||||
ForEach(schema.indices, id: \.self) { idx in
|
||||
let field = schema[idx]
|
||||
let ftype = field["type"] as? String ?? "text"
|
||||
if !["button_submit", "button_print", "button_email"].contains(ftype) {
|
||||
gridCell(field: field, cellW: cellW, cellH: cellH)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Height is derived from the same arithmetic as the cell
|
||||
// offsets — the ScrollView measures this frame and can never
|
||||
// be wrong.
|
||||
.frame(height: containerWidth > 0
|
||||
? canvasHeight() + 2 * Self.cardPadding
|
||||
: 0)
|
||||
}
|
||||
}
|
||||
// ── Width probe ───────────────────────────────────────────────────
|
||||
// Attached as a background so it reports the resolved container width
|
||||
// without taking part in sizing the content itself.
|
||||
.background(
|
||||
GeometryReader { geo in
|
||||
Color.clear.preference(
|
||||
key: WidthPreferenceKey.self,
|
||||
value: geo.size.width
|
||||
)
|
||||
}
|
||||
)
|
||||
.onPreferenceChange(WidthPreferenceKey.self) { width in
|
||||
if width > 0 { containerWidth = width }
|
||||
}
|
||||
// Height is always derived from the same arithmetic as cell offsets —
|
||||
// the ScrollView measures this frame and can never be wrong.
|
||||
// When containerWidth is 0, canvasHeight() still returns the correct
|
||||
// value (it uses computedCellW which returns 0 when containerWidth is 0),
|
||||
// so the card reserves space and avoids a layout jump.
|
||||
.frame(height: containerWidth > 0 ? canvasHeight() + 2 * Self.cardPadding : 0)
|
||||
}
|
||||
|
||||
// ── Compact (narrow) layout ───────────────────────────────────────────
|
||||
// One field per line, full width, natural height. Fields are ordered by
|
||||
// (row, col) because the form editor stores them in drag/creation order,
|
||||
// not visual order — the same sort the PDF and read-only renderers use
|
||||
// (rule 62). Nothing is absolutely positioned here, so nothing can
|
||||
// overlap regardless of how narrow the screen gets.
|
||||
|
||||
private var isCompact: Bool { containerWidth < Self.minGridWidth }
|
||||
|
||||
private var orderedFields: [[String: Any]] {
|
||||
schema
|
||||
.filter { f in
|
||||
let t = f["type"] as? String ?? "text"
|
||||
return !["button_submit", "button_print", "button_email"].contains(t)
|
||||
}
|
||||
.sorted {
|
||||
let r0 = $0["row"] as? Int ?? 0, r1 = $1["row"] as? Int ?? 0
|
||||
if r0 != r1 { return r0 < r1 }
|
||||
return ($0["col"] as? Int ?? 0) < ($1["col"] as? Int ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
private var stackedLayout: some View {
|
||||
let fields = orderedFields
|
||||
return VStack(alignment: .leading, spacing: 14) {
|
||||
ForEach(fields.indices, id: \.self) { idx in
|
||||
let field = fields[idx]
|
||||
let ftype = field["type"] as? String ?? "text"
|
||||
let fid = fieldId(field)
|
||||
|
||||
GridCellContentView(
|
||||
field: field,
|
||||
value: Binding(
|
||||
get: { formValues[fid] ?? "" },
|
||||
set: { formValues[fid] = $0; onFieldChanged?() }
|
||||
),
|
||||
onPhotoSelected: { path in onPhotoSelected?(path, field) }
|
||||
)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
minHeight: Self.stackedMinH[ftype] ?? 0,
|
||||
alignment: .topLeading
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(Self.cardPadding)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
// ── Derived cell width from current containerWidth ────────────────────
|
||||
|
||||
Reference in New Issue
Block a user