Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 35 additions & 52 deletions OSA/Features/Home/HomeScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ struct HomeScreen: View {
private var highContrastMode = AccessibilitySettings.highContrastModeDefault

@State private var spotlightMode: SpotlightMode = .quickCards
@State private var connectivity: ConnectivityState = .offline
@State private var quickCardsState: HomeSectionState<[QuickCard]> = .loading
@State private var feedState: HomeSectionState<[HomeFeedItem]> = .loading
@State private var pinnedState: HomeSectionState<[HomePinnedItem]> = .loading
Expand All @@ -42,15 +41,15 @@ struct HomeScreen: View {
@State private var notesState: HomeSectionState<[NoteRecord]> = .loading
@State private var readinessSnapshot: SupplyReadinessSnapshot?
@State private var showEmergencyMode = false
@State private var connectivityNotice: ConnectivityStatusNotice?
@State private var connectivityNoticeDismissTask: Task<Void, Never>?
@State private var hasLoadedInitially = false
@State private var connectivityPresenter = ConnectivityNoticePresenter()

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.lg) {
HomeHeaderView(connectivity: connectivity, onEmergencyModeTapped: openEmergencyMode)
if let connectivityNotice {
ConnectivityStatusCallout(notice: connectivityNotice)
HomeHeaderView(connectivity: connectivityPresenter.connectivity, onEmergencyModeTapped: openEmergencyMode)
if let notice = connectivityPresenter.notice {
ConnectivityStatusCallout(notice: notice)
}
HomeReadinessSectionView(readinessSnapshot: readinessSnapshot)
HomeWeeklyDrillSectionView(state: weeklyDrillState)
Expand All @@ -73,24 +72,38 @@ struct HomeScreen: View {
.navigationTitle("Home")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadDashboard)
.task { await observeConnectivity() }
.task {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ LOW RISK

Suggestion: Use the id parameter on the .task modifier to keep the presenter's motion settings in sync with system changes (e.g., .task(id: accessibilityReduceMotion)).

connectivityPresenter.updateReduceMotion(accessibilityReduceMotion)
guard let service = connectivityService else { return }
await connectivityPresenter.observe(service: service) { state, previous in
Self.homeConnectivityNotice(for: state, previousState: previous)
}
}
.refreshable { await refreshDashboard() }
.fullScreenCover(isPresented: $showEmergencyMode) {
EmergencyModeView()
}
.onDisappear {
connectivityNoticeDismissTask?.cancel()
connectivityPresenter.cancelDismissTask()
}
}

private func loadDashboard() {
reloadLocalSections()
if hasLoadedInitially {
// On tab re-entry, refresh only sections whose underlying data may
// have changed (checklists, inventory, notes, readiness). Quick cards
// keep their stable shuffle to avoid visual churn.
reloadMutableSections()
} else {
hasLoadedInitially = true
reloadAllSections()
}
if spotlightMode == .feed, case .loading = feedState {
Task { await loadFeed() }
}
}

private func reloadLocalSections() {
private func reloadAllSections() {
loadQuickCards()
loadWeeklyDrill()
loadPinnedContent()
Expand All @@ -101,8 +114,18 @@ struct HomeScreen: View {
loadRecentNotes()
}

/// Refreshes only the sections backed by user-mutable data.
/// Quick cards and weekly drill keep their stable shuffle.
private func reloadMutableSections() {
loadPinnedContent()
loadActiveChecklists()
loadReadinessSnapshot()
loadInventoryReminders()
loadRecentNotes()
}

private func refreshDashboard() async {
reloadLocalSections()
reloadAllSections()
if spotlightMode == .feed {
await loadFeed()
}
Expand Down Expand Up @@ -496,42 +519,7 @@ struct HomeScreen: View {
}
}

private func observeConnectivity() async {
guard let service = connectivityService else { return }
var previousState: ConnectivityState?
for await state in service.stateStream() {
handleConnectivityChange(from: previousState, to: state)
previousState = state
}
}

private func handleConnectivityChange(from previousState: ConnectivityState?, to newState: ConnectivityState) {
guard previousState != newState else { return }
withAnimation(connectivityAnimation) {
connectivity = newState
}
presentConnectivityNotice(homeConnectivityNotice(for: newState, previousState: previousState))
}

private func presentConnectivityNotice(_ notice: ConnectivityStatusNotice?) {
connectivityNoticeDismissTask?.cancel()

withAnimation(connectivityAnimation) {
connectivityNotice = notice
}

guard let notice, notice.autoDismisses else { return }

connectivityNoticeDismissTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(4))
guard !Task.isCancelled, connectivityNotice == notice else { return }
withAnimation(connectivityAnimation) {
connectivityNotice = nil
}
}
}

private func homeConnectivityNotice(
private static func homeConnectivityNotice(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM RISK

The method homeConnectivityNotice has a cyclomatic complexity of 9 (threshold 8). Given HomeScreen.swift is flagged as an uncovered complex file, this logic should be simplified to reduce maintenance risk. Refactor to use a switch statement or a declarative mapping for connectivity state transitions.

See Issue in Codacy

for state: ConnectivityState,
previousState: ConnectivityState?
) -> ConnectivityStatusNotice? {
Expand Down Expand Up @@ -570,11 +558,6 @@ struct HomeScreen: View {
}
}

private var connectivityAnimation: Animation {
accessibilityReduceMotion
? .easeOut(duration: 0.12)
: .easeInOut(duration: 0.2)
}
}

#Preview {
Expand Down
21 changes: 16 additions & 5 deletions OSA/Features/Home/HomeSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,24 @@ func evaluateSupplyReadiness(
var missingCriticalCount = 0
var nearExpiryCount = 0

// Pre-compute the expiry threshold once instead of per-item.
let nearExpiryThreshold = Calendar.current.date(byAdding: .day, value: 30, to: Date()) ?? .distantFuture

// Group inventory by category for O(1) lookup instead of scanning the
// full list for every template item.
let inventoryByCategory = Dictionary(grouping: inventory, by: \.category)

for templateItem in template.items {
let targetQuantity = templateItem.targetQuantity * (templateItem.scalesWithHouseholdSize ? householdSize : 1)
let matches = inventory.filter { item in
guard item.category == templateItem.inventoryCategory else { return false }
let categoryItems = inventoryByCategory[templateItem.inventoryCategory] ?? []

// Pre-lowercase keywords once per template item.
let lowercasedKeywords = templateItem.matchKeywords.map { $0.lowercased() }

let matches = categoryItems.filter { item in
guard !lowercasedKeywords.isEmpty else { return true }
let searchableText = "\(item.name) \(item.notes) \(item.location)".lowercased()
return templateItem.matchKeywords.isEmpty
|| templateItem.matchKeywords.contains { searchableText.contains($0.lowercased()) }
return lowercasedKeywords.contains { searchableText.contains($0) }
}

let matchedQuantity = matches.reduce(0) { $0 + $1.quantity }
Expand All @@ -79,7 +90,7 @@ func evaluateSupplyReadiness(

nearExpiryCount += matches.filter {
guard let expiryDate = $0.expiryDate else { return false }
return expiryDate <= Calendar.current.date(byAdding: .day, value: 30, to: Date()) ?? .distantFuture
return expiryDate <= nearExpiryThreshold
}.count
}

Expand Down
74 changes: 18 additions & 56 deletions OSA/Features/Settings/SettingsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,14 @@ struct SettingsScreen: View {
private var isRSSDiscoveryEnabled = DiscoverySettings.isRSSDiscoveryEnabledDefault
@AppStorage(DiscoverySettings.lastDiscoveryDateKey)
private var lastDiscoveryTimestamp = 0.0
@State private var connectivity: ConnectivityState = .offline
@State private var braveSearchAPIKey: String
@State private var isDiscovering = false
@State private var lastDiscoveryMessage: String?
@State private var lastDiscoveryMessageColor: Color = .secondary
@State private var credentialErrorMessage: String?
@State private var contacts: [EmergencyContact] = []
@State private var contactEditor: EmergencyContactEditorState?
@State private var connectivityNotice: ConnectivityStatusNotice?
@State private var connectivityNoticeDismissTask: Task<Void, Never>?
@State private var connectivityPresenter = ConnectivityNoticePresenter()
@State private var inventoryAlertAuthorizationStatus: InventoryNotificationAuthorizationStatus = .notDetermined
@State private var isUpdatingInventoryAlerts = false
private let braveSearchCredentialStore: BraveSearchCredentialStore
Expand Down Expand Up @@ -74,7 +72,11 @@ struct SettingsScreen: View {
.task {
loadContacts()
await refreshInventoryAlertAuthorizationStatus()
await observeConnectivity()
connectivityPresenter.updateReduceMotion(accessibilityReduceMotion)
guard let service = connectivityService else { return }
await connectivityPresenter.observe(service: service) { state, previous in
Self.settingsConnectivityNotice(for: state, previousState: previous)
}
}
.onChange(of: braveSearchAPIKey) { _, newValue in
persistBraveSearchAPIKey(newValue)
Expand Down Expand Up @@ -108,7 +110,7 @@ struct SettingsScreen: View {
}
}
.onDisappear {
connectivityNoticeDismissTask?.cancel()
connectivityPresenter.cancelDismissTask()
}
}

Expand Down Expand Up @@ -279,12 +281,12 @@ struct SettingsScreen: View {
@ViewBuilder
private var connectivitySection: some View {
Section("Connectivity") {
if let connectivityNotice {
ConnectivityStatusCallout(notice: connectivityNotice)
if let notice = connectivityPresenter.notice {
ConnectivityStatusCallout(notice: notice)
}

LabeledContent("Status") {
ConnectivityBadge(state: connectivity)
ConnectivityBadge(state: connectivityPresenter.connectivity)
}

LabeledContent("Last Discovery Run") {
Expand Down Expand Up @@ -414,7 +416,7 @@ struct SettingsScreen: View {
lastDiscoveryMessageColor = .osaCritical
return
}
guard connectivity == .onlineUsable else {
guard connectivityPresenter.connectivity == .onlineUsable else {
lastDiscoveryMessage = "Connect to the internet to discover new content."
lastDiscoveryMessageColor = .secondary
hapticFeedbackService?.play(.warning)
Expand Down Expand Up @@ -455,22 +457,6 @@ struct SettingsScreen: View {
}
}

private func observeConnectivity() async {
guard let service = connectivityService else { return }
var previousState: ConnectivityState?
for await state in service.stateStream() {
handleConnectivityChange(from: previousState, to: state)
previousState = state
}
}

private func handleConnectivityChange(from previousState: ConnectivityState?, to newState: ConnectivityState) {
guard previousState != newState else { return }
withAnimation(connectivityAnimation) {
connectivity = newState
}
presentConnectivityNotice(settingsConnectivityNotice(for: newState, previousState: previousState))
}

private func refreshInventoryAlertAuthorizationStatus() async {
guard let inventoryExpiryNotificationService else {
Expand Down Expand Up @@ -539,25 +525,7 @@ struct SettingsScreen: View {
}
}

private func presentConnectivityNotice(_ notice: ConnectivityStatusNotice?) {
connectivityNoticeDismissTask?.cancel()

withAnimation(connectivityAnimation) {
connectivityNotice = notice
}

guard let notice, notice.autoDismisses else { return }

connectivityNoticeDismissTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(4))
guard !Task.isCancelled, connectivityNotice == notice else { return }
withAnimation(connectivityAnimation) {
connectivityNotice = nil
}
}
}

private func settingsConnectivityNotice(
private static func settingsConnectivityNotice(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM RISK

Similar to the home screen, the settingsConnectivityNotice method is overly complex at a CCN of 9. Extract the logic for determining the ConnectivityStatusNotice into a dedicated mapper or use a switch statement on the state transitions to improve maintainability.

See Issue in Codacy

for state: ConnectivityState,
previousState: ConnectivityState?
) -> ConnectivityStatusNotice? {
Expand Down Expand Up @@ -635,7 +603,7 @@ struct SettingsScreen: View {
}

private var connectivitySupportText: String {
switch connectivity {
switch connectivityPresenter.connectivity {
case .offline:
"OSA remains fully usable offline. Connectivity only affects optional discovery and refresh."
case .onlineConstrained:
Expand All @@ -648,7 +616,7 @@ struct SettingsScreen: View {
}

private var discoveryAvailabilityText: String {
switch connectivity {
switch connectivityPresenter.connectivity {
case .offline:
"Discovery is unavailable offline. Your local handbook, quick cards, and notes still work."
case .onlineConstrained:
Expand All @@ -661,7 +629,7 @@ struct SettingsScreen: View {
}

private var discoveryAvailabilityIcon: String {
switch connectivity {
switch connectivityPresenter.connectivity {
case .offline:
"wifi.slash"
case .onlineConstrained:
Expand All @@ -674,7 +642,7 @@ struct SettingsScreen: View {
}

private var discoveryAvailabilityTint: Color {
switch connectivity {
switch connectivityPresenter.connectivity {
case .offline:
.osaBoundary
case .onlineConstrained:
Expand All @@ -687,11 +655,11 @@ struct SettingsScreen: View {
}

private var discoveryActionDisabled: Bool {
isDiscovering || discoveryCoordinator == nil || connectivity != .onlineUsable
isDiscovering || discoveryCoordinator == nil || connectivityPresenter.connectivity != .onlineUsable
}

private var discoveryAccessibilityHint: String {
switch connectivity {
switch connectivityPresenter.connectivity {
case .offline:
"Unavailable offline. Reconnect to check approved sources for new content."
case .onlineConstrained:
Expand Down Expand Up @@ -746,12 +714,6 @@ struct SettingsScreen: View {
}
}

private var connectivityAnimation: Animation {
accessibilityReduceMotion
? .easeOut(duration: 0.12)
: .easeInOut(duration: 0.2)
}

private var selectedHazards: Set<HazardScenario> {
Set(UserProfileSettings.hazards(from: hazardsRawValue))
}
Expand Down
Loading
Loading