5 Commits

Author SHA1 Message Date
Trey T d968fc01d0 tests: add populated-state screenshot UITest
Android UI Tests / ui-tests (push) Has been cancelled
Companion to EmptyStateScreenshotUITests: seeds a fresh verified account with
realistic data (residences, tasks across kanban columns, contractors, documents
+ warranties) and screenshots each populated tab. Exploratory visual harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:11:06 -05:00
Trey T 44f712f345 iOS: add biometric / PIN app lock (CL-5)
Android UI Tests / ui-tests (push) Has been cancelled
Gates an authenticated session behind Face ID / Touch ID with a 6-digit PIN
fallback. AppLockManager (Keychain-backed enabled flag + SHA-256 PIN hash, all
via KeychainHelper) arms the lock on scenePhase .background; RootView overlays
LockScreenView above all auth states when locked (the lock screen also serves
as the app-switcher privacy cover). AppLockSettingsView (Profile › App Lock)
toggles it, sets/changes the PIN, and toggles biometrics. NSFaceIDUsageDescription
added. Fully bypassed under UI tests (AppLockManager.isEnabled is false when
UITestRuntime is enabled) so the XCUITest suite is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:25:40 -05:00
Trey T f5a5710b2c iOS: add fastlane config for TestFlight upload
Android UI Tests / ui-tests (push) Has been cancelled
Adds fastlane Appfile/Fastfile (upload_only lane that pushes an already-
exported IPA to TestFlight via the ASC API key) and the generated README.
The ASC .p8 private key stays outside the repo (~/.appstoreconnect); only
its key_id/issuer_id identifiers are referenced. Gitignores the transient
fastlane/report.xml run artifact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:16:49 -05:00
Trey T 713c8d9cbb iOS: absolutely center empty states across every tab
Android UI Tests / ui-tests (push) Has been cancelled
Anchor the empty-state icon/text boundary at the exact vertical center
in the shared OrganicEmptyScreen: the icon's bottom sits 8pt above center
and the text's top 8pt below, so the 16pt gap straddles 50% Y regardless
of icon size or title/subtitle length. Previously the block-center was
centered, so longer copy drifted the icon (~1.7% spread across tabs);
boundary spread is now ~0.1% (pixel-identical on all four tabs).

Supporting changes so each tab renders the empty state alone (full
content area, consistent nav-bar height):
- Tasks: keep toolbar buttons present (disabled) when empty so the inline
  nav bar doesn't collapse and shift content up
- Contractors/Documents: hide search/filter chrome when truly empty
- Residences: restore original copy + frame-fill

Adds EmptyStateScreenshotUITests as a regression guard that captures the
empty state of all four tabs for a fresh verified no-data user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:08:54 -05:00
Trey T c0032ab7e1 Tests: assert verified-by-default API gating
Android UI Tests / ui-tests (push) Has been cancelled
Update AuthGatingAPITests for the backend's new policy (all app-data routes
require a verified email):
- unverified user -> 403 on GET /residences/, /tasks/, /contractors/, /documents/
- unverified user can still reach the sign-up allow-list: GET /auth/me/, and
  public lookups (GET /tasks/categories/)
- verified user -> 200 (positive control)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:49:45 -05:00
19 changed files with 976 additions and 224 deletions
+3
View File
@@ -0,0 +1,3 @@
# fastlane transient run artifact
fastlane/report.xml
+105 -58
View File
@@ -3,30 +3,41 @@ import XCTest
/// Email-verification gating tests against the real backend's `RequireVerified` /// Email-verification gating tests against the real backend's `RequireVerified`
/// middleware. /// 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 /// The app's UI-test mode bypasses email verification, so the rule
/// "an unverified user is blocked from the app" is not exercised by the UI /// "an unverified user is blocked from app data" is not exercised by the UI
/// suite. These tests close that gap by hitting a `RequireVerified`-gated /// suite. These tests close that gap by hitting the gated endpoints directly
/// endpoint directly with a real Kratos session token and asserting the gate /// with a real Kratos session token and asserting the policy:
/// behaves as designed:
/// ///
/// - Test A (negative): an UNVERIFIED account is rejected with **403** by the /// - VERIFIED required (403 for an authenticated-but-unverified user) on the
/// verification gate. /// app-data routes e.g. `GET /residences/`, `GET /tasks/`,
/// - Test B (positive control): a VERIFIED account is NOT blocked (200/list), /// `GET /contractors/`, `GET /documents/`.
/// proving the 403 in Test A is the verification gate and not an unrelated /// - AUTHENTICATED-ONLY (must still work unverified NOT 403):
/// failure. /// `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/`.
/// ///
/// Both run entirely via the API no app launch required. /// 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 { final class AuthGatingAPITests: XCTestCase {
/// `POST /residences/:id/generate-share-code/` is wrapped with the /// App-data endpoints now gated behind email verification. Each is a read
/// `RequireVerified` middleware in the Go router (see /// (GET) so no body is needed; the gate runs before the handler, so an
/// `setupResidenceRoutes` only the share-code/share-package generation /// unverified caller is rejected with 403 even with no seeded data.
/// routes carry the gate). Because the middleware runs BEFORE the handler, private let verifiedGatedDataPaths = [
/// an unverified caller is rejected with 403 regardless of whether the "/residences/",
/// residence id exists making it a clean, setup-free probe of the gate. "/tasks/",
private func gatedPath(residenceId: Int) -> String { "/contractors/",
"/residences/\(residenceId)/generate-share-code/" "/documents/",
} ]
/// Kratos identity emails created during a test, deleted in tearDown. /// Kratos identity emails created during a test, deleted in tearDown.
private var createdEmails: [String] = [] private var createdEmails: [String] = []
@@ -57,11 +68,11 @@ final class AuthGatingAPITests: XCTestCase {
try super.tearDownWithError() try super.tearDownWithError()
} }
// MARK: - Test A: unverified user is blocked // MARK: - Test 01: unverified user is blocked from app data (broad policy)
/// An unverified account must be rejected by the `RequireVerified` gate with /// An authenticated-but-UNVERIFIED account must be rejected by the
/// a 403 when it tries to reach a gated endpoint. /// `RequireVerified` gate with a 403 on every app-data endpoint.
func test01_unverifiedUserIsBlockedWith403() throws { func test01_unverifiedUserBlockedFromAppData() throws {
let runId = UUID().uuidString.prefix(6) let runId = UUID().uuidString.prefix(6)
let email = "authgate_unverified_\(runId)@test.com" let email = "authgate_unverified_\(runId)@test.com"
createdEmails.append(email) createdEmails.append(email)
@@ -75,29 +86,80 @@ final class AuthGatingAPITests: XCTestCase {
return return
} }
// Hit the RequireVerified-gated endpoint with the unverified session // Each gated data endpoint must return 403 for the unverified caller.
// token. The gate runs before the handler, so a non-existent residence // The gate fires before the handler, so no seeded data is required.
// id (999_999) still produces the 403 from the verification middleware for path in verifiedGatedDataPaths {
// rather than a 404 from the handler no residence setup required. let result = TestAccountAPIClient.rawRequest(
let result = TestAccountAPIClient.rawRequest( method: "GET",
method: "POST", path: path,
path: gatedPath(residenceId: 999_999), token: session.token
body: [:], )
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 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( XCTAssertEqual(
result.statusCode, 403, me.statusCode, 200,
"Unverified user should be blocked by the RequireVerified gate with 403, got \(result.statusCode): \(result.errorBody ?? "nil")" "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 B: verified user is not blocked (positive control) // MARK: - Test 03: verified user is not blocked (positive control)
/// A verified account must pass the `RequireVerified` gate i.e. the gated /// A VERIFIED account must pass the gate `GET /residences/` must NOT
/// endpoint must NOT return 403. This proves Test A's 403 is the /// return 403 (it returns 200). This proves Test 01's 403s are the
/// verification gate, not an unrelated rejection. /// verification gate and not an unrelated failure.
func test02_verifiedUserIsNotBlocked() throws { func test03_verifiedUserNotBlocked() throws {
let runId = UUID().uuidString.prefix(6) let runId = UUID().uuidString.prefix(6)
let email = "authgate_verified_\(runId)@test.com" let email = "authgate_verified_\(runId)@test.com"
createdEmails.append(email) createdEmails.append(email)
@@ -111,33 +173,18 @@ final class AuthGatingAPITests: XCTestCase {
return return
} }
// The verified caller must own a real residence so the share-code
// handler (which runs AFTER it passes the gate) returns a clean 200
// rather than a 404. This is what makes it a positive control: it
// exercises the gate AND succeeds past it.
guard let residence = TestAccountAPIClient.createResidence(
token: session.token,
name: "AuthGate Verified \(runId)"
) else {
XCTFail("Verified user should be able to create a residence")
return
}
createdResidences.append((token: session.token, id: residence.id))
let result = TestAccountAPIClient.rawRequest( let result = TestAccountAPIClient.rawRequest(
method: "POST", method: "GET",
path: gatedPath(residenceId: residence.id), path: "/residences/",
body: [:],
token: session.token token: session.token
) )
XCTAssertNotEqual( XCTAssertNotEqual(
result.statusCode, 403, result.statusCode, 403,
"Verified user should NOT be blocked by the RequireVerified gate, but got 403: \(result.errorBody ?? "nil")" "Verified user should NOT be blocked by RequireVerified on GET /residences/, but got 403: \(result.errorBody ?? "nil")"
) )
XCTAssertEqual( XCTAssertEqual(
result.statusCode, 200, result.statusCode, 200,
"Verified user should pass the gate and generate a share code (200), got \(result.statusCode): \(result.errorBody ?? "nil")" "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)
}
}
}
+2
View File
@@ -0,0 +1,2 @@
app_identifier("com.myhoneydue.honeyDue")
team_id("X86BR9WTLD")
+18
View File
@@ -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
+32
View File
@@ -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).
@@ -758,6 +758,7 @@
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts."; 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_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_NSPhotoLibraryUsageDescription = "honeyDue needs photo library access to attach photos to tasks and documents.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -1246,6 +1247,7 @@
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = NO;
INFOPLIST_KEY_NSCameraUsageDescription = "honeyDue needs camera access to take photos of tasks, documents, and receipts."; 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_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_NSPhotoLibraryUsageDescription = "honeyDue needs photo library access to attach photos to tasks and documents.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+125
View File
@@ -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
}
}
}
}
+130
View File
@@ -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") 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 { var body: some View {
ZStack { ZStack {
WarmGradientBackground() WarmGradientBackground()
VStack(spacing: 0) { if isTrueEmpty {
// Search Bar // True-empty: render only the empty state, dead-center of the
OrganicSearchBar(text: $searchText, placeholder: L10n.Contractors.searchPlaceholder) // full screen. No search bar, no filter chips, no offset.
.padding(.horizontal, 16) Group {
.padding(.top, 8) if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") {
OrganicEmptyScreen(
// Active Filters hidden when the list is empty so the empty icon: "person.2.fill",
// placeholder centers in the full screen rather than being title: L10n.Contractors.emptyTitle,
// offset by this header. subtitle: L10n.Contractors.emptyNoFilters
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
) )
}, } else {
emptyContent: { UpgradeFeatureView(
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") { 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 let hasFilters = showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty
OrganicEmptyScreen( OrganicEmptyScreen(
icon: "person.2.fill", icon: "person.2.fill",
title: hasFilters ? L10n.Contractors.emptyFiltered : L10n.Contractors.emptyTitle, title: hasFilters ? L10n.Contractors.emptyFiltered : L10n.Contractors.emptyTitle,
subtitle: hasFilters ? "" : L10n.Contractors.emptyNoFilters subtitle: hasFilters ? "" : L10n.Contractors.emptyNoFilters
) )
} else { },
UpgradeFeatureView( onRefresh: {
triggerKey: "view_contractors", viewModel.loadContractors(forceRefresh: true)
icon: "person.2.fill" 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) .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 { var body: some View {
ZStack { ZStack {
WarmGradientBackground() WarmGradientBackground()
VStack(spacing: 0) { if isTrulyEmpty {
// Segmented Control // Full-screen-centered empty state. No segmented control,
OrganicSegmentedControl(selection: $selectedTab) // search bar, filter chip, Spacers, or offset above it.
.padding(.horizontal, 16) OrganicEmptyScreen(
.padding(.top, 8) icon: "doc.text.viewfinder",
title: L10n.Documents.noWarrantiesFound,
// Search Bar subtitle: L10n.Documents.noWarrantiesMessage
OrganicDocSearchBar(text: $searchText, placeholder: L10n.Documents.searchPlaceholder) )
.padding(.horizontal, 16) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 8) } else {
VStack(spacing: 0) {
// Active Filters // Segmented Control
if selectedCategory != nil || selectedDocType != nil || (selectedTab == .warranties && showActiveOnly) { OrganicSegmentedControl(selection: $selectedTab)
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(.horizontal, 16)
} .padding(.top, 8)
.padding(.vertical, 8)
}
// Content // Search Bar
if selectedTab == .warranties { OrganicDocSearchBar(text: $searchText, placeholder: L10n.Documents.searchPlaceholder)
WarrantiesTabContent( .padding(.horizontal, 16)
viewModel: documentViewModel, .padding(.top, 8)
searchText: searchText
) // Active Filters
} else { if selectedCategory != nil || selectedDocType != nil || (selectedTab == .warranties && showActiveOnly) {
DocumentsTabContent( ScrollView(.horizontal, showsIndicators: false) {
viewModel: documentViewModel, HStack(spacing: 8) {
searchText: searchText 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)) { NavigationLink(destination: Text(L10n.Profile.privacy)) {
Label(L10n.Profile.privacy, systemImage: "lock.shield") 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() .sectionBackground()
@@ -18,30 +18,45 @@ struct ResidencesListView: View {
WarmGradientBackground() WarmGradientBackground()
if let response = viewModel.myResidences { if let response = viewModel.myResidences {
ListAsyncContentView( if response.residences.isEmpty && !viewModel.isLoading {
items: response.residences, // Empty state: render the empty view ALONE, filling the full
isLoading: viewModel.isLoading, // available area (inside NavigationStack/background, respecting
errorMessage: viewModel.errorMessage, // safe area) so SwiftUI centers it dead-center of the screen
content: { residences in // identical to the other tabs. No header chrome, Spacers, or
ResidencesContent(residences: residences) // offsets that would bias the centering.
}, OrganicEmptyScreen(
emptyContent: { imageName: "outline",
OrganicEmptyScreen( title: "Welcome to Your Space",
imageName: "outline", subtitle: "Tap the + icon in the top right\nto add your first property"
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(
onRefresh: { items: response.residences,
viewModel.loadMyResidences(forceRefresh: true) isLoading: viewModel.isLoading,
for await loading in viewModel.$isLoading.values { errorMessage: viewModel.errorMessage,
if !loading { break } 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 { } else if viewModel.isLoading {
DefaultLoadingView() DefaultLoadingView()
} else if let error = viewModel.errorMessage { } else if let error = viewModel.errorMessage {
+14
View File
@@ -124,6 +124,9 @@ class AuthenticationManager: ObservableObject {
isAuthenticated = false isAuthenticated = false
isVerified = 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 // Note: We don't reset onboarding state on logout
// so returning users go to login screen, not onboarding // so returning users go to login screen, not onboarding
@@ -143,6 +146,7 @@ struct RootView: View {
@EnvironmentObject private var themeManager: ThemeManager @EnvironmentObject private var themeManager: ThemeManager
@StateObject private var authManager = AuthenticationManager.shared @StateObject private var authManager = AuthenticationManager.shared
@StateObject private var onboardingState = OnboardingState.shared @StateObject private var onboardingState = OnboardingState.shared
@StateObject private var appLock = AppLockManager.shared
@State private var refreshID = UUID() @State private var refreshID = UUID()
@Binding var deepLinkResetToken: String? @Binding var deepLinkResetToken: String?
@@ -202,7 +206,17 @@ struct RootView: View {
Color.clear Color.clear
.frame(width: 1, height: 1) .frame(width: 1, height: 1)
.accessibilityIdentifier("ui.app.ready") .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 { .task {
// Trigger auth check here, after iOSApp.init() has completed // Trigger auth check here, after iOSApp.init() has completed
// DataManager.initialize(). This avoids the race condition where // DataManager.initialize(). This avoids the race condition where
@@ -193,41 +193,66 @@ struct OrganicEmptyScreen: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isAnimating = false @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 { var body: some View {
ZStack { ZStack {
// Dead-centered placeholder content // Anchor the icon/text boundary at the exact vertical center.
VStack(spacing: OrganicSpacing.comfortable) { GeometryReader { geo in
illustration let topHeight = max(0, geo.size.height / 2 - halfGap)
.accessibilityHidden(true)
VStack(spacing: 12) { VStack(spacing: 0) {
Text(title) // Icon bottom edge ends `halfGap` above the vertical center.
.font(.system(size: 24, weight: .bold, design: .rounded)) VStack(spacing: 0) {
.foregroundColor(Color.appTextPrimary) Spacer(minLength: 0)
.multilineTextAlignment(.center) illustration
.accessibilityHidden(true)
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, 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) // Decorative three-leaf footer (consistent across every empty screen)
VStack { VStack {
+19 -3
View File
@@ -33,6 +33,13 @@ struct AllTasksView: View {
private var isLoadingTasks: Bool { taskViewModel.isLoadingTasks } private var isLoadingTasks: Bool { taskViewModel.isLoadingTasks }
private var tasksError: String? { taskViewModel.tasksError } 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 { private var shouldShowSwipeHint: Bool {
guard let response = tasksResponse, guard let response = tasksResponse,
let firstColumn = response.columns.first else { return false } let firstColumn = response.columns.first else { return false }
@@ -171,7 +178,9 @@ struct AllTasksView: View {
} }
} else if let tasksResponse = tasksResponse { } else if let tasksResponse = tasksResponse {
if hasNoTasks { 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 { if hasResidences {
OrganicEmptyScreen( OrganicEmptyScreen(
icon: "checklist", icon: "checklist",
@@ -186,6 +195,7 @@ struct AllTasksView: View {
} }
} }
) )
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
// No residences: the original action button was disabled and // No residences: the original action button was disabled and
// showed "Add a property first" guidance, so surface that copy // showed "Add a property first" guidance, so surface that copy
@@ -195,6 +205,7 @@ struct AllTasksView: View {
title: L10n.Tasks.noTasksYet, title: L10n.Tasks.noTasksYet,
subtitle: L10n.Tasks.addPropertyFirst subtitle: L10n.Tasks.addPropertyFirst
) )
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} else { } else {
ScrollViewReader { proxy in ScrollViewReader { proxy in
@@ -279,6 +290,11 @@ struct AllTasksView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { 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) { HStack(spacing: 12) {
Button(action: { Button(action: {
loadAllTasks(forceRefresh: true) loadAllTasks(forceRefresh: true)
@@ -289,7 +305,7 @@ struct AllTasksView: View {
.rotationEffect(.degrees(isLoadingTasks ? 360 : 0)) .rotationEffect(.degrees(isLoadingTasks ? 360 : 0))
.animation(isLoadingTasks ? .linear(duration: 0.5).repeatForever(autoreverses: false) : .default, value: isLoadingTasks) .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) .accessibilityIdentifier(AccessibilityIdentifiers.Task.refreshButton)
.accessibilityLabel("Refresh tasks") .accessibilityLabel("Refresh tasks")
@@ -302,7 +318,7 @@ struct AllTasksView: View {
}) { }) {
OrganicToolbarAddButton() OrganicToolbarAddButton()
} }
.disabled((residenceViewModel.myResidences?.residences.isEmpty ?? true) || showAddTask) .disabled(!hasResidences || showAddTask)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton) .accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
.accessibilityLabel("Add new task") .accessibilityLabel("Add new task")
} }
+4
View File
@@ -130,6 +130,10 @@ struct iOSApp: App {
} }
} }
} else if newPhase == .background { } 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 // Refresh widget when app goes to background
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()