Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
201 changes: 201 additions & 0 deletions Application/DevLogPresentation/Sources/Root/RootFeature.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
//
// RootFeature.swift
// DevLogPresentation
//
// Created by opfic on 6/17/26.
//

import Combine
import ComposableArchitecture
import DevLogCore
import DevLogDomain
import Foundation

@Reducer
struct RootFeature {
private enum CancelID: Hashable {
case networkConnectivity
case session
case theme
}

@ObservableState
struct State: Equatable {
@Presents var alert: AlertState<Never>?
@Presents var sheet: SheetState?
var isNetworkConnected = true
var signIn: Bool?
var theme: SystemTheme = .automatic
var selectedMainTab = MainTab.home
var isObservingNetworkConnectivity = false
var isObservingSession = false
var isObservingTheme = false
}

@ObservableState
struct SheetState: Equatable, Identifiable {
let todoId: String
var id: String { todoId }
}

enum Action: BindableAction, Equatable {
case alert(PresentationAction<Never>)
case binding(BindingAction<State>)
case sheet(PresentationAction<Sheet>)
case onAppear
case presentTodoDetail(String)
case openWidgetRoute(MainTab)
case networkStatusChanged(Bool)
case setTheme(SystemTheme)
case didLogined(Bool)

enum Sheet: Equatable {
case tapCloseButton
}
}

@Dependency(\.observeAuthSessionUseCase) var observeAuthSessionUseCase
@Dependency(\.networkConnectivityUseCase) var networkConnectivityUseCase
@Dependency(\.systemThemeUseCase) var systemThemeUseCase
@Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase
@Dependency(\.setApplicationBadgeCount) var setApplicationBadgeCount

var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .alert:
break
case .binding:
break
case .sheet(.dismiss), .sheet(.presented(.tapCloseButton)):
state.sheet = nil
case .sheet:
break
case .onAppear:
var effect = clearApplicationBadgeCountEffect()

if !state.isObservingNetworkConnectivity {
state.isObservingNetworkConnectivity = true
effect = .merge(effect, observeNetworkConnectivityEffect())
}

if !state.isObservingSession {
state.isObservingSession = true
effect = .merge(effect, observeSessionEffect())
}

if !state.isObservingTheme {
state.isObservingTheme = true
effect = .merge(effect, observeThemeEffect())
}

return effect
case .presentTodoDetail(let todoId):
state.sheet = .init(todoId: todoId)
case .openWidgetRoute(let mainTab):
guard state.signIn == true else { break }
state.selectedMainTab = mainTab
case .networkStatusChanged(let isConnected):
let wasConnected = state.isNetworkConnected
state.isNetworkConnected = isConnected
if wasConnected && !isConnected {
state.alert = Self.alertState()
}
case .setTheme(let theme):
state.theme = theme
case .didLogined(let result):
state.signIn = result
if result {
state.selectedMainTab = .home
} else {
return trackLoginScreenEffect()
}
Comment thread
opficdev marked this conversation as resolved.
}

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

private struct RootSheetFeature: Reducer {
typealias State = RootFeature.SheetState
typealias Action = RootFeature.Action.Sheet

var body: some ReducerOf<Self> {
EmptyReducer()
}
}

extension DependencyValues {
var observeAuthSessionUseCase: ObserveAuthSessionUseCase {
get { self[ObserveAuthSessionUseCaseKey.self] }
set { self[ObserveAuthSessionUseCaseKey.self] = newValue }
}
}

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

static var testValue: ObserveAuthSessionUseCase {
liveValue
}
}

private extension RootFeature {
func clearApplicationBadgeCountEffect() -> Effect<Action> {
.run { [setApplicationBadgeCount] _ in
try? await setApplicationBadgeCount(0)
}
}

func observeNetworkConnectivityEffect() -> Effect<Action> {
.publisher { [networkConnectivityUseCase] in
networkConnectivityUseCase.observe()
.map(Action.networkStatusChanged)
}
.cancellable(id: CancelID.networkConnectivity, cancelInFlight: true)
}
Comment thread
opficdev marked this conversation as resolved.

func observeSessionEffect() -> Effect<Action> {
.publisher { [observeAuthSessionUseCase] in
observeAuthSessionUseCase.observe()
.removeDuplicates()
.map(Action.didLogined)
}
.cancellable(id: CancelID.session, cancelInFlight: true)
}
Comment thread
opficdev marked this conversation as resolved.

func observeThemeEffect() -> Effect<Action> {
.publisher { [systemThemeUseCase] in
systemThemeUseCase.observe()
.removeDuplicates()
.map(Action.setTheme)
}
.cancellable(id: CancelID.theme, cancelInFlight: true)
}
Comment thread
opficdev marked this conversation as resolved.

func trackLoginScreenEffect() -> Effect<Action> {
.run { [trackAnalyticsEventUseCase] _ in
trackAnalyticsEventUseCase?.execute(.screenView("login"))
}
}
Comment thread
opficdev marked this conversation as resolved.

static func alertState() -> AlertState<Never> {
AlertState {
TextState(String(localized: "root_network_disconnected_title"))
} actions: {
ButtonState(role: .cancel) {
TextState(String(localized: "common_close"))
}
} message: {
TextState(String(localized: "root_network_disconnected_message"))
}
}
}
103 changes: 39 additions & 64 deletions Application/DevLogPresentation/Sources/Root/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ import DevLogDomain

public struct RootView: View {
@Environment(\.diContainer) var container: DIContainer
@State var viewModel: RootViewModel
@State private var selectedRoute: Route?
@State private var selectedMainTab = MainTab.home
@State private var store: StoreOf<RootFeature>
private let widgetURLTab: (URL) -> MainTab?
private let windowEvent: TodoEditorWindowEvent
private let pushNotificationTodoIdPublisher: AnyPublisher<String, Never>
Expand All @@ -31,12 +29,14 @@ public struct RootView: View {
pushNotificationTodoIdPublisher: AnyPublisher<String, Never>,
clearPushNotificationRoute: @escaping () -> Void
) {
self._viewModel = State(initialValue: RootViewModel(
sessionUseCase: sessionUseCase,
networkConnectivityUseCase: networkConnectivityUseCase,
systemThemeUseCase: systemThemeUseCase,
trackAnalyticsEventUseCase: trackAnalyticsEventUseCase
))
self._store = State(initialValue: Store(initialState: RootFeature.State()) {
RootFeature()
} withDependencies: {
$0.observeAuthSessionUseCase = sessionUseCase
$0.networkConnectivityUseCase = networkConnectivityUseCase
$0.systemThemeUseCase = systemThemeUseCase
$0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase
})
self.widgetURLTab = widgetURLTab
self.windowEvent = windowEvent
self.pushNotificationTodoIdPublisher = pushNotificationTodoIdPublisher
Expand All @@ -46,81 +46,56 @@ public struct RootView: View {
public var body: some View {
ZStack {
Color(UIColor.systemGroupedBackground).ignoresSafeArea()
if let signIn = viewModel.state.signIn {
if let signIn = store.signIn {
if signIn {
MainView(
container: container,
windowEvent: windowEvent,
selectedTab: $selectedMainTab
selectedTab: $store.selectedMainTab
)
} else {
LoginView(signInUseCase: container.resolve(SignInUseCase.self))
}
}
}
.preferredColorScheme(viewModel.state.theme.colorScheme)
.onAppear { viewModel.send(.onAppear) }
.onChange(of: viewModel.state.signIn) { _, value in
guard let value else { return }
if value {
selectedMainTab = .home
}
}
.preferredColorScheme(store.theme.colorScheme)
.onAppear { store.send(.onAppear) }
.onOpenURL { url in
guard let mainTab = widgetURLTab(url) else { return }
switch viewModel.state.signIn {
case .some(false):
break
case .some(true):
selectedMainTab = mainTab
case .none:
break
}
store.send(.openWidgetRoute(mainTab))
}
.alert(viewModel.state.alertTitle, isPresented: Binding(
get: { viewModel.state.showAlert },
set: { viewModel.send(.setAlert($0)) }
)) {
Button(String(localized: "common_close"), role: .cancel) { }
} message: {
Text(viewModel.state.alertMessage)
}
.sheet(item: $selectedRoute) { route in
switch route {
case .todoDetail(let todoId):
NavigationStack {
TodoDetailView(store: Store(
initialState: TodoDetailFeature.State(todoId: todoId, showEditButton: false)
) {
TodoDetailFeature()
} withDependencies: {
$0.fetchTodoByIdUseCase = container.resolve(FetchTodoByIdUseCase.self)
$0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self)
})
.toolbar {
ToolbarLeadingButton {
selectedRoute = nil
}
}
}
.background(Color(.systemGroupedBackground))
.presentationDragIndicator(.visible)
.alert($store.scope(state: \.alert, action: \.alert))
.sheet(item: $store.scope(state: \.sheet, action: \.sheet)) { sheetStore in
sheetContent(todoId: sheetStore.todoId) {
sheetStore.send(.tapCloseButton)
}
}
.onReceive(pushNotificationTodoIdPublisher) { todoId in
selectedRoute = .todoDetail(todoId)
store.send(.presentTodoDetail(todoId))
clearPushNotificationRoute()
}
}
}

private enum Route: Equatable, Identifiable {
case todoDetail(String)

var id: String {
switch self {
case .todoDetail(let todoId):
return "todo:\(todoId)"
private func sheetContent(
todoId: String,
onClose: @escaping () -> Void
) -> some View {
NavigationStack {
TodoDetailView(store: Store(
initialState: TodoDetailFeature.State(todoId: todoId, showEditButton: false)
) {
TodoDetailFeature()
} withDependencies: {
$0.fetchTodoByIdUseCase = container.resolve(FetchTodoByIdUseCase.self)
$0.fetchReferenceItemsUseCase = container.resolve(FetchReferenceItemsUseCase.self)
})
.toolbar {
ToolbarLeadingButton {
onClose()
}
}
}
.background(Color(.systemGroupedBackground))
.presentationDragIndicator(.visible)
}
}
Loading
Loading