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
162 changes: 162 additions & 0 deletions Application/DevLogPresentation/Sources/Main/MainFeature.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
//
// MainFeature.swift
// DevLogPresentation
//
// Created by opfic on 6/16/26.
//

import Combine
import ComposableArchitecture
import DevLogCore
import DevLogDomain
import Foundation
import UserNotifications

@Reducer
struct MainFeature {
@ObservableState
struct State: Equatable {
@Presents var alert: AlertState<Never>?
var unreadPushCount = 0
var isObservingUnreadPushCount = false
}

enum Action: Equatable {
case alert(PresentationAction<Never>)
case view(ViewAction)
case store(StoreAction)

enum ViewAction: Equatable {
case onAppear
case selectedTabChanged(MainTab)
}

enum StoreAction: Equatable {
case setUnreadPushCount(Int)
case setAlert
}
}

private enum CancelID: Hashable {
case unreadPushCount
}

@Dependency(\.observeUnreadPushCountUseCase) var observeUnreadPushCountUseCase
@Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase
@Dependency(\.setApplicationBadgeCount) var setApplicationBadgeCount

var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .alert:
break
case .view(.onAppear):
guard !state.isObservingUnreadPushCount else { break }
state.isObservingUnreadPushCount = true
return observeUnreadPushCountEffect()
case .view(.selectedTabChanged(let tab)):
guard let screenName = tab.analyticsScreenName else { break }
return trackScreenViewEffect(screenName)
case .store(.setUnreadPushCount(let count)):
state.unreadPushCount = count
return updateBadgeCountEffect(count)
case .store(.setAlert):
state.alert = Self.alertState()
}

return .none
}
.ifLet(\.$alert, action: \.alert)
}
}

extension DependencyValues {
var observeUnreadPushCountUseCase: ObserveUnreadPushCountUseCase {
get { self[ObserveUnreadPushCountUseCaseKey.self] }
set { self[ObserveUnreadPushCountUseCaseKey.self] = newValue }
}

var setApplicationBadgeCount: @Sendable (Int) async throws -> Void {
get { self[SetApplicationBadgeCountKey.self] }
set { self[SetApplicationBadgeCountKey.self] = newValue }
}
}

private enum ObserveUnreadPushCountUseCaseKey: DependencyKey {
static var liveValue: ObserveUnreadPushCountUseCase {
preconditionFailure("ObserveUnreadPushCountUseCase must be provided.")
}

static var testValue: ObserveUnreadPushCountUseCase {
liveValue
}
}

private enum SetApplicationBadgeCountKey: DependencyKey {
static let liveValue: @Sendable (Int) async throws -> Void = { count in
try await UNUserNotificationCenter.current().setBadgeCount(count)
}

static var testValue: @Sendable (Int) async throws -> Void {
liveValue
}
}

private extension MainFeature {
func observeUnreadPushCountEffect() -> Effect<Action> {
.run { [observeUnreadPushCountUseCase] send in
do {
let publisher = try observeUnreadPushCountUseCase.observe()
for try await count in publisher.values {
await send(.store(.setUnreadPushCount(count)))
}
} catch {
await send(.store(.setAlert))
}
}
.cancellable(id: CancelID.unreadPushCount, cancelInFlight: true)
}

func trackScreenViewEffect(_ screenName: String) -> Effect<Action> {
.run { [trackAnalyticsEventUseCase] _ in
trackAnalyticsEventUseCase?.execute(.screenView(screenName))
}
}

func updateBadgeCountEffect(_ count: Int) -> Effect<Action> {
.run { [setApplicationBadgeCount] _ in
do {
try await setApplicationBadgeCount(count)
} catch {
Logger(category: "MainFeature").error("Failed to update application badge count", error: error)
}
}
}

static func alertState() -> AlertState<Never> {
AlertState {
TextState(String(localized: "common_error_title"))
} actions: {
ButtonState(role: .cancel) {
TextState(String(localized: "common_close"))
}
} message: {
TextState(String(localized: "main_alert_badge_error_message"))
}
}
}

private extension MainTab {
var analyticsScreenName: String? {
switch self {
case .home:
return "home"
case .today:
return "today"
case .notification:
return nil
case .profile:
return "profile"
}
}
}
37 changes: 15 additions & 22 deletions Application/DevLogPresentation/Sources/Main/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,42 @@
//

import SwiftUI
import ComposableArchitecture
import DevLogCore
import DevLogDomain

struct MainView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var coordinator: MainViewCoordinator
@State private var todoWindowCoordinator: TodoWindowCoordinator
@State private var homeViewCoordinator: HomeViewCoordinator
@State private var todayViewCoordinator: TodayViewCoordinator
@State private var pushNotificationListViewCoordinator: PushNotificationListViewCoordinator
@State private var profileViewCoordinator: ProfileViewCoordinator
@Binding var selectedTab: MainTab
@State private var store: StoreOf<MainFeature>
private let windowEvent: TodoEditorWindowEvent

init(
container: DIContainer,
windowEvent: TodoEditorWindowEvent,
selectedTab: Binding<MainTab>
) {
self._coordinator = State(initialValue: MainViewCoordinator(container: container))
self._store = State(initialValue: Store(initialState: MainFeature.State()) {
MainFeature()
} withDependencies: {
$0.observeUnreadPushCountUseCase = container.resolve(ObserveUnreadPushCountUseCase.self)
$0.trackAnalyticsEventUseCase = container.resolve(TrackAnalyticsEventUseCase.self)
})
self._todoWindowCoordinator = State(initialValue: TodoWindowCoordinator(container: container))
self._homeViewCoordinator = State(initialValue: HomeViewCoordinator(container: container))
self._todayViewCoordinator = State(initialValue: TodayViewCoordinator(container: container))
self._pushNotificationListViewCoordinator = State(
initialValue: PushNotificationListViewCoordinator(container: container)
)
self._profileViewCoordinator = State(initialValue: ProfileViewCoordinator(container: container))
self.windowEvent = windowEvent

self._selectedTab = selectedTab
self.windowEvent = windowEvent
}

var body: some View {
Expand All @@ -46,13 +53,13 @@ struct MainView: View {
}
}
.onAppear {
coordinator.viewModel.send(.onAppear)
store.send(.view(.onAppear))
homeViewCoordinator.bindWindowEvent(windowEvent)
homeViewCoordinator.bindTodoMutationEvent()
todoWindowCoordinator.bindWindowEvent(windowEvent)
}
.onChange(of: selectedTab, initial: true) { _, newValue in
coordinator.viewModel.send(.selectedTabChanged(newValue))
store.send(.view(.selectedTabChanged(newValue)))
if newValue == .home {
homeViewCoordinator.fetchData()
} else if newValue == .today {
Expand All @@ -63,14 +70,7 @@ struct MainView: View {
profileViewCoordinator.fetchData()
}
}
.alert(
coordinator.viewModel.state.alertTitle,
isPresented: mainAlertPresented
) {
Button(String(localized: "common_close"), role: .cancel) { }
} message: {
Text(coordinator.viewModel.state.alertMessage)
}
.alert($store.scope(state: \.alert, action: \.alert))
.toastHost()
}

Expand All @@ -92,7 +92,7 @@ struct MainView: View {
.tabItem {
tabLabel(.notification)
}
.badge(coordinator.viewModel.state.unreadPushCount)
.badge(store.unreadPushCount)
.tag(MainTab.notification)

profileView
Expand Down Expand Up @@ -163,7 +163,7 @@ struct MainView: View {
private func sidebarRow(_ tab: MainTab) -> some View {
if tab == .notification {
tabLabel(tab)
.badge(coordinator.viewModel.state.unreadPushCount)
.badge(store.unreadPushCount)
.tag(tab)
} else {
tabLabel(tab)
Expand Down Expand Up @@ -381,13 +381,6 @@ private extension MainView {
horizontalSizeClass == .compact
}

var mainAlertPresented: Binding<Bool> {
Binding(
get: { coordinator.viewModel.state.showAlert },
set: { coordinator.viewModel.send(.setAlert($0)) }
)
}

var sidebarSelection: Binding<MainTab?> {
Binding(
get: { selectedTab },
Expand Down

This file was deleted.

Loading
Loading