06/18 Update Inspection details view layout
This commit is contained in:
@@ -29,9 +29,22 @@
|
|||||||
B37875952FA6358F0088F40B /* JanitorialQCUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JanitorialQCUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
B37875952FA6358F0088F40B /* JanitorialQCUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JanitorialQCUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
B3C5F1DC2FDB4A38005E0181 /* Exceptions for "JanitorialQC" folder in "JanitorialQC" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = B378757B2FA6358E0088F40B /* JanitorialQC */;
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
B378757E2FA6358E0088F40B /* JanitorialQC */ = {
|
B378757E2FA6358E0088F40B /* JanitorialQC */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
B3C5F1DC2FDB4A38005E0181 /* Exceptions for "JanitorialQC" folder in "JanitorialQC" target */,
|
||||||
|
);
|
||||||
path = JanitorialQC;
|
path = JanitorialQC;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -398,26 +411,27 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_TEAM = SB7DNYC9TY;
|
DEVELOPMENT_TEAM = SB7DNYC9TY;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = JanitorialQC/Info.plist;
|
||||||
INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync;
|
INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
|
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "Take photos to document inspection issues.";
|
INFOPLIST_KEY_NSCameraUsageDescription = "Take photos to document inspection issues.";
|
||||||
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "JQC records your GPS location when you submit an inspection to verify it was completed on-site.";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Attach photos from your library to inspection issues.";
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Attach photos from your library to inspection issues.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17;
|
IPHONEOS_DEPLOYMENT_TARGET = 17;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -440,26 +454,27 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_TEAM = SB7DNYC9TY;
|
DEVELOPMENT_TEAM = SB7DNYC9TY;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = JanitorialQC/Info.plist;
|
||||||
INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync;
|
INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
|
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "Take photos to document inspection issues.";
|
INFOPLIST_KEY_NSCameraUsageDescription = "Take photos to document inspection issues.";
|
||||||
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "JQC records your GPS location when you submit an inspection to verify it was completed on-site.";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Attach photos from your library to inspection issues.";
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Attach photos from your library to inspection issues.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17;
|
IPHONEOS_DEPLOYMENT_TARGET = 17;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ struct APITemplateSummary: Decodable, Identifiable, Sendable {
|
|||||||
let name: String
|
let name: String
|
||||||
let description: String
|
let description: String
|
||||||
let frequency: String
|
let frequency: String
|
||||||
|
let isActive: Bool
|
||||||
|
|
||||||
nonisolated init(from decoder: any Decoder) throws {
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
@@ -226,8 +227,10 @@ struct APITemplateSummary: Decodable, Identifiable, Sendable {
|
|||||||
name = try c.decode(String.self, forKey: .name)
|
name = try c.decode(String.self, forKey: .name)
|
||||||
description = try c.decode(String.self, forKey: .description)
|
description = try c.decode(String.self, forKey: .description)
|
||||||
frequency = try c.decode(String.self, forKey: .frequency)
|
frequency = try c.decode(String.self, forKey: .frequency)
|
||||||
|
// Default true — backwards compatible if server omits the field
|
||||||
|
isActive = (try? c.decode(Bool.self, forKey: .isActive)) ?? true
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey { case id, name, description, frequency }
|
private enum CodingKeys: String, CodingKey { case id, name, description, frequency, isActive }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TemplatesResponseData: Decodable, Sendable {
|
struct TemplatesResponseData: Decodable, Sendable {
|
||||||
|
|||||||
+15
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **Audience:** AI assistants and developers working on the JanitorialQC iPad app.
|
> **Audience:** AI assistants and developers working on the JanitorialQC iPad app.
|
||||||
> **Purpose:** Authoritative reference for architecture, conventions, constraints, and decisions.
|
> **Purpose:** Authoritative reference for architecture, conventions, constraints, and decisions.
|
||||||
> **Last reviewed:** May 2026 (Phase 19 complete — server selection, standalone issue creation, multi-photo evidence, facility deduplication, RetryablePhotoView, photo URL /static/ prefix)
|
> **Last reviewed:** June 2026 (Phase 19 complete — server selection, standalone issue creation, multi-photo evidence, facility deduplication, RetryablePhotoView, photo URL /static/ prefix + Phase 25 GPS capture at submit time + active-template filter + area relationship fix + read-only inspection detail view)
|
||||||
> **Companion:** See the web backend's `CLAUDE.md` for API contract, server-side rules, and migration chain.
|
> **Companion:** See the web backend's `CLAUDE.md` for API contract, server-side rules, and migration chain.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -192,7 +192,7 @@ LocalInspection.self, LocalIssue.self, PendingPhoto.self, SyncQueueEntry.self
|
|||||||
| `LocalFacility` | Read-only cached facility reference | `serverId` (`@Attribute(.unique)`), `name`, `projectId`, `projectName`, `areas` (cascade) |
|
| `LocalFacility` | Read-only cached facility reference | `serverId` (`@Attribute(.unique)`), `name`, `projectId`, `projectName`, `areas` (cascade) |
|
||||||
| `LocalArea` | Read-only cached area reference | `serverId`, `facilityServerId`, `name`, `areaType` |
|
| `LocalArea` | Read-only cached area reference | `serverId`, `facilityServerId`, `name`, `areaType` |
|
||||||
| `LocalTemplate` | Cached template + raw JSON schema | `serverId`, `formSchemaJSON`, `formSchema` (computed) |
|
| `LocalTemplate` | Cached template + raw JSON schema | `serverId`, `formSchemaJSON`, `formSchema` (computed) |
|
||||||
| `LocalInspection` | Inspector-authored inspection record | `localId` (UUID, unique), `serverId`, `status`, `syncStatus`, `formDataJSON`, `followUpRequired`, `parentLocalId`, `parentServerId` |
|
| `LocalInspection` | Inspector-authored inspection record | `localId` (UUID, unique), `serverId`, `status`, `syncStatus`, `formDataJSON`, `followUpRequired`, `parentLocalId`, `parentServerId`, `submitLatitude` (Double?), `submitLongitude` (Double?) |
|
||||||
| `LocalIssue` | Issue record | `localId` (UUID, unique), `serverId`, `inspectionLocalId` (`""` for standalone/server-pulled), `facilityServerId`, `severity`, `syncStatus`, `photoLocalPathsJSON`, `photoServerPathsJSON` |
|
| `LocalIssue` | Issue record | `localId` (UUID, unique), `serverId`, `inspectionLocalId` (`""` for standalone/server-pulled), `facilityServerId`, `severity`, `syncStatus`, `photoLocalPathsJSON`, `photoServerPathsJSON` |
|
||||||
| `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `entityType` (`"issue"` or `"inspection"`), `fieldId` |
|
| `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `entityType` (`"issue"` or `"inspection"`), `fieldId` |
|
||||||
| `SyncQueueEntry` | Outbox entry (informational) | `entityType`, `localId`, `syncStatus`, `payloadJSON` |
|
| `SyncQueueEntry` | Outbox entry (informational) | `entityType`, `localId`, `syncStatus`, `payloadJSON` |
|
||||||
@@ -366,6 +366,10 @@ Contract → Facility cascade pickers (same as `StandaloneIssueView`). `onChange
|
|||||||
- Submit: computes score, sets `status = "completed"`, `syncStatus = "pending"`, calls `clearParentFollowUpFlag()`, triggers sync.
|
- Submit: computes score, sets `status = "completed"`, `syncStatus = "pending"`, calls `clearParentFollowUpFlag()`, triggers sync.
|
||||||
- Shows success banner 2.5 seconds then dismisses.
|
- Shows success banner 2.5 seconds then dismisses.
|
||||||
|
|
||||||
|
**GPS capture (Phase 25):** `InspectionLocationManager` (thin `CLLocationManager` wrapper defined at the bottom of `ExecuteInspectionView.swift`) begins acquiring a fix the moment the submit confirm dialog appears. On successful fix, `inspection.submitLatitude` and `inspection.submitLongitude` are set before the inspection is marked completed. GPS is best-effort — nil on permission denial or location failure; submission still proceeds normally.
|
||||||
|
|
||||||
|
`APIClient.submitInspection` sends `submit_latitude` / `submit_longitude` only when non-nil. The `PATCH` endpoint does not accept GPS fields — creation-time (POST) capture only. The server displays a Google Maps embed in `inspections/view.html` for admin/director when both fields are present.
|
||||||
|
|
||||||
### 3. Draft management
|
### 3. Draft management
|
||||||
|
|
||||||
Swipe-left delete (confirmation required). Deletes draft + `PendingPhoto` records + local photo files + associated `LocalIssue` records. Only `status == "draft"` inspections may be deleted.
|
Swipe-left delete (confirmation required). Deletes draft + `PendingPhoto` records + local photo files + associated `LocalIssue` records. Only `status == "draft"` inspections may be deleted.
|
||||||
@@ -640,6 +644,15 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record
|
|||||||
| 50 | **`NotificationsView.markNotificationsViewed()` on both `.onAppear` and sidebar tap** | Sidebar tap calls `sync.markNotificationsViewed()` inline to clear the badge immediately without waiting for navigation. |
|
| 50 | **`NotificationsView.markNotificationsViewed()` on both `.onAppear` and sidebar tap** | Sidebar tap calls `sync.markNotificationsViewed()` inline to clear the badge immediately without waiting for navigation. |
|
||||||
| 51 | **`FlagIssueView.submitIssue()` must copy `inspection.areaServerId` to `issue.areaServerId`** | Without this, the server cannot link the issue to the correct area. `APIClient.submitIssue` sends `area_id` only when `issue.areaServerId` is non-nil. |
|
| 51 | **`FlagIssueView.submitIssue()` must copy `inspection.areaServerId` to `issue.areaServerId`** | Without this, the server cannot link the issue to the correct area. `APIClient.submitIssue` sends `area_id` only when `issue.areaServerId` is non-nil. |
|
||||||
| 52 | **`LocalIssue.swift` zip delivery must include all Phase A–E fields** | When packaging, copy from the working-tree file and verify every field with `grep` before zipping. Partial field sets cause `SyncManager` build failures. The recurring hotfix pattern traces to this: each phase patched the working tree but the prior phase’s file was not in working tree. Fix: always `cp` back immediately after creating a phase file. |
|
| 52 | **`LocalIssue.swift` zip delivery must include all Phase A–E fields** | When packaging, copy from the working-tree file and verify every field with `grep` before zipping. Partial field sets cause `SyncManager` build failures. The recurring hotfix pattern traces to this: each phase patched the working tree but the prior phase’s file was not in working tree. Fix: always `cp` back immediately after creating a phase file. |
|
||||||
|
| 53 | **`submitLatitude`/`submitLongitude` on `LocalInspection` are `Double?` optionals** | SwiftData lightweight migration supports nil-default optionals without a migration plan. `APIClient.submitInspection` sends them only when non-nil via `if let lat = inspection.submitLatitude`. Never make them non-optional — GPS is best-effort and must not block submission on permission denial or hardware failure. |
|
||||||
|
| 54 | **`InspectionLocationManager.startUpdating()` called when confirm dialog appears, not at view load** | Starting too early wastes battery. The confirm dialog provides a natural ~1–2 second window before the user taps Confirm, giving the manager time to acquire a fix. GPS captured into `LocalInspection` immediately before marking `status = "completed"`. |
|
||||||
|
| 55 | **`StartInspectionView` must filter templates in Swift, not via `@Query` predicate** | `@Query(sort: \LocalTemplate.name)` fetches all into `allTemplates`; computed `var templates` filters `{ $0.isActive }`. `#Predicate` with `isActive` is unreliable under Xcode 26 rule 3. |
|
||||||
|
| 56 | **`LocalTemplate.isActive` must have inline default `= true`** | Added in this session. SwiftData lightweight migration requires all new `Bool` fields to carry an inline default (rule 8). `GET /api/v1/templates` now only returns active templates; `pullReferenceData` deletes cached templates not in the server response so inactive ones never appear in pickers even offline. |
|
||||||
|
| 57 | **`upsertAreas` must set `newArea.facility = facility` at insert time** | SwiftData relationship wiring requires the inverse to be explicitly assigned. Without `newArea.facility = facility`, `LocalFacility.areas` is always empty and the area picker shows nothing. The `LocalFacility` object (from `facilityMap` or just inserted) is passed into `upsertAreas` as a parameter. Also re-wires on update: `if ex.facility == nil { ex.facility = facility }`. |
|
||||||
|
| 58 | **`ReadOnlyGridFormView` uses `rowView` (GeometryReader + ZStack), NOT a ZStack canvas or LazyVGrid** | ZStack canvas: gaps from unanswered rows because y-offsets are absolute. LazyVGrid: ignores `col` position, flows items sequentially. Correct approach: group fields by original `row` into `RowGroup`s, render each group as a `GeometryReader` that divides width by 12 to get `colW`, positions each field with `.offset(x: colW * (col-1))` and `.frame(width: colW * colSpan)`. `VStack(spacing: 3)` between rows. Row height fixed at 36pt (section headers 28pt). |
|
||||||
|
| 59 | **Read-only inspection detail: only answered fields are shown — filtering is 5-pass** | Pass 1: collect `answeredIds` (rating > 0, or non-empty value). Pass 2: collect `visibleLabelIds` (labels immediately before an answered field). Pass 3: collect `visibleSectionIds` (sections with at least one answered field after them). Pass 4: group all schema fields by original `row`. Pass 5: for each row group, emit only visible fields; skip rows with no visible content. |
|
||||||
|
| 60 | **`ReadOnlyGridFormView` rows advance by 1 regardless of original `rowSpan`** | The web renders every field with `grid-row: N / span 1`. The read-only view collapses all rowSpans to 1 — no field occupies more than one row of vertical space. |
|
||||||
|
| 61 | **`PhotoThumbnailView` owns `@State private var showLightbox`** | `ReadOnlyCellView.valueView` is a computed `@ViewBuilder` — it cannot hold `@State`. The `image` case delegates to `PhotoThumbnailView` (a separate struct) which holds its own sheet state. Thumbnail is 32×32pt; lightbox is a full-screen black sheet dismissed by tap. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ final class LocalTemplate {
|
|||||||
var frequency: String
|
var frequency: String
|
||||||
var formSchemaJSON: String
|
var formSchemaJSON: String
|
||||||
var lastSyncedAt: Date
|
var lastSyncedAt: Date
|
||||||
|
var isActive: Bool = true // phase21 — false templates excluded from picker
|
||||||
|
|
||||||
init(from summary: APITemplateSummary) {
|
init(from summary: APITemplateSummary) {
|
||||||
self.serverId = summary.id
|
self.serverId = summary.id
|
||||||
@@ -22,12 +23,14 @@ final class LocalTemplate {
|
|||||||
self.frequency = summary.frequency
|
self.frequency = summary.frequency
|
||||||
self.formSchemaJSON = "[]"
|
self.formSchemaJSON = "[]"
|
||||||
self.lastSyncedAt = Date()
|
self.lastSyncedAt = Date()
|
||||||
|
self.isActive = summary.isActive
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateSummary(from summary: APITemplateSummary) {
|
func updateSummary(from summary: APITemplateSummary) {
|
||||||
self.name = summary.name
|
self.name = summary.name
|
||||||
self.templateDescription = summary.description
|
self.templateDescription = summary.description
|
||||||
self.frequency = summary.frequency
|
self.frequency = summary.frequency
|
||||||
|
self.isActive = summary.isActive
|
||||||
self.lastSyncedAt = Date()
|
self.lastSyncedAt = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -418,12 +418,16 @@ class SyncManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for apiFacility in uniqueFacilities {
|
for apiFacility in uniqueFacilities {
|
||||||
|
let localFacility: LocalFacility
|
||||||
if let existing = facilityMap[apiFacility.id] {
|
if let existing = facilityMap[apiFacility.id] {
|
||||||
existing.update(from: apiFacility)
|
existing.update(from: apiFacility)
|
||||||
|
localFacility = existing
|
||||||
} else {
|
} else {
|
||||||
context.insert(LocalFacility(from: apiFacility))
|
let newFacility = LocalFacility(from: apiFacility)
|
||||||
|
context.insert(newFacility)
|
||||||
|
localFacility = newFacility
|
||||||
}
|
}
|
||||||
try await upsertAreas(for: apiFacility.id, context: context)
|
try await upsertAreas(for: apiFacility.id, facility: localFacility, context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
let existingTemplates = try context.fetch(FetchDescriptor<LocalTemplate>())
|
let existingTemplates = try context.fetch(FetchDescriptor<LocalTemplate>())
|
||||||
@@ -443,6 +447,17 @@ class SyncManager: ObservableObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete any cached templates the server no longer returns.
|
||||||
|
// The server endpoint now only returns active templates, so any
|
||||||
|
// locally cached template not in the response was deactivated.
|
||||||
|
// Deleting ensures they never appear in the picker even offline.
|
||||||
|
let returnedTemplateIds = Set(templatesData.templates.map { $0.id })
|
||||||
|
for existing in existingTemplates {
|
||||||
|
if !returnedTemplateIds.contains(existing.serverId) {
|
||||||
|
context.delete(existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try context.save()
|
try context.save()
|
||||||
|
|
||||||
} catch APIError.notAuthenticated {
|
} catch APIError.notAuthenticated {
|
||||||
@@ -464,7 +479,7 @@ class SyncManager: ObservableObject {
|
|||||||
|
|
||||||
// ── Private Helpers ───────────────────────────────────────────────────
|
// ── Private Helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
private func upsertAreas(for facilityId: Int, context: ModelContext) async throws {
|
private func upsertAreas(for facilityId: Int, facility: LocalFacility, context: ModelContext) async throws {
|
||||||
let areasData: AreasResponseData = try await APIClient.shared.request(
|
let areasData: AreasResponseData = try await APIClient.shared.request(
|
||||||
"/api/v1/facilities/\(facilityId)/areas"
|
"/api/v1/facilities/\(facilityId)/areas"
|
||||||
)
|
)
|
||||||
@@ -473,8 +488,18 @@ class SyncManager: ObservableObject {
|
|||||||
let areaMap = Dictionary(existing.map { ($0.serverId, $0) },
|
let areaMap = Dictionary(existing.map { ($0.serverId, $0) },
|
||||||
uniquingKeysWith: { a, _ in a })
|
uniquingKeysWith: { a, _ in a })
|
||||||
for apiArea in areasData.areas {
|
for apiArea in areasData.areas {
|
||||||
if let ex = areaMap[apiArea.id] { ex.update(from: apiArea) }
|
if let ex = areaMap[apiArea.id] {
|
||||||
else { context.insert(LocalArea(from: apiArea)) }
|
ex.update(from: apiArea)
|
||||||
|
// Re-wire relationship in case it was lost (e.g. cache clear)
|
||||||
|
if ex.facility == nil { ex.facility = facility }
|
||||||
|
} else {
|
||||||
|
let newArea = LocalArea(from: apiArea)
|
||||||
|
// Wire the inverse relationship so LocalFacility.areas is populated.
|
||||||
|
// Without this assignment SwiftData never links the area into the
|
||||||
|
// facility's areas array and selectedFacility?.areas returns [].
|
||||||
|
newArea.facility = facility
|
||||||
|
context.insert(newArea)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,15 @@ struct StartInspectionView: View {
|
|||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
|
@Query(sort: \LocalTemplate.name) private var allTemplates: [LocalTemplate]
|
||||||
@Query(sort: \LocalFacility.name) private var facilities: [LocalFacility]
|
@Query(sort: \LocalFacility.name) private var facilities: [LocalFacility]
|
||||||
|
|
||||||
|
/// Active templates only — inactive templates excluded from the picker.
|
||||||
|
/// Filtered in Swift (not #Predicate) per CLAUDE.md rule 3.
|
||||||
|
private var templates: [LocalTemplate] {
|
||||||
|
allTemplates.filter { $0.isActive }
|
||||||
|
}
|
||||||
|
|
||||||
@State private var selectedTemplateId: Int?
|
@State private var selectedTemplateId: Int?
|
||||||
@State private var selectedProjectId: Int?
|
@State private var selectedProjectId: Int?
|
||||||
@State private var selectedFacilityId: Int?
|
@State private var selectedFacilityId: Int?
|
||||||
|
|||||||
@@ -434,89 +434,151 @@ struct HistoryDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ReadOnlyGridFormView
|
// MARK: - ReadOnlyGridFormView
|
||||||
// Renders a submitted form in the same 12-column grid as ExecuteInspectionView
|
// Renders answered form fields row by row.
|
||||||
// but with all inputs disabled/display-only — no editing allowed.
|
// Each original schema row becomes one HStack; each field is sized
|
||||||
|
// proportionally to its colSpan (out of 12 columns).
|
||||||
|
// Fields that start past col 1 get a leading spacer.
|
||||||
|
// Only rows containing at least one answered field are shown.
|
||||||
|
|
||||||
struct ReadOnlyGridFormView: View {
|
struct ReadOnlyGridFormView: View {
|
||||||
|
|
||||||
let schema: [[String: Any]]
|
let schema: [[String: Any]]
|
||||||
let formValues: [String: String]
|
let formValues: [String: String]
|
||||||
|
|
||||||
private struct ReadOnlyWidthKey: PreferenceKey {
|
// ── Field visibility filtering ────────────────────────────────────────
|
||||||
static var defaultValue: CGFloat = 0
|
|
||||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
// A row group: all visible fields that share the same original `row`.
|
||||||
value = max(value, nextValue())
|
private struct RowGroup {
|
||||||
}
|
let fields: [[String: Any]] // visible fields in this row, schema order
|
||||||
}
|
}
|
||||||
|
|
||||||
static let totalColumns: Int = 12
|
private var visibleRowGroups: [RowGroup] {
|
||||||
static let cellGap: CGFloat = 8
|
let skipTypes: Set<String> = ["label", "section",
|
||||||
static let rowGap: CGFloat = 4
|
"button_submit", "button_print", "button_email"]
|
||||||
static let cellAspect: CGFloat = 52/72
|
|
||||||
static let cardPadding: CGFloat = 16
|
|
||||||
|
|
||||||
@State private var containerWidth: CGFloat = 952
|
// Pass 1 — answered data field IDs
|
||||||
|
var answeredIds = Set<String>()
|
||||||
private var computedCellW: CGFloat {
|
for f in schema {
|
||||||
(containerWidth - 2 * Self.cardPadding
|
guard let ftype = f["type"] as? String, !skipTypes.contains(ftype) else { continue }
|
||||||
- CGFloat(Self.totalColumns - 1) * Self.cellGap)
|
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
|
||||||
/ CGFloat(Self.totalColumns)
|
let val = formValues[fid] ?? ""
|
||||||
}
|
let answered = ftype == "rating" ? (Int(val) ?? 0) > 0 : !val.isEmpty
|
||||||
|
if answered { answeredIds.insert(fid) }
|
||||||
private func canvasHeight() -> CGFloat {
|
|
||||||
let cellH = computedCellW * Self.cellAspect
|
|
||||||
let maxRow = schema.reduce(0) { acc, f in
|
|
||||||
max(acc, (f["row"] as? Int ?? 1) + (f["rowSpan"] as? Int ?? 2) - 1)
|
|
||||||
}
|
}
|
||||||
return CGFloat(maxRow) * cellH + CGFloat(max(maxRow - 1, 0)) * Self.rowGap
|
|
||||||
|
// Pass 2 — label IDs that immediately precede an answered field
|
||||||
|
var visibleLabelIds = Set<String>()
|
||||||
|
var lbuf: [String] = []
|
||||||
|
for f in schema {
|
||||||
|
let ftype = f["type"] as? String ?? ""
|
||||||
|
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
|
||||||
|
if ftype == "label" {
|
||||||
|
lbuf.append(fid)
|
||||||
|
} else if !skipTypes.contains(ftype) {
|
||||||
|
if answeredIds.contains(fid) { visibleLabelIds.formUnion(lbuf) }
|
||||||
|
lbuf.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 3 — section IDs that precede at least one answered field
|
||||||
|
var visibleSectionIds = Set<String>()
|
||||||
|
var pendingSecId: String? = nil
|
||||||
|
for f in schema {
|
||||||
|
let ftype = f["type"] as? String ?? ""
|
||||||
|
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
|
||||||
|
if ftype == "section" {
|
||||||
|
pendingSecId = fid
|
||||||
|
} else if !skipTypes.contains(ftype), answeredIds.contains(fid) {
|
||||||
|
if let sid = pendingSecId { visibleSectionIds.insert(sid); pendingSecId = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 4 — group fields by original row, keep schema order
|
||||||
|
var rowGroups: [Int: [[String: Any]]] = [:]
|
||||||
|
var rowOrder: [Int] = []
|
||||||
|
for f in schema {
|
||||||
|
let row = f["row"] as? Int ?? 1
|
||||||
|
if rowGroups[row] == nil { rowOrder.append(row); rowGroups[row] = [] }
|
||||||
|
rowGroups[row]!.append(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 5 — for each row, collect visible fields; skip rows with none
|
||||||
|
var result: [RowGroup] = []
|
||||||
|
for origRow in rowOrder.sorted() {
|
||||||
|
guard let group = rowGroups[origRow] else { continue }
|
||||||
|
var visibleInRow: [[String: Any]] = []
|
||||||
|
for f in group {
|
||||||
|
let ftype = f["type"] as? String ?? ""
|
||||||
|
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
|
||||||
|
switch ftype {
|
||||||
|
case "button_submit", "button_print", "button_email": continue
|
||||||
|
case "section": if visibleSectionIds.contains(fid) { visibleInRow.append(f) }
|
||||||
|
case "label": if visibleLabelIds.contains(fid) { visibleInRow.append(f) }
|
||||||
|
default: if answeredIds.contains(fid) { visibleInRow.append(f) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !visibleInRow.isEmpty {
|
||||||
|
result.append(RowGroup(fields: visibleInRow))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .topLeading) {
|
if visibleRowGroups.isEmpty {
|
||||||
RoundedRectangle(cornerRadius: 12)
|
Text("No form responses recorded.")
|
||||||
.fill(Color(.secondarySystemBackground))
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(16)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
ForEach(visibleRowGroups.indices, id: \.self) { idx in
|
||||||
|
rowView(visibleRowGroups[idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Color.clear
|
// Render one row as a GeometryReader-based HStack so each field
|
||||||
.frame(maxWidth: .infinity).frame(height: 0)
|
// occupies exactly (colSpan/12) of the available width, and leading
|
||||||
.background(GeometryReader { geo in
|
// space before col > 1 is filled with a transparent spacer.
|
||||||
Color.clear.preference(key: ReadOnlyWidthKey.self, value: geo.size.width)
|
@ViewBuilder
|
||||||
})
|
private func rowView(_ group: RowGroup) -> some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let totalW = geo.size.width
|
||||||
|
let colW = totalW / 12.0
|
||||||
|
let fields = group.fields
|
||||||
|
|
||||||
let cellW = computedCellW
|
ZStack(alignment: .topLeading) {
|
||||||
let cellH = cellW * Self.cellAspect
|
ForEach(fields.indices, id: \.self) { i in
|
||||||
|
let f = fields[i]
|
||||||
|
let col = max(1, f["col"] as? Int ?? 1)
|
||||||
|
let colSpan = max(1, min(f["colSpan"] as? Int ?? 6, 13 - col))
|
||||||
|
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
|
||||||
|
let value = formValues[fid] ?? ""
|
||||||
|
let ftype = f["type"] as? String ?? "text"
|
||||||
|
let label = f["label"] as? String ?? ""
|
||||||
|
|
||||||
ForEach(schema.indices, id: \.self) { idx in
|
ReadOnlyCellView(field: f, value: value, fieldType: ftype, label: label)
|
||||||
let field = schema[idx]
|
.frame(width: colW * CGFloat(colSpan), alignment: .topLeading)
|
||||||
let ftype = field["type"] as? String ?? "text"
|
.offset(x: colW * CGFloat(col - 1))
|
||||||
if !["button_submit", "button_print", "button_email"].contains(ftype) {
|
|
||||||
readOnlyCell(field: field, cellW: cellW, cellH: cellH)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onPreferenceChange(ReadOnlyWidthKey.self) { if $0 > 0 { containerWidth = $0 } }
|
.frame(height: rowHeight(group))
|
||||||
.frame(height: canvasHeight() + 2 * Self.cardPadding)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
// Row height: fixed 36pt for most fields; taller for section headers.
|
||||||
private func readOnlyCell(field: [String: Any], cellW: CGFloat, cellH: CGFloat) -> some View {
|
private func rowHeight(_ group: RowGroup) -> CGFloat {
|
||||||
let col = max(1, field["col"] as? Int ?? 1)
|
let hasSection = group.fields.contains { ($0["type"] as? String) == "section" }
|
||||||
let row = max(1, field["row"] as? Int ?? 1)
|
return hasSection ? 28 : 36
|
||||||
let colSpan = max(1, field["colSpan"] as? Int ?? 6)
|
|
||||||
let rowSpan = max(1, field["rowSpan"] as? Int ?? 2)
|
|
||||||
|
|
||||||
let xOff = CGFloat(col - 1) * (cellW + Self.cellGap) + Self.cardPadding
|
|
||||||
let yOff = CGFloat(row - 1) * (cellH + Self.rowGap) + Self.cardPadding
|
|
||||||
let width = CGFloat(colSpan) * cellW + CGFloat(colSpan - 1) * Self.cellGap
|
|
||||||
let height = CGFloat(rowSpan) * cellH + CGFloat(rowSpan - 1) * Self.rowGap
|
|
||||||
|
|
||||||
let fid = field["id"] as? String ?? (field["id"] as? Int).map(String.init) ?? ""
|
|
||||||
let value = formValues[fid] ?? ""
|
|
||||||
let ftype = field["type"] as? String ?? "text"
|
|
||||||
let label = field["label"] as? String ?? ""
|
|
||||||
|
|
||||||
ReadOnlyCellView(field: field, value: value, fieldType: ftype, label: label)
|
|
||||||
.frame(width: width, height: height, alignment: .topLeading)
|
|
||||||
.offset(x: xOff, y: yOff)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,35 +671,7 @@ struct ReadOnlyCellView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "image":
|
case "image":
|
||||||
if value.hasPrefix("local://") {
|
PhotoThumbnailView(value: value)
|
||||||
// Photo taken on this device — may still be on disk
|
|
||||||
let path = String(value.dropFirst("local://".count))
|
|
||||||
if let img = UIImage(contentsOfFile: path) {
|
|
||||||
Image(uiImage: img)
|
|
||||||
.resizable()
|
|
||||||
.scaledToFit()
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
|
||||||
} else {
|
|
||||||
// Local file cleaned up — show placeholder
|
|
||||||
Label("Photo no longer on device", systemImage: "exclamationmark.triangle")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
} else if value.hasPrefix("uploads/") {
|
|
||||||
// Photo synced to server — load with retry support
|
|
||||||
RetryablePhotoView(
|
|
||||||
url: URL(string: "\(ServerConfig.current)/static/\(value)")
|
|
||||||
)
|
|
||||||
} else if !value.isEmpty {
|
|
||||||
// Unknown path format — generic indicator
|
|
||||||
Label("Photo attached", systemImage: "photo")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
Text("—")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(Color(.tertiaryLabel))
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Text, textarea, number, email, date, select, radio, checkbox_group
|
// Text, textarea, number, email, date, select, radio, checkbox_group
|
||||||
@@ -655,3 +689,80 @@ struct ReadOnlyCellView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - PhotoThumbnailView
|
||||||
|
// Compact thumbnail that fits inside a single grid row (cellAspect 0.5).
|
||||||
|
// Tap opens a fullscreen lightbox sheet.
|
||||||
|
|
||||||
|
struct PhotoThumbnailView: View {
|
||||||
|
let value: String
|
||||||
|
@State private var showLightbox = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if value.hasPrefix("local://") {
|
||||||
|
let path = String(value.dropFirst("local://".count))
|
||||||
|
if let img = UIImage(contentsOfFile: path) {
|
||||||
|
thumbnailButton {
|
||||||
|
Image(uiImage: img)
|
||||||
|
.resizable().scaledToFill()
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showLightbox) {
|
||||||
|
ZStack {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
Image(uiImage: img)
|
||||||
|
.resizable().scaledToFit()
|
||||||
|
}
|
||||||
|
.onTapGesture { showLightbox = false }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Label("No longer on device", systemImage: "exclamationmark.triangle")
|
||||||
|
.font(.system(size: 11)).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else if value.hasPrefix("uploads/") {
|
||||||
|
let url = URL(string: "\(ServerConfig.current)/static/\(value)")
|
||||||
|
thumbnailButton {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let img):
|
||||||
|
img.resizable().scaledToFill()
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||||
|
case .failure:
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.system(size: 14)).foregroundStyle(.secondary)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
default:
|
||||||
|
ProgressView().frame(width: 32, height: 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showLightbox) {
|
||||||
|
ZStack {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
RetryablePhotoView(url: url)
|
||||||
|
}
|
||||||
|
.onTapGesture { showLightbox = false }
|
||||||
|
}
|
||||||
|
} else if !value.isEmpty {
|
||||||
|
Label("Photo attached", systemImage: "photo")
|
||||||
|
.font(.system(size: 11)).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func thumbnailButton<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||||
|
Button { showLightbox = true } label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
content()
|
||||||
|
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user