Compare commits
7 Commits
a3b684744b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d968fc01d0 | |||
| 44f712f345 | |||
| f5a5710b2c | |||
| 713c8d9cbb | |||
| c0032ab7e1 | |||
| 912888f14c | |||
| 73a60c886d |
@@ -562,6 +562,11 @@ object APILayer {
|
||||
if (result is ApiResult.Success) {
|
||||
DataManager.setTotalSummary(result.data.summary)
|
||||
DataManager.addResidence(result.data.residence)
|
||||
// Proactive refresh — the optimistic addResidence above suppresses
|
||||
// getMyResidences' count-based task invalidation, so fetch fresh
|
||||
// tasks here so the joined residence's tasks appear immediately
|
||||
// without a manual refresh.
|
||||
getTasks(forceRefresh = true)
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
# fastlane transient run artifact
|
||||
fastlane/report.xml
|
||||
@@ -0,0 +1,190 @@
|
||||
import XCTest
|
||||
|
||||
/// Email-verification gating tests against the real backend's `RequireVerified`
|
||||
/// middleware.
|
||||
///
|
||||
/// The backend policy changed so that ALL app-data endpoints now require a
|
||||
/// VERIFIED user (not merely an authenticated one). Previously only the
|
||||
/// share-code generation routes carried the gate; now the broad set of
|
||||
/// app-data reads/writes (residences, tasks, contractors, documents,
|
||||
/// notifications, subscription, …) are all gated behind verification.
|
||||
///
|
||||
/// The app's UI-test mode bypasses email verification, so the rule
|
||||
/// "an unverified user is blocked from app data" is not exercised by the UI
|
||||
/// suite. These tests close that gap by hitting the gated endpoints directly
|
||||
/// with a real Kratos session token and asserting the policy:
|
||||
///
|
||||
/// - VERIFIED required (403 for an authenticated-but-unverified user) on the
|
||||
/// app-data routes — e.g. `GET /residences/`, `GET /tasks/`,
|
||||
/// `GET /contractors/`, `GET /documents/`.
|
||||
/// - AUTHENTICATED-ONLY (must still work unverified — NOT 403):
|
||||
/// `GET /auth/me/`. The sign-up allow-list keeps these reachable so a freshly
|
||||
/// registered, not-yet-verified user can complete onboarding.
|
||||
/// - PUBLIC / lookup GETs (reachable without verification): e.g.
|
||||
/// `GET /tasks/categories/`.
|
||||
///
|
||||
/// All tests run entirely via the API — no app launch required. Because the
|
||||
/// `RequireVerified` middleware fires BEFORE the handler, a 403 is produced for
|
||||
/// an unverified caller regardless of whether any data exists — so the
|
||||
/// negative-path reads need no seeded residences/tasks.
|
||||
final class AuthGatingAPITests: XCTestCase {
|
||||
|
||||
/// App-data endpoints now gated behind email verification. Each is a read
|
||||
/// (GET) so no body is needed; the gate runs before the handler, so an
|
||||
/// unverified caller is rejected with 403 even with no seeded data.
|
||||
private let verifiedGatedDataPaths = [
|
||||
"/residences/",
|
||||
"/tasks/",
|
||||
"/contractors/",
|
||||
"/documents/",
|
||||
]
|
||||
|
||||
/// Kratos identity emails created during a test, deleted in tearDown.
|
||||
private var createdEmails: [String] = []
|
||||
|
||||
/// Residence ids created during a test (verified path), deleted in tearDown.
|
||||
private var createdResidences: [(token: String, id: Int)] = []
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
|
||||
guard TestAccountAPIClient.isBackendReachable() else {
|
||||
throw XCTSkip("Local backend is not reachable at \(TestAccountAPIClient.baseURL)")
|
||||
}
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// True teardown: clean residences first (need their owner's token), then
|
||||
// remove every Kratos identity we provisioned. Both are idempotent and
|
||||
// run even if a test failed mid-way.
|
||||
for res in createdResidences {
|
||||
_ = TestAccountAPIClient.deleteResidence(token: res.token, id: res.id)
|
||||
}
|
||||
createdResidences.removeAll()
|
||||
for email in createdEmails {
|
||||
_ = TestAccountAPIClient.deleteKratosIdentity(email: email)
|
||||
}
|
||||
createdEmails.removeAll()
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
// MARK: - Test 01: unverified user is blocked from app data (broad policy)
|
||||
|
||||
/// An authenticated-but-UNVERIFIED account must be rejected by the
|
||||
/// `RequireVerified` gate with a 403 on every app-data endpoint.
|
||||
func test01_unverifiedUserBlockedFromAppData() throws {
|
||||
let runId = UUID().uuidString.prefix(6)
|
||||
let email = "authgate_unverified_\(runId)@test.com"
|
||||
createdEmails.append(email)
|
||||
|
||||
guard let session = TestAccountAPIClient.createUnverifiedAccount(
|
||||
username: "authgate_unverified_\(runId)",
|
||||
email: email,
|
||||
password: "TestPass123!"
|
||||
) else {
|
||||
XCTFail("Could not create unverified account")
|
||||
return
|
||||
}
|
||||
|
||||
// Each gated data endpoint must return 403 for the unverified caller.
|
||||
// The gate fires before the handler, so no seeded data is required.
|
||||
for path in verifiedGatedDataPaths {
|
||||
let result = TestAccountAPIClient.rawRequest(
|
||||
method: "GET",
|
||||
path: path,
|
||||
token: session.token
|
||||
)
|
||||
XCTAssertEqual(
|
||||
result.statusCode, 403,
|
||||
"Unverified user should be blocked by RequireVerified on GET \(path) with 403, got \(result.statusCode): \(result.errorBody ?? "nil")"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test 02: unverified user can still reach sign-up endpoints
|
||||
|
||||
/// The sign-up allow-list must keep working for an unverified user, so a
|
||||
/// freshly registered account can complete onboarding before verifying.
|
||||
/// `GET /auth/me/` (authenticated-only) and lookup GETs (public) must NOT be
|
||||
/// blocked by the verification gate.
|
||||
func test02_unverifiedUserCanReachSignupEndpoints() throws {
|
||||
let runId = UUID().uuidString.prefix(6)
|
||||
let email = "authgate_signup_\(runId)@test.com"
|
||||
createdEmails.append(email)
|
||||
|
||||
guard let session = TestAccountAPIClient.createUnverifiedAccount(
|
||||
username: "authgate_signup_\(runId)",
|
||||
email: email,
|
||||
password: "TestPass123!"
|
||||
) else {
|
||||
XCTFail("Could not create unverified account")
|
||||
return
|
||||
}
|
||||
|
||||
// `GET /auth/me/` is authenticated-only — an unverified token must still
|
||||
// succeed (200), proving the sign-up allow-list bypasses the gate.
|
||||
let me = TestAccountAPIClient.rawRequest(
|
||||
method: "GET",
|
||||
path: "/auth/me/",
|
||||
token: session.token
|
||||
)
|
||||
XCTAssertNotEqual(
|
||||
me.statusCode, 403,
|
||||
"GET /auth/me/ must NOT be verification-gated for an unverified user, got 403: \(me.errorBody ?? "nil")"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
me.statusCode, 200,
|
||||
"Unverified user should reach GET /auth/me/ (sign-up allow-list), got \(me.statusCode): \(me.errorBody ?? "nil")"
|
||||
)
|
||||
|
||||
// A lookup GET is public reference data — reachable without
|
||||
// verification (not 401/403) even for an unverified caller.
|
||||
let categories = TestAccountAPIClient.rawRequest(
|
||||
method: "GET",
|
||||
path: "/tasks/categories/",
|
||||
token: session.token
|
||||
)
|
||||
XCTAssertNotEqual(
|
||||
categories.statusCode, 401,
|
||||
"Lookup GET /tasks/categories/ should be reachable, got 401: \(categories.errorBody ?? "nil")"
|
||||
)
|
||||
XCTAssertNotEqual(
|
||||
categories.statusCode, 403,
|
||||
"Lookup GET /tasks/categories/ should not be verification-gated, got 403: \(categories.errorBody ?? "nil")"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Test 03: verified user is not blocked (positive control)
|
||||
|
||||
/// A VERIFIED account must pass the gate — `GET /residences/` must NOT
|
||||
/// return 403 (it returns 200). This proves Test 01's 403s are the
|
||||
/// verification gate and not an unrelated failure.
|
||||
func test03_verifiedUserNotBlocked() throws {
|
||||
let runId = UUID().uuidString.prefix(6)
|
||||
let email = "authgate_verified_\(runId)@test.com"
|
||||
createdEmails.append(email)
|
||||
|
||||
guard let session = TestAccountAPIClient.createVerifiedAccount(
|
||||
username: "authgate_verified_\(runId)",
|
||||
email: email,
|
||||
password: "TestPass123!"
|
||||
) else {
|
||||
XCTFail("Could not create verified account")
|
||||
return
|
||||
}
|
||||
|
||||
let result = TestAccountAPIClient.rawRequest(
|
||||
method: "GET",
|
||||
path: "/residences/",
|
||||
token: session.token
|
||||
)
|
||||
XCTAssertNotEqual(
|
||||
result.statusCode, 403,
|
||||
"Verified user should NOT be blocked by RequireVerified on GET /residences/, but got 403: \(result.errorBody ?? "nil")"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
result.statusCode, 200,
|
||||
"Verified user should pass the gate and list residences (200), got \(result.statusCode): \(result.errorBody ?? "nil")"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import XCTest
|
||||
|
||||
/// Exploratory: capture the empty-state of every main tab for a FRESH, verified,
|
||||
/// no-data user (no residence/task/contractor/document). Used to compare empty-
|
||||
/// state vertical/horizontal centering across tabs. Not part of the regular run.
|
||||
final class EmptyStateScreenshotUITests: AuthenticatedUITestCase {
|
||||
// Fresh verified account, NO preconditions -> every tab is empty.
|
||||
// (requiresResidence stays false; nothing is seeded.)
|
||||
|
||||
func test_captureAllTabEmptyStates() {
|
||||
let tabs: [(name: String, nav: () -> Void)] = [
|
||||
("01-Residences", { self.navigateToResidences() }),
|
||||
("02-Tasks", { self.navigateToTasks() }),
|
||||
("03-Contractors",{ self.navigateToContractors() }),
|
||||
("04-Documents", { self.navigateToDocuments() }),
|
||||
]
|
||||
|
||||
for tab in tabs {
|
||||
tab.nav()
|
||||
// Let the screen + any empty-state render fully settle.
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(2.0))
|
||||
let shot = XCTAttachment(screenshot: app.screenshot())
|
||||
shot.name = "EmptyState-\(tab.name)"
|
||||
shot.lifetime = .keepAlways
|
||||
add(shot)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import XCTest
|
||||
|
||||
/// Exploratory companion to `EmptyStateScreenshotUITests`: capture every main
|
||||
/// tab for a FRESH, verified user whose account has been seeded with realistic
|
||||
/// data (residences, tasks across kanban columns, contractors, documents +
|
||||
/// warranties). Used to eyeball how the populated views look. Not part of the
|
||||
/// regular run — kept as a quick visual harness.
|
||||
final class PopulatedStateScreenshotUITests: AuthenticatedUITestCase {
|
||||
|
||||
/// Seed a full, realistic dataset under the fresh account's token BEFORE the
|
||||
/// app logs in, so the post-login fetch loads everything (anything seeded
|
||||
/// after login is invisible until a manual refresh).
|
||||
override func seedAccountPreconditions(_ account: TestAccount) {
|
||||
// --- Residences (two, so the list shows multiple cards) ---
|
||||
let home = TestDataSeeder.createResidenceWithAddress(
|
||||
token: account.token,
|
||||
name: "Maple Street House",
|
||||
street: "412 Maple Street",
|
||||
city: "Austin",
|
||||
state: "TX",
|
||||
postalCode: "78704"
|
||||
)
|
||||
_ = TestDataSeeder.createResidenceWithAddress(
|
||||
token: account.token,
|
||||
name: "Lakeside Cabin",
|
||||
street: "9 Birch Trail",
|
||||
city: "Marble Falls",
|
||||
state: "TX",
|
||||
postalCode: "78654"
|
||||
)
|
||||
|
||||
// --- Tasks (spread across overdue / due-soon / upcoming / no-date) ---
|
||||
TestDataSeeder.createTaskWithDueDate(token: account.token, residenceId: home.id, title: "Clean gutters", daysFromNow: -2)
|
||||
TestDataSeeder.createTaskWithDueDate(token: account.token, residenceId: home.id, title: "Replace HVAC filter", daysFromNow: 3)
|
||||
TestDataSeeder.createTaskWithDueDate(token: account.token, residenceId: home.id, title: "Service water heater", daysFromNow: 12)
|
||||
TestDataSeeder.createTaskWithDueDate(token: account.token, residenceId: home.id, title: "Test smoke detectors", daysFromNow: 30)
|
||||
TestDataSeeder.createTaskWithDueDate(token: account.token, residenceId: home.id, title: "Reseal driveway", daysFromNow: 45)
|
||||
TestDataSeeder.createTask(token: account.token, residenceId: home.id, title: "Touch up exterior paint")
|
||||
|
||||
// --- Contractors (with contact info so cards look complete) ---
|
||||
TestDataSeeder.createContractor(token: account.token, name: "Bob's Plumbing",
|
||||
fields: ["company": "Bob's Plumbing LLC", "phone": "512-555-0142", "email": "bob@bobsplumbing.test"])
|
||||
TestDataSeeder.createContractor(token: account.token, name: "Spark Electric",
|
||||
fields: ["company": "Spark Electric Co", "phone": "512-555-0188", "email": "hello@sparkelectric.test"])
|
||||
TestDataSeeder.createContractor(token: account.token, name: "GreenLeaf Landscaping",
|
||||
fields: ["company": "GreenLeaf Landscaping", "phone": "512-555-0203", "email": "team@greenleaf.test"])
|
||||
|
||||
// --- Documents + Warranties (the Documents tab has both segments) ---
|
||||
TestDataSeeder.createDocument(token: account.token, residenceId: home.id, title: "Home Insurance Policy", documentType: "general")
|
||||
TestDataSeeder.createDocument(token: account.token, residenceId: home.id, title: "Mortgage Agreement", documentType: "general")
|
||||
TestDataSeeder.createDocument(token: account.token, residenceId: home.id, title: "Roof Inspection Report", documentType: "general")
|
||||
TestDataSeeder.createDocument(token: account.token, residenceId: home.id, title: "HVAC System Warranty", documentType: "warranty")
|
||||
TestDataSeeder.createDocument(token: account.token, residenceId: home.id, title: "Refrigerator Warranty", documentType: "warranty")
|
||||
}
|
||||
|
||||
func test_capturePopulatedTabStates() {
|
||||
let tabs: [(name: String, nav: () -> Void)] = [
|
||||
("01-Residences", { self.navigateToResidences() }),
|
||||
("02-Tasks", { self.navigateToTasks() }),
|
||||
("03-Contractors",{ self.navigateToContractors() }),
|
||||
("04-Documents", { self.navigateToDocuments() }),
|
||||
]
|
||||
|
||||
for tab in tabs {
|
||||
tab.nav()
|
||||
// Let the screen's data fetch land and the list render fully.
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(2.5))
|
||||
let shot = XCTAttachment(screenshot: app.screenshot())
|
||||
shot.name = "Populated-\(tab.name)"
|
||||
shot.lifetime = .keepAlways
|
||||
add(shot)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,22 +129,30 @@ final class OnboardingUITests: BaseUITestCase {
|
||||
/// create account → verify email — then confirms the app lands on main tabs,
|
||||
/// which indicates the residence was bootstrapped during onboarding.
|
||||
func testF110_startFreshCreatesResidenceAfterVerification() throws {
|
||||
// QUARANTINED: this end-to-end onboarding flow (register → Kratos verify →
|
||||
// home-profile → first-task → main tabs) is flaky at the verify handoff,
|
||||
// failing at different points across runs. Its unique coverage — a
|
||||
// residence being auto-created during onboarding — is already proven by
|
||||
// OnboardingTaskCacheUITests (register → verify → tasks on residence
|
||||
// detail) and the F101–F108/F111 navigation tests. TODO: harden the
|
||||
// verify-screen handoff and re-enable.
|
||||
throw XCTSkip("Flaky end-to-end onboarding flow; coverage provided by OnboardingTaskCacheUITests + F-series. TODO: harden and re-enable.")
|
||||
// QUARANTINED (after a hardening attempt): the full Start-Fresh → Kratos
|
||||
// verify → main-tabs flow is irreducibly flaky at the register→verify
|
||||
// transition (the verification screen intermittently doesn't appear after
|
||||
// the create-account submit; failures land at different points across
|
||||
// runs). The SAME transition is exercised reliably by
|
||||
// OnboardingTaskCacheUITests (register → verify → tasks), and onboarding
|
||||
// navigation is covered by F101–F108/F111 — so this test's coverage is
|
||||
// fully redundant. Skipping is preferred over a flaky red in the suite.
|
||||
// TODO: stabilize the register→verify handoff (likely an app-side timing
|
||||
// issue between Kratos identity creation and the verify-screen navigation)
|
||||
// and re-enable.
|
||||
throw XCTSkip("Flaky register→verify transition; coverage provided by OnboardingTaskCacheUITests + F-series.")
|
||||
|
||||
try? XCTSkipIf(
|
||||
!TestAccountAPIClient.isBackendReachable(),
|
||||
"Local backend is not reachable — skipping ONB-005"
|
||||
)
|
||||
|
||||
// Generate unique credentials so we don't collide with other test runs
|
||||
// Generate unique credentials so we don't collide with other test runs.
|
||||
// Capture the registered email ONCE into a local `let` and reuse it for
|
||||
// BOTH registration and the Mailpit verification-code lookup — the two
|
||||
// must be byte-for-byte identical or the code read will miss.
|
||||
let creds = TestAccountManager.uniqueCredentials(prefix: "onb005")
|
||||
let email = creds.email
|
||||
let uniqueResidenceName = "ONB005 Home \(Int(Date().timeIntervalSince1970))"
|
||||
|
||||
// Step 1: Navigate Start Fresh flow to the Create Account screen
|
||||
@@ -167,7 +175,7 @@ final class OnboardingUITests: BaseUITestCase {
|
||||
onbUsernameField.focusAndType(creds.username, app: app)
|
||||
|
||||
onbEmailField.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
onbEmailField.focusAndType(creds.email, app: app)
|
||||
onbEmailField.focusAndType(email, app: app)
|
||||
|
||||
onbPasswordField.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
onbPasswordField.focusAndType(creds.password, app: app)
|
||||
@@ -181,11 +189,19 @@ final class OnboardingUITests: BaseUITestCase {
|
||||
createAccountButton.waitForExistenceOrFail(timeout: defaultTimeout)
|
||||
createAccountButton.forceTap()
|
||||
|
||||
// Step 4: Verify email with the debug code
|
||||
// Step 4: Verify email with the real Kratos code from Mailpit.
|
||||
//
|
||||
// This handoff is the historically flaky part. Mirror the proven-robust
|
||||
// sequence from OnboardingTaskCacheUITests: wait for the verification
|
||||
// screen to actually LOAD before reading anything, give the screen's own
|
||||
// onAppear sendCode a brief settle to fire, then read the live code from
|
||||
// Mailpit for the captured `email`.
|
||||
let verificationScreen = VerificationScreen(app: app)
|
||||
// If the create account button was disabled (password fields didn't fill),
|
||||
// we won't reach verification. Check before asserting.
|
||||
let verificationLoaded = verificationScreen.codeField.waitForExistence(timeout: loginTimeout)
|
||||
// Wait for the screen to load (code field OR verify button). If we never
|
||||
// reach it, the form submission stalled (e.g. password fields didn't fill).
|
||||
verificationScreen.waitForLoad(timeout: loginTimeout)
|
||||
let verificationLoaded = verificationScreen.codeField.waitForExistence(timeout: navigationTimeout)
|
||||
|| verificationScreen.verifyButton.waitForExistence(timeout: navigationTimeout)
|
||||
guard verificationLoaded else {
|
||||
// Check if the create account button is still visible (form submission failed)
|
||||
if createAccountButton.exists {
|
||||
@@ -194,15 +210,20 @@ final class OnboardingUITests: BaseUITestCase {
|
||||
XCTFail("Expected verification screen to load")
|
||||
return
|
||||
}
|
||||
|
||||
// The app's onboarding registration uses Kratos's real email verification
|
||||
// flow (NOT the API's DEBUG fixed code). The verify screen's onAppear fires
|
||||
// its OWN sendCode (a fresh Kratos flow), invalidating any earlier code — so
|
||||
// read the live code from Mailpit AFTER the screen has appeared and sent it.
|
||||
// read the live code from Mailpit AFTER the screen has appeared and had a
|
||||
// beat to send it. Reuse the SAME `email` captured at registration so the
|
||||
// lookup addresses the identical inbox.
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(2.0))
|
||||
guard let realCode = TestAccountAPIClient.latestVerificationCode(for: creds.email) else {
|
||||
throw XCTSkip("Could not read Kratos verification code from Mailpit for \(creds.email)")
|
||||
}
|
||||
verificationScreen.enterCode(realCode)
|
||||
let code = TestAccountAPIClient.latestVerificationCode(for: email) ?? ""
|
||||
XCTAssertFalse(
|
||||
code.isEmpty,
|
||||
"No Kratos verification code arrived in Mailpit for \(email)"
|
||||
)
|
||||
verificationScreen.enterCode(code)
|
||||
|
||||
// The Onboarding Verify button is disabled until the 6-digit code commits;
|
||||
// wait for it to enable, then tap. Fall back to the generic submit helper.
|
||||
@@ -238,6 +259,9 @@ final class OnboardingUITests: BaseUITestCase {
|
||||
// which fires the residence-create POST and navigates to the First Task step.
|
||||
// The in-screen "Continue" button has no accessibility identifier and isn't
|
||||
// reliably discoverable, so drive the flow via the identified Skip button.
|
||||
// The skip button can briefly be non-hittable during the verify→homeProfile
|
||||
// screen-in transition, so confirm existence then forceTap() to bypass the
|
||||
// strict hittable check (mirrors OnboardingTaskCacheUITests).
|
||||
let skipButton = app.buttons[AccessibilityIdentifiers.Onboarding.skipButton]
|
||||
if skipButton.waitForExistence(timeout: loginTimeout) {
|
||||
skipButton.forceTap()
|
||||
@@ -247,6 +271,8 @@ final class OnboardingUITests: BaseUITestCase {
|
||||
}
|
||||
|
||||
// Step 5b: First Task — Skip again to complete onboarding and land on main tabs.
|
||||
// A single slow transition shouldn't fail the test: re-confirm the skip
|
||||
// button each time and forceTap, falling back to the submit-tasks button.
|
||||
if firstTaskTitle.waitForExistence(timeout: navigationTimeout) {
|
||||
if skipButton.waitForExistence(timeout: navigationTimeout) {
|
||||
skipButton.forceTap()
|
||||
@@ -255,6 +281,17 @@ final class OnboardingUITests: BaseUITestCase {
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive retry: if a slow transition left us still on the First Task
|
||||
// step, try the skip/submit once more so timing alone doesn't fail us.
|
||||
if firstTaskTitle.waitForExistence(timeout: navigationTimeout)
|
||||
&& !mainTabs.exists && !tabBar.exists {
|
||||
if skipButton.exists {
|
||||
skipButton.forceTap()
|
||||
} else if submitTasksButton.exists {
|
||||
submitTasksButton.forceTap()
|
||||
}
|
||||
}
|
||||
|
||||
let reachedMain = mainTabs.waitForExistence(timeout: loginTimeout)
|
||||
|| tabBar.waitForExistence(timeout: 5)
|
||||
let onbVisible = app.otherElements[UITestID.Root.onboarding].exists
|
||||
|
||||
@@ -154,19 +154,7 @@ final class SharingUITests: AuthenticatedUITestCase {
|
||||
|
||||
// MARK: - Test 03: Shared Tasks Visible in UI
|
||||
|
||||
/// Known issue: After joining a shared residence, the Tasks tab doesn't show
|
||||
/// the shared tasks. The AllTasksView's residenceViewModel uses cached (empty)
|
||||
/// data, which disables the refresh button and prevents task loading.
|
||||
/// Fix: AllTasksView.onAppear should detect residence list changes or use
|
||||
/// DataManager's already-refreshed cache.
|
||||
func test03_sharedTasksVisibleInTasksTab() throws {
|
||||
// Known issue: after a user joins a shared residence, that residence's
|
||||
// tasks (created by the owner) do not appear in the joining user's Tasks
|
||||
// tab even after force-refresh — the residence itself shows, but its
|
||||
// tasks aren't fetched for the joined member. Pre-existing app gap;
|
||||
// skip until the shared-task fetch on join is fixed.
|
||||
throw XCTSkip("App gap: joined member doesn't see the shared residence's tasks in the Tasks tab (residence shows, tasks don't).")
|
||||
|
||||
// Join via UI — this lands on Residences tab which triggers forceRefresh
|
||||
joinResidenceViaUI()
|
||||
|
||||
|
||||
@@ -26,15 +26,23 @@ final class TaskCRUDUITests: AuthenticatedUITestCase {
|
||||
|
||||
override func seedAccountPreconditions(_ account: TestAccount) {
|
||||
super.seedAccountPreconditions(account) // seeds seededResidence (requiresResidence)
|
||||
guard let residence = seededResidence else { return }
|
||||
|
||||
// A residence MUST exist before we can seed the cancelled tasks. The base
|
||||
// populates `seededResidence` when `requiresResidence` is true, but rather
|
||||
// than early-returning (and silently skipping the cancelled-task seeding —
|
||||
// which then makes the uncancel tests SKIP instead of run), guarantee one
|
||||
// here: fall back to seeding a residence directly if it's somehow nil.
|
||||
let residence = seededResidence ?? account.seedResidence(name: "Precondition Home")
|
||||
|
||||
// TASK-010: a cancelled task that the test will uncancel/reopen.
|
||||
// createCancelledTask is non-optional — it XCTFails (and crashes) on a real
|
||||
// API failure, so a genuine break surfaces as a failure, never a silent skip.
|
||||
seededCancelledTask_uncancelFlow = TestDataSeeder.createCancelledTask(
|
||||
token: account.token,
|
||||
residenceId: residence.id
|
||||
)
|
||||
|
||||
// TASK-010 (v2): a named residence+task, cancelled, that the test restores.
|
||||
// TASK-010 (v2): a named task, cancelled, that the test restores.
|
||||
let v2Task = account.seedTask(
|
||||
residenceId: residence.id,
|
||||
title: "Uncancel Me \(Int(Date().timeIntervalSince1970))"
|
||||
@@ -42,6 +50,33 @@ final class TaskCRUDUITests: AuthenticatedUITestCase {
|
||||
seededCancelledTask_uncancelV2 = TestAccountAPIClient.cancelTask(token: account.token, id: v2Task.id) ?? v2Task
|
||||
}
|
||||
|
||||
/// Try to bring a task card into view on the Tasks Kanban by its title.
|
||||
///
|
||||
/// Refreshes via the toolbar button (the Kanban has no pull-to-refresh) and
|
||||
/// swipes the board horizontally, returning the static-text element for the
|
||||
/// card. NOTE: the backend intentionally HIDES cancelled and archived tasks
|
||||
/// from `GET /tasks/` (the board's only data source — see the API's
|
||||
/// `determineExpectedColumn`: cancelled/archived return "" = hidden). So a
|
||||
/// seeded *cancelled* task will never surface here; callers must handle the
|
||||
/// not-found case explicitly.
|
||||
@discardableResult
|
||||
private func revealKanbanTask(titled title: String, maxSwipes: Int = 6) -> XCUIElement {
|
||||
let taskText = app.staticTexts[title]
|
||||
|
||||
refreshTasks()
|
||||
if taskText.waitForExistence(timeout: defaultTimeout) { return taskText }
|
||||
|
||||
let board = app.scrollViews.firstMatch.exists
|
||||
? app.scrollViews.firstMatch
|
||||
: app.collectionViews.firstMatch
|
||||
for _ in 0..<maxSwipes {
|
||||
guard board.exists else { break }
|
||||
board.swipeLeft()
|
||||
if taskText.waitForExistence(timeout: 1.0) { return taskText }
|
||||
}
|
||||
return taskText
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
|
||||
@@ -254,18 +289,26 @@ final class TaskCRUDUITests: AuthenticatedUITestCase {
|
||||
|
||||
func testTASK010_UncancelTaskFlow() throws {
|
||||
// Cancelled task was seeded BEFORE login (seedAccountPreconditions) so the
|
||||
// app's post-login fetch already has it.
|
||||
guard let cancelledTask = seededCancelledTask_uncancelFlow else {
|
||||
throw XCTSkip("Cancelled task precondition was not seeded")
|
||||
}
|
||||
// app's post-login fetch already has it. Seeding is guaranteed (the
|
||||
// precondition seeds a residence then a cancelled task, failing hard on a
|
||||
// real API error), so a nil here is a genuine bug — surface it as a
|
||||
// failure, not a skip.
|
||||
let cancelledTask = try XCTUnwrap(
|
||||
seededCancelledTask_uncancelFlow,
|
||||
"Cancelled task precondition was not seeded — seedAccountPreconditions failed to populate it"
|
||||
)
|
||||
|
||||
navigateToTasks()
|
||||
|
||||
// Pull to refresh until the cancelled task is visible
|
||||
let taskText = app.staticTexts[cancelledTask.title]
|
||||
pullToRefreshUntilVisible(taskText)
|
||||
// The cancelled task is seeded correctly (asserted above), but the backend
|
||||
// intentionally hides cancelled/archived tasks from the Tasks Kanban
|
||||
// (`GET /tasks/`) — the only view this tab exposes. There is currently no
|
||||
// UI affordance to display, let alone uncancel, a cancelled task from the
|
||||
// Tasks screen, so the flow cannot be exercised end-to-end here. Skip with
|
||||
// the real reason (no longer the misleading "not seeded").
|
||||
let taskText = revealKanbanTask(titled: cancelledTask.title)
|
||||
guard taskText.waitForExistence(timeout: defaultTimeout) else {
|
||||
throw XCTSkip("Cancelled task not visible in current view")
|
||||
throw XCTSkip("Cancelled tasks are hidden from the Tasks Kanban by design (backend omits cancelled/archived from GET /tasks/), so there is no UI surface to uncancel from. Seeding succeeded — see seedAccountPreconditions.")
|
||||
}
|
||||
taskText.forceTap()
|
||||
|
||||
@@ -289,17 +332,21 @@ final class TaskCRUDUITests: AuthenticatedUITestCase {
|
||||
func test15_uncancelRestorescancelledTask() throws {
|
||||
// Residence + cancelled task were seeded BEFORE login
|
||||
// (seedAccountPreconditions) so the app loads them on its post-login fetch.
|
||||
guard let task = seededCancelledTask_uncancelV2 else {
|
||||
throw XCTSkip("Cancelled task precondition was not seeded")
|
||||
}
|
||||
// Seeding is guaranteed, so a nil here is a genuine bug — fail, don't skip.
|
||||
let task = try XCTUnwrap(
|
||||
seededCancelledTask_uncancelV2,
|
||||
"Cancelled task precondition was not seeded — seedAccountPreconditions failed to populate it"
|
||||
)
|
||||
|
||||
navigateToTasks()
|
||||
|
||||
// Pull to refresh until the cancelled task is visible
|
||||
let taskText = app.staticTexts[task.title]
|
||||
pullToRefreshUntilVisible(taskText)
|
||||
guard taskText.waitForExistence(timeout: loginTimeout) else {
|
||||
throw XCTSkip("Cancelled task '\(task.title)' not visible — may require a Cancelled filter to be active")
|
||||
// Seeding succeeded (asserted above), but the backend intentionally hides
|
||||
// cancelled/archived tasks from the Tasks Kanban (`GET /tasks/`) — the only
|
||||
// view this tab exposes — so there is no UI surface to uncancel from. Skip
|
||||
// with the real reason (no longer the misleading "not seeded").
|
||||
let taskText = revealKanbanTask(titled: task.title)
|
||||
guard taskText.waitForExistence(timeout: defaultTimeout) else {
|
||||
throw XCTSkip("Cancelled tasks are hidden from the Tasks Kanban by design (backend omits cancelled/archived from GET /tasks/), so there is no UI surface to uncancel from. Seeding succeeded — see seedAccountPreconditions.")
|
||||
}
|
||||
taskText.forceTap()
|
||||
|
||||
@@ -308,9 +355,10 @@ final class TaskCRUDUITests: AuthenticatedUITestCase {
|
||||
NSPredicate(format: "label CONTAINS[c] 'Uncancel' OR label CONTAINS[c] 'Reopen' OR label CONTAINS[c] 'Restore'")
|
||||
).firstMatch
|
||||
|
||||
guard uncancelButton.waitForExistence(timeout: defaultTimeout) else {
|
||||
throw XCTSkip("No uncancel button found — feature may not yet be implemented in UI")
|
||||
}
|
||||
uncancelButton.waitForExistenceOrFail(
|
||||
timeout: defaultTimeout,
|
||||
message: "Uncancel/Reopen/Restore action should be available on a cancelled task"
|
||||
)
|
||||
uncancelButton.forceTap()
|
||||
|
||||
// After uncancelling, the task should no longer show a Cancelled status label
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
app_identifier("com.myhoneydue.honeyDue")
|
||||
team_id("X86BR9WTLD")
|
||||
@@ -0,0 +1,18 @@
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
desc "Upload an already-exported IPA to TestFlight"
|
||||
lane :upload_only do
|
||||
api_key = app_store_connect_api_key(
|
||||
key_id: "H67SQ2QB98",
|
||||
issuer_id: "c1e3de74-20ca-4e4e-8ab4-528c497ac155",
|
||||
key_filepath: File.expand_path("~/.appstoreconnect/private_keys/AuthKey_H67SQ2QB98.p8")
|
||||
)
|
||||
upload_to_testflight(
|
||||
api_key: api_key,
|
||||
ipa: "/tmp/honeydue_export/honeyDue.ipa",
|
||||
skip_waiting_for_build_processing: true,
|
||||
skip_submission: true
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
fastlane documentation
|
||||
----
|
||||
|
||||
# Installation
|
||||
|
||||
Make sure you have the latest version of the Xcode command line tools installed:
|
||||
|
||||
```sh
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||
|
||||
# Available Actions
|
||||
|
||||
## iOS
|
||||
|
||||
### ios upload_only
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios upload_only
|
||||
```
|
||||
|
||||
Upload an already-exported IPA to TestFlight
|
||||
|
||||
----
|
||||
|
||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||
|
||||
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||
|
||||
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||
@@ -21,6 +21,7 @@
|
||||
91A9D5E4A93A022693888B95 /* TestDataSeeder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51B2E73D6FB0BDB53123DDC /* TestDataSeeder.swift */; };
|
||||
99FB08E574AA3B88AD73DEAC /* TestDataCleaner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1579B80B44611651771CC51A /* TestDataCleaner.swift */; };
|
||||
BEF62D0EDC3E9B922195C7ED /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 12403969C38C7CB74B1EA820 /* Foundation.framework */; };
|
||||
BF00F008D3D5E8372B0453C7 /* AuthGatingAPITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21CAC7CEBEF38100CFF2FD2 /* AuthGatingAPITests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -97,6 +98,7 @@
|
||||
AD6CD907CA1045CBBC845D91 /* CompletionCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionCardView.swift; sourceTree = "<group>"; };
|
||||
C51B2E73D6FB0BDB53123DDC /* TestDataSeeder.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TestDataSeeder.swift; path = HoneyDueUITests/Framework/TestDataSeeder.swift; sourceTree = "<group>"; };
|
||||
D70FEF27FDF4EFFACCE83F54 /* TestAccountAPIClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TestAccountAPIClient.swift; path = HoneyDueUITests/Framework/TestAccountAPIClient.swift; sourceTree = "<group>"; };
|
||||
E21CAC7CEBEF38100CFF2FD2 /* AuthGatingAPITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthGatingAPITests.swift; sourceTree = "<group>"; };
|
||||
ECF8E25041D46376FEC29BE2 /* SharingAPITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharingAPITests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -337,6 +339,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ECF8E25041D46376FEC29BE2 /* SharingAPITests.swift */,
|
||||
E21CAC7CEBEF38100CFF2FD2 /* AuthGatingAPITests.swift */,
|
||||
);
|
||||
name = HoneyDueAPITests;
|
||||
path = HoneyDueAPITests;
|
||||
@@ -702,6 +705,7 @@
|
||||
59A92CA8C3A412D8A18338C7 /* TestAccountAPIClient.swift in Sources */,
|
||||
91A9D5E4A93A022693888B95 /* TestDataSeeder.swift in Sources */,
|
||||
99FB08E574AA3B88AD73DEAC /* TestDataCleaner.swift in Sources */,
|
||||
BF00F008D3D5E8372B0453C7 /* AuthGatingAPITests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -754,6 +758,7 @@
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts.";
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "honeyDue uses Face ID to unlock the app.";
|
||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "honeyDue needs permission to save photos to your library.";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs photo library access to attach photos to tasks and documents.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -1242,6 +1247,7 @@
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts.";
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "honeyDue uses Face ID to unlock the app.";
|
||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "honeyDue needs permission to save photos to your library.";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "honeyDue needs photo library access to attach photos to tasks and documents.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
import CryptoKit
|
||||
|
||||
/// App-lock: gates an already-authenticated session behind Face ID / Touch ID
|
||||
/// with a numeric-PIN fallback. Sits ABOVE auth — RootView overlays the lock
|
||||
/// screen when an authenticated user has it enabled and it's armed. All state
|
||||
/// lives in the Keychain (via KeychainHelper). Fully disabled under UI tests so
|
||||
/// the XCUITest suite is never gated by a lock screen.
|
||||
@MainActor
|
||||
final class AppLockManager: ObservableObject {
|
||||
static let shared = AppLockManager()
|
||||
|
||||
static let pinLength = 6
|
||||
|
||||
private enum Key {
|
||||
static let enabled = "app_lock_enabled"
|
||||
static let pinHash = "app_lock_pin_hash"
|
||||
static let biometric = "app_lock_biometric_enabled"
|
||||
}
|
||||
|
||||
/// True when the lock screen should cover the app.
|
||||
@Published private(set) var isLocked: Bool = false
|
||||
|
||||
private let keychain = KeychainHelper.shared
|
||||
|
||||
private init() {
|
||||
// Locked on cold launch if the user enabled it.
|
||||
isLocked = isEnabled
|
||||
}
|
||||
|
||||
/// Whether app-lock is on. Always false under UI tests.
|
||||
var isEnabled: Bool {
|
||||
if UITestRuntime.isEnabled { return false }
|
||||
return keychain.get(key: Key.enabled) == "true"
|
||||
}
|
||||
|
||||
var isBiometricEnabled: Bool {
|
||||
keychain.get(key: Key.biometric) == "true"
|
||||
}
|
||||
|
||||
var biometricAvailable: Bool {
|
||||
LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
|
||||
}
|
||||
|
||||
var biometryType: LABiometryType {
|
||||
let ctx = LAContext()
|
||||
_ = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
|
||||
return ctx.biometryType
|
||||
}
|
||||
|
||||
// MARK: - Enable / disable (settings)
|
||||
|
||||
func enable(pin: String, useBiometrics: Bool) {
|
||||
_ = keychain.save(key: Key.pinHash, value: Self.hash(pin))
|
||||
_ = keychain.save(key: Key.biometric, value: useBiometrics ? "true" : "false")
|
||||
_ = keychain.save(key: Key.enabled, value: "true")
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
func disable() {
|
||||
_ = keychain.delete(key: Key.enabled)
|
||||
_ = keychain.delete(key: Key.pinHash)
|
||||
_ = keychain.delete(key: Key.biometric)
|
||||
isLocked = false
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
func setBiometric(_ on: Bool) {
|
||||
_ = keychain.save(key: Key.biometric, value: on ? "true" : "false")
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
// MARK: - Lock / unlock
|
||||
|
||||
/// Arm the lock when leaving the foreground so returning requires re-auth.
|
||||
/// Called on scenePhase `.background`; the lock screen then also serves as
|
||||
/// the app-switcher privacy cover.
|
||||
func lockOnBackground() {
|
||||
if isEnabled { isLocked = true }
|
||||
}
|
||||
|
||||
/// Unlock on a correct PIN (constant-time compare).
|
||||
@discardableResult
|
||||
func unlock(withPIN pin: String) -> Bool {
|
||||
guard let stored = keychain.get(key: Key.pinHash) else { return false }
|
||||
let ok = Self.constantTimeEquals(Self.hash(pin), stored)
|
||||
if ok { isLocked = false }
|
||||
return ok
|
||||
}
|
||||
|
||||
/// Unlock via Face ID / Touch ID.
|
||||
func unlockWithBiometrics() async -> Bool {
|
||||
let ctx = LAContext()
|
||||
guard ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) else { return false }
|
||||
do {
|
||||
let ok = try await ctx.evaluatePolicy(
|
||||
.deviceOwnerAuthenticationWithBiometrics,
|
||||
localizedReason: String(localized: "Unlock honeyDue"))
|
||||
if ok { isLocked = false }
|
||||
return ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Called on logout — never leave the login screen covered.
|
||||
func clearLockState() {
|
||||
isLocked = false
|
||||
}
|
||||
|
||||
// MARK: - PIN hashing
|
||||
|
||||
private static func hash(_ pin: String) -> String {
|
||||
SHA256.hash(data: Data(pin.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private static func constantTimeEquals(_ a: String, _ b: String) -> Bool {
|
||||
let ab = Array(a.utf8), bb = Array(b.utf8)
|
||||
guard ab.count == bb.count else { return false }
|
||||
var diff: UInt8 = 0
|
||||
for i in 0..<ab.count { diff |= ab[i] ^ bb[i] }
|
||||
return diff == 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Settings screen to enable/disable App Lock, choose biometrics, and set/change
|
||||
/// the PIN. Reached from the Profile/Settings screen.
|
||||
struct AppLockSettingsView: View {
|
||||
@ObservedObject private var lock = AppLockManager.shared
|
||||
@State private var showPinSetup = false
|
||||
@State private var pendingDisable = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle(isOn: Binding(
|
||||
get: { lock.isEnabled },
|
||||
set: { newValue in
|
||||
if newValue {
|
||||
showPinSetup = true // ask for a PIN before enabling
|
||||
} else {
|
||||
pendingDisable = true
|
||||
}
|
||||
}
|
||||
)) {
|
||||
Label("Require unlock", systemImage: "lock.fill")
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
}
|
||||
.tint(Color.appPrimary)
|
||||
} footer: {
|
||||
Text("Lock honeyDue when you leave the app. You'll unlock with \(lock.biometricAvailable ? "Face ID / Touch ID or " : "")your PIN.")
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
|
||||
if lock.isEnabled {
|
||||
Section {
|
||||
if lock.biometricAvailable {
|
||||
Toggle(isOn: Binding(
|
||||
get: { lock.isBiometricEnabled },
|
||||
set: { lock.setBiometric($0) }
|
||||
)) {
|
||||
Label(lock.biometryType == .faceID ? "Use Face ID" : "Use Touch ID",
|
||||
systemImage: lock.biometryType == .faceID ? "faceid" : "touchid")
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
}
|
||||
.tint(Color.appPrimary)
|
||||
}
|
||||
Button {
|
||||
showPinSetup = true
|
||||
} label: {
|
||||
Label("Change PIN", systemImage: "number")
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appBackgroundPrimary)
|
||||
.navigationTitle("App Lock")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(isPresented: $showPinSetup) {
|
||||
PinSetupView { pin in
|
||||
lock.enable(pin: pin, useBiometrics: lock.biometricAvailable ? lock.isBiometricEnabled || !lock.isEnabled : false)
|
||||
showPinSetup = false
|
||||
} onCancel: {
|
||||
showPinSetup = false
|
||||
}
|
||||
}
|
||||
.alert("Turn off App Lock?", isPresented: $pendingDisable) {
|
||||
Button("Turn Off", role: .destructive) { lock.disable() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Your PIN will be removed and honeyDue will no longer lock.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-step PIN entry (enter, then confirm). Calls onDone with the PIN on match.
|
||||
private struct PinSetupView: View {
|
||||
var onDone: (String) -> Void
|
||||
var onCancel: () -> Void
|
||||
|
||||
@State private var first = ""
|
||||
@State private var confirm = ""
|
||||
@State private var confirming = false
|
||||
@State private var mismatch = false
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
private let pinLength = AppLockManager.pinLength
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 24) {
|
||||
Text(confirming ? "Re-enter your PIN" : "Choose a \(pinLength)-digit PIN")
|
||||
.font(.system(size: 18, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.padding(.top, 40)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
ForEach(0..<pinLength, id: \.self) { i in
|
||||
Circle()
|
||||
.strokeBorder(Color.appPrimary, lineWidth: 1.5)
|
||||
.background(Circle().fill(i < current.count ? Color.appPrimary : Color.clear))
|
||||
.frame(width: 15, height: 15)
|
||||
}
|
||||
}
|
||||
|
||||
if mismatch {
|
||||
Text("PINs didn't match. Try again.")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appError)
|
||||
}
|
||||
|
||||
// Hidden field drives entry; tap the dots to focus.
|
||||
TextField("", text: bindingForCurrent)
|
||||
.keyboardType(.numberPad)
|
||||
.focused($focused)
|
||||
.opacity(0.02)
|
||||
.frame(height: 1)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.background(Color.appBackgroundPrimary.ignoresSafeArea())
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { focused = true }
|
||||
.onAppear { focused = true }
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { onCancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var current: String { confirming ? confirm : first }
|
||||
|
||||
private var bindingForCurrent: Binding<String> {
|
||||
confirming
|
||||
? Binding(get: { confirm }, set: { handleConfirm($0) })
|
||||
: Binding(get: { first }, set: { handleFirst($0) })
|
||||
}
|
||||
|
||||
private func handleFirst(_ v: String) {
|
||||
first = String(v.prefix(pinLength).filter(\.isNumber))
|
||||
if first.count == pinLength {
|
||||
confirming = true
|
||||
mismatch = false
|
||||
}
|
||||
}
|
||||
|
||||
private func handleConfirm(_ v: String) {
|
||||
confirm = String(v.prefix(pinLength).filter(\.isNumber))
|
||||
if confirm.count == pinLength {
|
||||
if confirm == first {
|
||||
onDone(first)
|
||||
} else {
|
||||
mismatch = true
|
||||
first = ""
|
||||
confirm = ""
|
||||
confirming = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import SwiftUI
|
||||
import LocalAuthentication
|
||||
|
||||
/// Full-screen lock overlay shown above the authenticated app when AppLock is
|
||||
/// armed. Auto-prompts biometrics (if enabled) and offers a numeric PIN.
|
||||
struct LockScreenView: View {
|
||||
@ObservedObject private var lock = AppLockManager.shared
|
||||
@State private var pin = ""
|
||||
@State private var shake = false
|
||||
@State private var biometricDismissed = false
|
||||
|
||||
private var showKeypad: Bool { biometricDismissed || !lock.isBiometricEnabled }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.appBackgroundPrimary.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 28) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 44))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
|
||||
Text("honeyDue is locked")
|
||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
// PIN progress dots
|
||||
HStack(spacing: 16) {
|
||||
ForEach(0..<AppLockManager.pinLength, id: \.self) { i in
|
||||
Circle()
|
||||
.strokeBorder(Color.appPrimary, lineWidth: 1.5)
|
||||
.background(Circle().fill(i < pin.count ? Color.appPrimary : Color.clear))
|
||||
.frame(width: 15, height: 15)
|
||||
}
|
||||
}
|
||||
.offset(x: shake ? -10 : 0)
|
||||
.animation(.default, value: shake)
|
||||
|
||||
if showKeypad {
|
||||
keypad
|
||||
}
|
||||
|
||||
if lock.isBiometricEnabled {
|
||||
Button {
|
||||
Task { await tryBiometric() }
|
||||
} label: {
|
||||
Label(biometricLabel, systemImage: biometricIcon)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
.task {
|
||||
if lock.isBiometricEnabled { await tryBiometric() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Keypad
|
||||
|
||||
private var keypad: some View {
|
||||
let rows: [[String]] = [["1", "2", "3"], ["4", "5", "6"], ["7", "8", "9"], ["", "0", "<"]]
|
||||
return VStack(spacing: 16) {
|
||||
ForEach(rows.indices, id: \.self) { r in
|
||||
HStack(spacing: 28) {
|
||||
ForEach(rows[r], id: \.self) { key in
|
||||
keyButton(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func keyButton(_ key: String) -> some View {
|
||||
if key.isEmpty {
|
||||
Color.clear.frame(width: 72, height: 72)
|
||||
} else if key == "<" {
|
||||
Button { if !pin.isEmpty { pin.removeLast() } } label: {
|
||||
Image(systemName: "delete.left")
|
||||
.font(.system(size: 22))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
} else {
|
||||
Button { addDigit(key) } label: {
|
||||
Text(key)
|
||||
.font(.system(size: 28, weight: .medium, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.frame(width: 72, height: 72)
|
||||
.background(Circle().fill(Color.appBackgroundSecondary))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addDigit(_ d: String) {
|
||||
guard pin.count < AppLockManager.pinLength else { return }
|
||||
pin.append(d)
|
||||
if pin.count == AppLockManager.pinLength {
|
||||
if lock.unlock(withPIN: pin) {
|
||||
pin = ""
|
||||
} else {
|
||||
shake.toggle()
|
||||
let bad = pin
|
||||
pin = ""
|
||||
// brief haptic-ish reset
|
||||
_ = bad
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func tryBiometric() async {
|
||||
let ok = await lock.unlockWithBiometrics()
|
||||
if !ok { biometricDismissed = true }
|
||||
}
|
||||
|
||||
private var biometricLabel: String {
|
||||
lock.biometryType == .faceID ? String(localized: "Use Face ID") : String(localized: "Use Touch ID")
|
||||
}
|
||||
|
||||
private var biometricIcon: String {
|
||||
lock.biometryType == .faceID ? "faceid" : "touchid"
|
||||
}
|
||||
}
|
||||
@@ -38,78 +38,103 @@ struct ContractorsListView: View {
|
||||
subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors")
|
||||
}
|
||||
|
||||
// True-empty = the user has NO contractors at all (underlying list empty),
|
||||
// not loading and not in an error state. In this case the empty-state view
|
||||
// is rendered ALONE, full-screen centered, with no search bar / filter chips
|
||||
// above it — matching the other tabs.
|
||||
private var isTrueEmpty: Bool {
|
||||
contractors.isEmpty && !viewModel.isLoading && viewModel.errorMessage == nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Search Bar
|
||||
OrganicSearchBar(text: $searchText, placeholder: L10n.Contractors.searchPlaceholder)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
// Active Filters — hidden when the list is empty so the empty
|
||||
// placeholder centers in the full screen rather than being
|
||||
// offset by this header.
|
||||
if (showFavoritesOnly || selectedSpecialty != nil) && !filteredContractors.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
if showFavoritesOnly {
|
||||
OrganicFilterChip(
|
||||
title: L10n.Contractors.favorites,
|
||||
icon: "star.fill",
|
||||
onRemove: { showFavoritesOnly = false }
|
||||
)
|
||||
}
|
||||
|
||||
if let specialty = selectedSpecialty {
|
||||
OrganicFilterChip(
|
||||
title: specialty,
|
||||
onRemove: { selectedSpecialty = nil }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// Content
|
||||
ListAsyncContentView(
|
||||
items: filteredContractors,
|
||||
isLoading: viewModel.isLoading,
|
||||
errorMessage: viewModel.errorMessage,
|
||||
content: { contractorList in
|
||||
OrganicContractorsContent(
|
||||
contractors: contractorList,
|
||||
onToggleFavorite: toggleFavorite
|
||||
if isTrueEmpty {
|
||||
// True-empty: render only the empty state, dead-center of the
|
||||
// full screen. No search bar, no filter chips, no offset.
|
||||
Group {
|
||||
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") {
|
||||
OrganicEmptyScreen(
|
||||
icon: "person.2.fill",
|
||||
title: L10n.Contractors.emptyTitle,
|
||||
subtitle: L10n.Contractors.emptyNoFilters
|
||||
)
|
||||
},
|
||||
emptyContent: {
|
||||
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") {
|
||||
} else {
|
||||
UpgradeFeatureView(
|
||||
triggerKey: "view_contractors",
|
||||
icon: "person.2.fill"
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
// Search Bar
|
||||
OrganicSearchBar(text: $searchText, placeholder: L10n.Contractors.searchPlaceholder)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
// Active Filters — hidden when the list is empty so the empty
|
||||
// placeholder centers in the full screen rather than being
|
||||
// offset by this header.
|
||||
if (showFavoritesOnly || selectedSpecialty != nil) && !filteredContractors.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
if showFavoritesOnly {
|
||||
OrganicFilterChip(
|
||||
title: L10n.Contractors.favorites,
|
||||
icon: "star.fill",
|
||||
onRemove: { showFavoritesOnly = false }
|
||||
)
|
||||
}
|
||||
|
||||
if let specialty = selectedSpecialty {
|
||||
OrganicFilterChip(
|
||||
title: specialty,
|
||||
onRemove: { selectedSpecialty = nil }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// Content
|
||||
ListAsyncContentView(
|
||||
items: filteredContractors,
|
||||
isLoading: viewModel.isLoading,
|
||||
errorMessage: viewModel.errorMessage,
|
||||
content: { contractorList in
|
||||
OrganicContractorsContent(
|
||||
contractors: contractorList,
|
||||
onToggleFavorite: toggleFavorite
|
||||
)
|
||||
},
|
||||
emptyContent: {
|
||||
// Filtered-empty: user has contractors but the current
|
||||
// search / specialty / favorites filter hides them all.
|
||||
// Search bar + chips stay visible above so the user can
|
||||
// clear the filter; this is NOT full-screen centered.
|
||||
let hasFilters = showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty
|
||||
OrganicEmptyScreen(
|
||||
icon: "person.2.fill",
|
||||
title: hasFilters ? L10n.Contractors.emptyFiltered : L10n.Contractors.emptyTitle,
|
||||
subtitle: hasFilters ? "" : L10n.Contractors.emptyNoFilters
|
||||
)
|
||||
} else {
|
||||
UpgradeFeatureView(
|
||||
triggerKey: "view_contractors",
|
||||
icon: "person.2.fill"
|
||||
)
|
||||
},
|
||||
onRefresh: {
|
||||
viewModel.loadContractors(forceRefresh: true)
|
||||
for await loading in viewModel.$isLoading.values {
|
||||
if !loading { break }
|
||||
}
|
||||
},
|
||||
onRetry: {
|
||||
loadContractors()
|
||||
}
|
||||
},
|
||||
onRefresh: {
|
||||
viewModel.loadContractors(forceRefresh: true)
|
||||
for await loading in viewModel.$isLoading.values {
|
||||
if !loading { break }
|
||||
}
|
||||
},
|
||||
onRetry: {
|
||||
loadContractors()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
||||
@@ -45,63 +45,85 @@ struct DocumentsWarrantiesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// True-empty = the account has NO documents and NO warranties at all
|
||||
/// (a fresh account), and we're not mid-load / not showing an error.
|
||||
/// In this case the chrome (segmented control / search / filter) is
|
||||
/// meaningless, so we hide it and center a single empty state in the
|
||||
/// dead middle of the full screen — matching every other main tab.
|
||||
private var isTrulyEmpty: Bool {
|
||||
documentViewModel.documents.isEmpty
|
||||
&& !documentViewModel.isLoading
|
||||
&& documentViewModel.errorMessage == nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Segmented Control
|
||||
OrganicSegmentedControl(selection: $selectedTab)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
// Search Bar
|
||||
OrganicDocSearchBar(text: $searchText, placeholder: L10n.Documents.searchPlaceholder)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
// Active Filters
|
||||
if selectedCategory != nil || selectedDocType != nil || (selectedTab == .warranties && showActiveOnly) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
if selectedTab == .warranties && showActiveOnly {
|
||||
OrganicDocFilterChip(
|
||||
title: L10n.Documents.activeOnly,
|
||||
icon: "checkmark.circle.fill",
|
||||
onRemove: { showActiveOnly = false }
|
||||
)
|
||||
}
|
||||
|
||||
if let category = selectedCategory, selectedTab == .warranties {
|
||||
OrganicDocFilterChip(
|
||||
title: category,
|
||||
onRemove: { selectedCategory = nil }
|
||||
)
|
||||
}
|
||||
|
||||
if let docType = selectedDocType, selectedTab == .documents {
|
||||
OrganicDocFilterChip(
|
||||
title: docType,
|
||||
onRemove: { selectedDocType = nil }
|
||||
)
|
||||
}
|
||||
}
|
||||
if isTrulyEmpty {
|
||||
// Full-screen-centered empty state. No segmented control,
|
||||
// search bar, filter chip, Spacers, or offset above it.
|
||||
OrganicEmptyScreen(
|
||||
icon: "doc.text.viewfinder",
|
||||
title: L10n.Documents.noWarrantiesFound,
|
||||
subtitle: L10n.Documents.noWarrantiesMessage
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
// Segmented Control
|
||||
OrganicSegmentedControl(selection: $selectedTab)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
// Content
|
||||
if selectedTab == .warranties {
|
||||
WarrantiesTabContent(
|
||||
viewModel: documentViewModel,
|
||||
searchText: searchText
|
||||
)
|
||||
} else {
|
||||
DocumentsTabContent(
|
||||
viewModel: documentViewModel,
|
||||
searchText: searchText
|
||||
)
|
||||
// Search Bar
|
||||
OrganicDocSearchBar(text: $searchText, placeholder: L10n.Documents.searchPlaceholder)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
// Active Filters
|
||||
if selectedCategory != nil || selectedDocType != nil || (selectedTab == .warranties && showActiveOnly) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
if selectedTab == .warranties && showActiveOnly {
|
||||
OrganicDocFilterChip(
|
||||
title: L10n.Documents.activeOnly,
|
||||
icon: "checkmark.circle.fill",
|
||||
onRemove: { showActiveOnly = false }
|
||||
)
|
||||
}
|
||||
|
||||
if let category = selectedCategory, selectedTab == .warranties {
|
||||
OrganicDocFilterChip(
|
||||
title: category,
|
||||
onRemove: { selectedCategory = nil }
|
||||
)
|
||||
}
|
||||
|
||||
if let docType = selectedDocType, selectedTab == .documents {
|
||||
OrganicDocFilterChip(
|
||||
title: docType,
|
||||
onRemove: { selectedDocType = nil }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// Content
|
||||
if selectedTab == .warranties {
|
||||
WarrantiesTabContent(
|
||||
viewModel: documentViewModel,
|
||||
searchText: searchText
|
||||
)
|
||||
} else {
|
||||
DocumentsTabContent(
|
||||
viewModel: documentViewModel,
|
||||
searchText: searchText
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,11 @@ struct ProfileTabView: View {
|
||||
NavigationLink(destination: Text(L10n.Profile.privacy)) {
|
||||
Label(L10n.Profile.privacy, systemImage: "lock.shield")
|
||||
}
|
||||
|
||||
NavigationLink(destination: AppLockSettingsView()) {
|
||||
Label("App Lock", systemImage: "lock.fill") // i18n-todo: add L10n.Profile.appLock key
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
}
|
||||
}
|
||||
.sectionBackground()
|
||||
|
||||
|
||||
@@ -337,9 +337,9 @@ class ResidenceViewModel: ObservableObject {
|
||||
|
||||
if result is ApiResultSuccess<JoinResidenceResponse> {
|
||||
self.isLoading = false
|
||||
// APILayer updates DataManager with refreshMyResidences,
|
||||
// which updates DataManagerObservable, which updates our
|
||||
// @Published myResidences via Combine subscription
|
||||
// APILayer.joinWithCode updates the residence cache and also
|
||||
// force-refreshes the tasks cache, so the joined residence's
|
||||
// shared tasks appear immediately via DataManagerObservable.
|
||||
completion(true)
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
|
||||
@@ -18,30 +18,45 @@ struct ResidencesListView: View {
|
||||
WarmGradientBackground()
|
||||
|
||||
if let response = viewModel.myResidences {
|
||||
ListAsyncContentView(
|
||||
items: response.residences,
|
||||
isLoading: viewModel.isLoading,
|
||||
errorMessage: viewModel.errorMessage,
|
||||
content: { residences in
|
||||
ResidencesContent(residences: residences)
|
||||
},
|
||||
emptyContent: {
|
||||
OrganicEmptyScreen(
|
||||
imageName: "outline",
|
||||
title: "Welcome to Your Space",
|
||||
subtitle: "Tap the + icon in the top right\nto add your first property"
|
||||
)
|
||||
},
|
||||
onRefresh: {
|
||||
viewModel.loadMyResidences(forceRefresh: true)
|
||||
for await loading in viewModel.$isLoading.values {
|
||||
if !loading { break }
|
||||
if response.residences.isEmpty && !viewModel.isLoading {
|
||||
// Empty state: render the empty view ALONE, filling the full
|
||||
// available area (inside NavigationStack/background, respecting
|
||||
// safe area) so SwiftUI centers it dead-center of the screen —
|
||||
// identical to the other tabs. No header chrome, Spacers, or
|
||||
// offsets that would bias the centering.
|
||||
OrganicEmptyScreen(
|
||||
imageName: "outline",
|
||||
title: "Welcome to Your Space",
|
||||
subtitle: "Tap the + icon in the top right\nto add your first property"
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ListAsyncContentView(
|
||||
items: response.residences,
|
||||
isLoading: viewModel.isLoading,
|
||||
errorMessage: viewModel.errorMessage,
|
||||
content: { residences in
|
||||
ResidencesContent(residences: residences)
|
||||
},
|
||||
emptyContent: {
|
||||
OrganicEmptyScreen(
|
||||
imageName: "outline",
|
||||
title: "Welcome to Your Space",
|
||||
subtitle: "Tap the + icon in the top right\nto add your first property"
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
},
|
||||
onRefresh: {
|
||||
viewModel.loadMyResidences(forceRefresh: true)
|
||||
for await loading in viewModel.$isLoading.values {
|
||||
if !loading { break }
|
||||
}
|
||||
},
|
||||
onRetry: {
|
||||
viewModel.loadMyResidences()
|
||||
}
|
||||
},
|
||||
onRetry: {
|
||||
viewModel.loadMyResidences()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
} else if viewModel.isLoading {
|
||||
DefaultLoadingView()
|
||||
} else if let error = viewModel.errorMessage {
|
||||
|
||||
@@ -124,6 +124,9 @@ class AuthenticationManager: ObservableObject {
|
||||
isAuthenticated = false
|
||||
isVerified = false
|
||||
|
||||
// Never leave the login screen covered by the app-lock overlay.
|
||||
AppLockManager.shared.clearLockState()
|
||||
|
||||
// Note: We don't reset onboarding state on logout
|
||||
// so returning users go to login screen, not onboarding
|
||||
|
||||
@@ -143,6 +146,7 @@ struct RootView: View {
|
||||
@EnvironmentObject private var themeManager: ThemeManager
|
||||
@StateObject private var authManager = AuthenticationManager.shared
|
||||
@StateObject private var onboardingState = OnboardingState.shared
|
||||
@StateObject private var appLock = AppLockManager.shared
|
||||
@State private var refreshID = UUID()
|
||||
@Binding var deepLinkResetToken: String?
|
||||
|
||||
@@ -202,7 +206,17 @@ struct RootView: View {
|
||||
Color.clear
|
||||
.frame(width: 1, height: 1)
|
||||
.accessibilityIdentifier("ui.app.ready")
|
||||
|
||||
// App-lock overlay — covers everything for an authenticated user
|
||||
// when the lock is armed. Sits above all auth states.
|
||||
if appLock.isLocked && authManager.isAuthenticated && authManager.isVerified {
|
||||
LockScreenView()
|
||||
.transition(.opacity)
|
||||
.zIndex(10)
|
||||
.accessibilityIdentifier("ui.app.locked")
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: appLock.isLocked)
|
||||
.task {
|
||||
// Trigger auth check here, after iOSApp.init() has completed
|
||||
// DataManager.initialize(). This avoids the race condition where
|
||||
|
||||
@@ -193,41 +193,66 @@ struct OrganicEmptyScreen: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var isAnimating = false
|
||||
|
||||
/// Half of the gap between the icon and the text. The icon's bottom sits this
|
||||
/// far ABOVE the vertical center and the text's top this far BELOW it, so the
|
||||
/// 16pt gap is straddled exactly by 50% Y. Anchoring on this boundary (rather
|
||||
/// than the block's center) makes the layout identical on every tab regardless
|
||||
/// of icon size or title/subtitle length.
|
||||
private let halfGap: CGFloat = 8
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Dead-centered placeholder content
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
illustration
|
||||
.accessibilityHidden(true)
|
||||
// Anchor the icon/text boundary at the exact vertical center.
|
||||
GeometryReader { geo in
|
||||
let topHeight = max(0, geo.size.height / 2 - halfGap)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text(title)
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(4)
|
||||
}
|
||||
|
||||
if let actionLabel = actionLabel, let action = action {
|
||||
Button(action: action) {
|
||||
Text(actionLabel)
|
||||
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 14)
|
||||
.background(Capsule().fill(accentColor))
|
||||
VStack(spacing: 0) {
|
||||
// Icon — bottom edge ends `halfGap` above the vertical center.
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
illustration
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: topHeight)
|
||||
|
||||
// The 16pt gap, centered on the vertical midpoint.
|
||||
Color.clear.frame(height: halfGap * 2)
|
||||
|
||||
// Text — top edge starts `halfGap` below the vertical center.
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: 12) {
|
||||
Text(title)
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(4)
|
||||
}
|
||||
|
||||
if let actionLabel = actionLabel, let action = action {
|
||||
Button(action: action) {
|
||||
Text(actionLabel)
|
||||
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 14)
|
||||
.background(Capsule().fill(accentColor))
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.frame(maxWidth: .infinity, alignment: .top)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityElement(children: .combine)
|
||||
|
||||
// Decorative three-leaf footer (consistent across every empty screen)
|
||||
VStack {
|
||||
|
||||
@@ -33,6 +33,13 @@ struct AllTasksView: View {
|
||||
private var isLoadingTasks: Bool { taskViewModel.isLoadingTasks }
|
||||
private var tasksError: String? { taskViewModel.tasksError }
|
||||
|
||||
/// Whether the user belongs to at least one residence. With no residence
|
||||
/// the screen is truly empty (no tasks can exist) so the empty placeholder
|
||||
/// is rendered alone, centered in the full screen — matching every other tab.
|
||||
private var hasResidences: Bool {
|
||||
!(residenceViewModel.myResidences?.residences.isEmpty ?? true)
|
||||
}
|
||||
|
||||
private var shouldShowSwipeHint: Bool {
|
||||
guard let response = tasksResponse,
|
||||
let firstColumn = response.columns.first else { return false }
|
||||
@@ -171,7 +178,9 @@ struct AllTasksView: View {
|
||||
}
|
||||
} else if let tasksResponse = tasksResponse {
|
||||
if hasNoTasks {
|
||||
let hasResidences = !(residenceViewModel.myResidences?.residences.isEmpty ?? true)
|
||||
// Empty state: render the placeholder ALONE, filling the full
|
||||
// available area so SwiftUI centers it identically to every
|
||||
// other tab. No header/action row or Spacers bias the position.
|
||||
if hasResidences {
|
||||
OrganicEmptyScreen(
|
||||
icon: "checklist",
|
||||
@@ -186,6 +195,7 @@ struct AllTasksView: View {
|
||||
}
|
||||
}
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
// No residences: the original action button was disabled and
|
||||
// showed "Add a property first" guidance, so surface that copy
|
||||
@@ -195,6 +205,7 @@ struct AllTasksView: View {
|
||||
title: L10n.Tasks.noTasksYet,
|
||||
subtitle: L10n.Tasks.addPropertyFirst
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
} else {
|
||||
ScrollViewReader { proxy in
|
||||
@@ -279,6 +290,11 @@ struct AllTasksView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
// Keep the toolbar buttons present even when empty — matching the
|
||||
// other tabs. An empty inline toolbar collapses the nav bar, which
|
||||
// makes the content area taller and shifts the empty placeholder
|
||||
// up (it was ~3% higher than the other tabs). Disable add/refresh
|
||||
// when there's no residence yet.
|
||||
HStack(spacing: 12) {
|
||||
Button(action: {
|
||||
loadAllTasks(forceRefresh: true)
|
||||
@@ -289,7 +305,7 @@ struct AllTasksView: View {
|
||||
.rotationEffect(.degrees(isLoadingTasks ? 360 : 0))
|
||||
.animation(isLoadingTasks ? .linear(duration: 0.5).repeatForever(autoreverses: false) : .default, value: isLoadingTasks)
|
||||
}
|
||||
.disabled((residenceViewModel.myResidences?.residences.isEmpty ?? true) || isLoadingTasks)
|
||||
.disabled(!hasResidences || isLoadingTasks)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Task.refreshButton)
|
||||
.accessibilityLabel("Refresh tasks")
|
||||
|
||||
@@ -302,7 +318,7 @@ struct AllTasksView: View {
|
||||
}) {
|
||||
OrganicToolbarAddButton()
|
||||
}
|
||||
.disabled((residenceViewModel.myResidences?.residences.isEmpty ?? true) || showAddTask)
|
||||
.disabled(!hasResidences || showAddTask)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
|
||||
.accessibilityLabel("Add new task")
|
||||
}
|
||||
|
||||
@@ -130,6 +130,10 @@ struct iOSApp: App {
|
||||
}
|
||||
}
|
||||
} else if newPhase == .background {
|
||||
// Arm the app-lock so returning requires re-auth; the lock
|
||||
// screen also serves as the app-switcher privacy cover.
|
||||
AppLockManager.shared.lockOnBackground()
|
||||
|
||||
// Refresh widget when app goes to background
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user