Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
eab22a4
ios: a catalog lists the completed scans the cleanup keeps, and stage…
SamGu-NRX Oct 2, 2026
0d3ba37
ios: Saved scans on the onboarding shares a kept scan after the app i…
SamGu-NRX Oct 2, 2026
aeae9dc
ios: a practice scan's row says its meter was a sample, not that its …
SamGu-NRX Oct 2, 2026
429448d
ios: Saved scans is an opaque full-height column in the app's own typ…
SamGu-NRX Oct 2, 2026
769af49
ios: launch deletes share copies an earlier run left, keeping any mad…
SamGu-NRX Oct 2, 2026
09ed76d
ios: the launch cutoff for old share copies is taken once per process…
SamGu-NRX Oct 2, 2026
96abec1
ios: a partial scan.zip no longer takes a kept place or gets an intac…
SamGu-NRX Oct 2, 2026
897ef2a
ios: bundleName's comment says a completed scan needs a whole packet,…
SamGu-NRX Oct 2, 2026
362700f
ios: a test replaces scan.zip during the archive check and expects it…
SamGu-NRX Oct 2, 2026
95eee1e
ios: each app run stages share copies in its own folder; launch delet…
SamGu-NRX Oct 2, 2026
be89599
Merge remote-tracking branch 'origin/feat/saved-scan-recovery' into f…
SamGu-NRX Oct 2, 2026
74640d5
ios: only a real scan folder is judged by its bundle; a link in its p…
SamGu-NRX Oct 2, 2026
f1767e3
Merge #200 (feat/saved-scan-recovery at 95eee1ed) into the saved-scan…
SamGu-NRX Oct 3, 2026
b826a08
Merge #202 (fix/cleanup-partial-bundles at 74640d5b) into the saved-s…
SamGu-NRX Oct 3, 2026
60e11c2
ios: tests pin share-copy ownership across sheets in one run and acro…
SamGu-NRX Oct 3, 2026
9945acb
ios: a failed upload's note says Saved scans can still share the save…
SamGu-NRX Oct 3, 2026
4a7d4bf
ios: the cleanup deletes an entry only if it is still what it was whe…
SamGu-NRX Oct 3, 2026
041dae0
ios: the README says which scans Saved scans lists and what a partial…
SamGu-NRX Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions ios/HouseScan/HouseScanApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import SwiftUI

@main
struct HouseScanApp: App {
/// Runs once per process, before any window exists: deletes the share copies earlier runs
/// left, whose share sheets can't still be reading them. This run's copies live in its own
/// session folder (`SavedScansLocation.staging`), so they are never among them.
init() {
let staging = SavedScansLocation.staging
Task.detached(priority: .utility) { staging.removeOtherSessions() }
}

var body: some Scene {
WindowGroup {
Group {
Expand Down
9 changes: 7 additions & 2 deletions ios/HouseScan/Runtime/KeyframeStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,18 @@ final class KeyframeStore {
/// listed here, before a newer store can exist, and deleted later off the main actor
/// (`ScanFolderCleanup`), so a deletion that runs late can't take a newer scan's folder.
init() {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let scans = caches.appending(path: "Scans", directoryHint: .isDirectory)
let scans = Self.scansRoot
directory = scans.appending(path: UUID().uuidString, directoryHint: .isDirectory)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
Self.delete(ScanFolderCleanup(root: scans, keeping: directory.lastPathComponent))
}

/// Caches/Scans: one folder per scan. Saved scans lists the completed ones here.
nonisolated static var scansRoot: URL {
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appending(path: "Scans", directoryHint: .isDirectory)
}

func nextKeyframeIndex() -> Int {
defer { nextIndex += 1 }
return nextIndex
Expand Down
23 changes: 23 additions & 0 deletions ios/HouseScan/UI/SavedScans/ActivitySheet.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import SwiftUI
import UIKit

/// The system share sheet for one file, reporting when it is done with it.
///
/// `ShareLink` needs its file to exist before it is tapped and never says when the sheet lets go
/// of it. Saved scans makes its copy only after the tap, and deletes the copy once the sheet
/// finishes, so it needs `UIActivityViewController`'s completion.
struct ActivitySheet: UIViewControllerRepresentable {
let file: URL
/// Called once the share completes or is cancelled; the file is no longer read after this.
let onFinish: @MainActor () -> Void

func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(activityItems: [file], applicationActivities: nil)
// UIKit calls this on the main thread; the hop keeps it correct whatever the handler's
// declared isolation.
controller.completionWithItemsHandler = { _, _, _, _ in Task { @MainActor in onFinish() } }
return controller
}

func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
}
97 changes: 97 additions & 0 deletions ios/HouseScan/UI/SavedScans/SavedScanRow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import HouseScanKit
import SwiftUI

/// One saved scan: when its bundle was saved, its size, a Practice tag when the stamp says so, and
/// its Share button. Nothing in the row reads as a result or a place on a wall.
struct SavedScanRow: View {
let scan: SavedScan
/// This scan's copy is being made for the share sheet.
let isPreparing: Bool
/// Some scan's copy is being made; Share waits for it.
let isBusy: Bool
let onShare: () -> Void

@Environment(\.dynamicTypeSize) private var typeSize

var body: some View {
let layout = typeSize.isAccessibilitySize
? AnyLayout(VStackLayout(alignment: .leading, spacing: 12))
: AnyLayout(HStackLayout(alignment: .center, spacing: 14))
layout {
HStack(alignment: .top, spacing: 14) {
Image(systemName: scan.practice == true ? "wrench.and.screwdriver.fill" : "doc.zipper")
.font(.title3.weight(.semibold))
.foregroundStyle(scan.practice == true ? Palette.ink : Palette.signal)
.frame(width: 44, height: 44)
.background(scan.practice == true ? Palette.caution : Palette.signal.opacity(0.1), in: .circle)
.accessibilityHidden(true)
details
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
shareButton
}
.padding(.vertical, 6)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("savedScan.\(scan.id)")
}

private var details: some View {
VStack(alignment: .leading, spacing: 4) {
Text(scan.savedAt.map { SavedScansCopy.saved($0) } ?? SavedScansCopy.unknownTime)
.font(Typeface.hint.weight(.semibold))
.foregroundStyle(Palette.ink)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 6) {
if scan.practice == true {
Text(SavedScansCopy.practice)
.font(Typeface.caption)
.foregroundStyle(Palette.ink)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Palette.caution, in: .capsule)
.accessibilityIdentifier("savedScan.practice")
}
Text(scan.byteCount, format: .byteCount(style: .file))
.font(.footnote)
.fixedSize(horizontal: false, vertical: true)
.foregroundStyle(Palette.muted)
.monospacedDigit()
}
if scan.practice == true {
Text(SavedScansCopy.practiceDetail)
.font(.footnote)
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
}
}
}

/// The quiet button's look (`QuietButtonStyle`) at row size: a tinted capsule, 44 pt tall.
private var shareButton: some View {
Button(action: onShare) {
HStack(spacing: 6) {
if isPreparing {
ProgressView()
.controlSize(.small)
.tint(Palette.signalText)
} else {
Image(systemName: "square.and.arrow.up")
}
Text(SavedScansCopy.share)
}
.font(Typeface.caption)
.foregroundStyle(Palette.signalText)
.padding(.horizontal, 16)
.frame(minHeight: Metrics.minTarget)
.background(Palette.signal.opacity(0.1), in: .capsule)
.contentShape(.capsule)
.opacity(isBusy && !isPreparing ? 0.45 : 1)
}
.buttonStyle(PressableStyle())
.disabled(isBusy)
.accessibilityLabel(SavedScansCopy.share)
.accessibilityHint("Opens the share sheet for this scan")
.accessibilityIdentifier("action.shareSavedScan")
}
}
33 changes: 33 additions & 0 deletions ios/HouseScan/UI/SavedScans/SavedScansButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import SwiftUI

/// The way into Saved scans, in the onboarding's top bar. Quiet like the Skip beside it: the
/// onboarding's one prominent action stays starting a new scan.
///
/// The button only asks for the sheet; the screen holds it (`savedScansSheet(isPresented:)`). The
/// top bar swaps layouts at accessibility text sizes, which makes a new button, and a sheet owned
/// by the button closed whenever the text size changed under it.
struct SavedScansButton: View {
let action: () -> Void

var body: some View {
Button(action: action) {
Label(SavedScansCopy.entry, systemImage: "tray.full")
.font(Typeface.hint.weight(.semibold))
.foregroundStyle(Palette.signalText)
.fixedSize(horizontal: false, vertical: true)
.frame(minWidth: Metrics.minTarget, minHeight: Metrics.minTarget)
.contentShape(.rect)
}
.buttonStyle(PressableStyle())
.accessibilityIdentifier("action.savedScans")
}
}

extension View {
/// Saved scans, presented from a view that stays put while the text size changes.
func savedScansSheet(isPresented: Binding<Bool>) -> some View {
sheet(isPresented: isPresented) {
SavedScansSheet(catalog: SavedScansLocation.catalog(), staging: SavedScansLocation.staging)
}
}
}
62 changes: 62 additions & 0 deletions ios/HouseScan/UI/SavedScans/SavedScansCopy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Foundation
import HouseScanKit

/// The words of Saved scans. A saved scan can be shared and nothing else: none of these lines may
/// suggest it can be reopened, continued or rechecked.
enum SavedScansCopy {
static let title = "Saved scans"
static let entry = "Saved scans"

/// How many completed scans the phone keeps, in words, from the cleanup's own count.
static var kept: String {
let count = ScanFolderCleanup.defaultKeepCompleted
return count == 1 ? "your most recent finished scan" : "your \(count) most recent finished scans"
}

static var intro: String {
"This phone keeps \(kept). You can send one to the House Scan team. A saved scan can't be reopened or continued."
}

static let shareFooter = "Sharing sends the scan's photos and measurements. Nothing leaves your phone until you choose where to send it."

static let emptyTitle = "No saved scans"
static var emptyDetail: String {
"When you finish a scan, it's saved here so you can share it later. This phone keeps \(kept)."
}

static let share = "Share"
static let practice = "Practice"
/// Under a practice scan: a drawn sample stood in for the meter and its close-up, so the
/// meter's place and number describe no real meter. The wall walk itself was real.
static let practiceDetail = "Practice scan with a sample meter"
static let unknownTime = "Saved time unknown"

static func saved(_ date: Date, now: Date = .now, calendar: Calendar = .current) -> String {
let time = date.formatted(date: .omitted, time: .shortened)
if calendar.isDate(date, inSameDayAs: now) { return "Saved today at \(time)" }
if let yesterday = calendar.date(byAdding: .day, value: -1, to: now), calendar.isDate(date, inSameDayAs: yesterday) {
return "Saved yesterday at \(time)"
}
let sameYear = calendar.component(.year, from: date) == calendar.component(.year, from: now)
let day = sameYear
? date.formatted(.dateTime.month(.abbreviated).day())
: date.formatted(.dateTime.month(.abbreviated).day().year())
return "Saved \(day) at \(time)"
}

static func problemTitle(_ error: SavedScanShareError) -> String {
switch error {
case .missing: "This scan is no longer on this phone"
case .damaged: "This scan can't be shared"
case .copyFailed: "Couldn't get the scan ready"
}
}

static func problemDetail(_ error: SavedScanShareError) -> String {
switch error {
case .missing: "A newer scan replaced it, or the phone cleared space. The list now shows what's left."
case .damaged: "Its file was not saved completely. The list now shows the scans that can be shared."
case .copyFailed: "Your phone may be low on storage. Free up some space, then try again."
}
}
}
24 changes: 24 additions & 0 deletions ios/HouseScan/UI/SavedScans/SavedScansLocation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Foundation
import HouseScanKit

/// Where Saved scans reads scans from and stages the copies it shares.
enum SavedScansLocation {
/// The scan store's folder (`KeyframeStore.scansRoot`). Debug builds take
/// `-savedScansRoot <folder>` instead, so a UI test can list synthetic scans it wrote; the
/// argument only changes which folder is listed, never what the store writes or deletes.
static func catalog(arguments: [String] = ProcessInfo.processInfo.arguments) -> SavedScanCatalog {
#if DEBUG
if let index = arguments.firstIndex(of: "-savedScansRoot"), index + 1 < arguments.count {
return SavedScanCatalog(root: URL(fileURLWithPath: arguments[index + 1], isDirectory: true))
}
#endif
return SavedScanCatalog(root: KeyframeStore.scansRoot)
}

/// In the app's temporary folder, apart from the scan folders, so the store's cleanup never
/// sees a copy and a copy never outlives the system's own tmp purge. One session per process:
/// a static is made once.
static let staging = SavedScanStaging(
root: FileManager.default.temporaryDirectory.appending(path: "SavedScanShares", directoryHint: .isDirectory),
session: UUID().uuidString)
}
Loading
Loading