How do i migrate the Pullbacks to Scopes in the Reducer Protocol

Hi, I am working on a SwiftUI app which was developed before Reducer Protocol. I am migrating it to the Redcucer Protocol but I had hard time converting these pullbacks to scopes. The previous implementation looks like this:

public var route: Route?
public enum Route: Equatable {
    case open(ItemDetailViewState)
    case closed(ItemDetailViewState?, Route2?)
    public enum Route2: Equatable {
        case item(id: ItemRowState.ID)
        case add(ItemEditState)
    }
}
public let listReducer = Reducer<ListViewState, ListAction, ListEnv>.combine(
    detailViewReducer.pullback(
        state: OptionalPath(\ListViewState.route)
            .appending(path: OptionalPath(/ListViewState.Route.open)),
        action: /ListAction.item,
        environment: { global in global.env }),
    detailViewReducer.pullback(
        state: OptionalPath(\ListViewState.route)
            .appending(
                path: OptionalPath(/ListViewState.Route.closed)
                    .appending(
                        path: OptionalPath(extract: { root in
                            root.0
                        }, set: { (root, value) in
                            root.0 = value
                        })
                    )
            ),
        action: /ListAction.item,
        environment: { global in global.env }),
    Reducer { state, action, environment in
        switch action {
            // cases
        }
    }
)

IfLetStore(
    self.store.scope(
        state: { state in
            guard case .open(let detailState) = state.route else {
                return nil
            }
            return detailState
        },
        action: { local in
            ListAction.item(local)
        }
    ),
    then: ItemDetailView.init(store:)
)

What i end up doing is I removed the nesting from the route enum and implemented pullbacks like this but it still has issues and the functionality does not work as it was before, instead I am receiving errors like:

An "ifCaseLet" at "ListViewFeature/ListView.swift:304" received a child action when child state was set to a different case

public var route: Route?
public enum Route: Equatable {
    case open(ItemDetailViewStateFeature.State)
    case closed(ItemDetailViewStateFeature.State?)
    case item(id: ItemRowFeature.State.ID)
    case add(ItemEditFeature.State?)
}
Scope(state: \.route, action: .self) {
    EmptyReducer()
        .ifCaseLet(/State.Route.closed, action: /Action.item) {
            ItemDetailView()
        }
}
Scope(state: \.route, action: .self) {
    EmptyReducer()
        .ifCaseLet(/State.Route.open, action: /Action.item) {
            ItemDetailView()
        }
}

I might need more of the domain to fully debug, but a few things stand out:

The main thing is that you have two ifCaseLets pointed to the same action case path, but different state cases.

The ifCaseLet modifier specifies that a reducer scoped to a particular case of enum state should process a particular case of actions, and if the parent reducer gets an action in when state is not in the given case, a warning is emitted, since the child reducer cannot process the action. In the code above, both "closed" and "open" reducers are going to try to process Action.item actions, and at least one is always going to fail, since state can't be closed and open at the same time. You should have a dedicated action case per case of enum state.

It also looks like the reducers aren't quite modeled as I would expect, but that might be optional promotion going awry. Because case paths are generated from functions, they can sometimes take part in optional promotion, and while this is OK most of the time, it could also cause some unexpected problems.

Looking back at your domain, you have an optional Route that holds an enum, so I think you want to ifLet it from your current domain, and then you can use Scope for each case:

Reduce { state, action in
  // Core reducer logic
}
.ifLet(\.route, action: /Action.route) {
  Scope(state: /State.Route.closed, action: /Action.Route.closed) {
    ItemDetailView()
  }
  Scope(state: /State.Route.open, action: /Action.Route.open) {
    ItemDetailView()
  }
}

A couple changes in addition to restructuring:

  • I've also theorized a route case of your domain's actions. It's generally good to keep state/action composition balanced and can help avoid ordering problems when it comes to using operators like ifLet and ifCaseLet.
  • I've changed Action.item to separate Action.Route.closed and Action.Route.open cases. They can embed the same action type, but need dedicated cases, as mentioned above.

Also, is ItemDetailView a reducer? I did a double-take assuming it was a SwiftUI view.

Yes ItemDetailView is a SwiftUI view. This view is basically presented as a sheet and it has a timer. I want to access the timer state when the sheet is open or closed. This is why both of these enums use the same action. The things you point out make sense and I tried to add separate actions for both states but faced the same issue. What I can't figure out is what is the proper way to handle this scenario in reducer protocol because it was working just fine with the pullbacks.

Is it playing double duty as a reducer somehow? Why is it included in your reducer's body?

I think we'd need to see the original code, pre-protocol if you'd like us to help you translate it over to the protocol style (ideally enough that compiles on its own). We do have a guide that covers the basics. What you've shared so far is unfortunately not enough code to troubleshoot much more, so please do provide more for us to work with!

Unfortunately, I can not share the part of the codebase because of some reasons but one thing I would request is that can you please suggest an implementation for the requirement given below.

I have three screens:
The first one is home which is responsible for showing a list of items (consider them as blogs or videos or whatever). And it is the first screen when the app is opened.
The second one is edit which is responsible for showing a specific item (blog, video, etc.) in the edit form. Users can edit certain details there.
And the third one is the timer which is responsible for starting a timer for specific durations. The timer can be stopped, played, and paused. Since this screen is presented as a sheet when the timer is started and the sheet is closed users can see a tiny view that shows timer changes on the home and edit screen in real time.

After reading this if you revisit what I have posted above it could make sense what I am trying to achieve.

I might be wrong at certain places because I am still learning TCA but if you can provide an example for this requirement that can be a great starter to tackle this issue.
Also, I have watched most of the TCA episodes but if you want to suggest something specific regarding this issue please go ahead.

@stephencelis I went ahead and picked the snippets you would need to check the issue in more detail.

Here goes the HomeFeature:

public struct HomeViewFeature: ReducerProtocol {
    
    public struct State: Equatable {
        public init(meditations: IdentifiedArrayOf<Meditation>) {
            self.meditations = IdentifiedArray(
                uniqueElements: meditations.map {
                    ItemRowFeature.State(item: $0, route: nil)
                }
            )
        }
        
        public var meditations: IdentifiedArrayOf<ItemRowFeature.State>
        var meditationsReversed: IdentifiedArrayOf<ItemRowFeature.State> {
            IdentifiedArrayOf<ItemRowFeature.State>(uniqueElements: self.meditations.reversed() )
        }
        public var route: Route?
        public enum Route: Equatable {
            case timerViewOpened(TimedSessionViewFeature.State?)
            case timerViewClosed(TimedSessionViewFeature.State?)
            case item(id: ItemRowFeature.State.ID)
            case add(EditEntryFeature.State)
            case deleteAlert(Bool)
            case shouldShowSettings(Bool)
            case shouldShowNewMedView(Bool)
        }
        
        var timedSession: TimedSessionViewFeature.State? {
            switch self.route {
            case .timerViewOpened(let sessionState):
                return sessionState
                
            case .timerViewClosed(let t):
                return t
                
            default:
                return TimedSessionViewFeature.State()
            }
        }
    }
    
    public enum Action: Equatable {
        case timerViewOpened(TimedSessionViewFeature.Action)
        case timerViewClosed(TimedSessionViewFeature.Action)
        case presentTimedMeditationButtonTapped
        case setSheet(isPresented: Bool)
        case timerBottomBarPushed
        case edit(id: UUID, action: ItemRowFeature.Action)
    }
    
    var file: FileClient
    public var healthKit: HealthKitClient
    public var userNotificationClient: UserNotificationClient
    var uuid: () -> UUID
    var now: () -> Date
    
    public init(file: FileClient, healthKit: HealthKitClient, uuid: @escaping () -> UUID, now: @escaping () -> Date, userNotificationClient: UserNotificationClient) {
        self.file = file
        self.healthKit = healthKit
        self.uuid = uuid
        self.now = now
        self.userNotificationClient = userNotificationClient
    }
    
    public var body: some ReducerProtocol<State, Action> {
        Reduce<State, Action> { state, action in
            switch action {
            case .timerViewOpened(.timerFinished):
                guard let timedState = state.timedSession else { fatalError() }
                let edit = ItemRowFeature.State(item: timedState.timedMeditation!, route: nil)
                state.meditations.removeOrAdd(item: edit)
                state.route = .timerViewClosed(nil)
                let meds = state.meditations
                return .fireAndForget {
                    healthKit.saveMindfulMinutes(-timedState.timedMeditation!.duration!)
                    file.save(Array(meds.map { $0.item }))
                }
                
            case .timerViewClosed(.timerFinished):
                guard let timedState = state.timedSession else { fatalError() }
                let edit = ItemRowFeature.State(item: timedState.timedMeditation!, route: nil)
                state.meditations.removeOrAdd(item: edit)
                let meds = state.meditations
                return .fireAndForget {
                    healthKit.saveMindfulMinutes(-timedState.timedMeditation!.duration!)
                    file.save(Array(meds.map { $0.item }))
                }
                
            case .timerViewOpened(_):
                return .none
                
            case .timerViewClosed(_):
                return .none
                
            case .presentTimedMeditationButtonTapped:
                state.route = .timerViewOpened(TimedSessionViewFeature.State())
                return .none
                
            case .timerBottomBarPushed:
                state.route = .timerViewOpened(state.timedSession)
                return .none
                
            case .setSheet(let isPresented):
                state.route = .timerViewClosed(state.timedSession)
                if isPresented {
                    state.route = .timerViewOpened(state.timedSession)
                } else {
                    state.route = .timerViewClosed(state.timedSession)
                }
                return .none
                
            case .edit(id: _, action: _):
                return .none
            }
        }
        .forEach(\.meditations, action: /Action.edit(id:action:)) {
            ItemRowFeature()
        }
        Scope(state: \.route, action: .self) {
            EmptyReducer()
                .ifCaseLet(/State.Route.timerViewOpened, action: /Action.timerViewOpened) {
                    TimedSessionViewFeature(
                        remoteClient: .randomDelayed,
                        userNotificationClient: UserNotificationClient.live,
                        mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
                        now: Date.init,
                        uuid: UUID.init
                    )
                }
        }
        Scope(state: \.route, action: .self) {
            EmptyReducer()
                .ifCaseLet(/State.Route.timerViewClosed, action: /Action.timerViewClosed) {
                    TimedSessionViewFeature(
                        remoteClient: .randomDelayed,
                        userNotificationClient: UserNotificationClient.live,
                        mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
                        now: Date.init,
                        uuid: UUID.init
                    )
                }
        }
    }
}


public struct HomeView: View {
    
    var store: StoreOf<HomeViewFeature>
    
    public init(store: StoreOf<HomeViewFeature>) {
        self.store = store
    }
    
    @State private var timerGoing = true
    
    public var body: some View {
        WithViewStore(self.store) { viewStore in
            ZStack {
                NavigationView {
                    List {
                        ForEachStore(
                            self.store.scope(
                                state: { $0.meditations },
                                action: HomeViewFeature.Action.edit(id:action:)
                            )
                        ) { meditationStore in
                            ItemRowView(store: meditationStore)
                        }
                    }
                }
                .sheet(
                    isPresented: viewStore.binding(
                        get: { (homeViewState: HomeViewFeature.State) -> Bool  in
                            guard case .timerViewOpened = homeViewState.route else {
                                return false
                            }
                            return true
                        },
                        send: HomeViewFeature.Action.setSheet(isPresented:)
                    )
                ) {
                    IfLetStore(
                        self.store.scope(
                            state: { state in
                                guard case .timerViewOpened(let sessionState) = state.route else {
                                    return nil
                                }
                                return sessionState
                            },
                            action: { local in
                                HomeViewFeature.Action.timerViewOpened(local)
                            }
                        ),
                        then: TimedSessionView.init(store:)
                    )
                }
            }
        }
    }
}

Here goes the TimerFeature:

public struct TimerFeature: ReducerProtocol {
    
    public struct State: Equatable {
        let minutesList: [Double] = (1 ... 60).map(Double.init).map{$0}
        var selType: Int
        var selMin: Int
        var types: [String]
        public var timerData: TimerData?
        public var timedMeditation: Meditation?
        var seconds: Double { self.minutesList[self.selMin] * 60 }
        var minutes: Double { self.minutesList[self.selMin] }
        var currentType: String { self.types[self.selType]}
        var timerGoing: Bool { return  nil != timerData}
        var paused: Bool = false
        
        public init(selType: Int = 0, selMin: Int = 0, types: [String] = [
            "Concentration",
            "Do Nothing"
        ], timerData: TimerData? = nil, timedMeditation: Meditation? = nil, _tdcount: Int = 0) {
            self.selType = selType
            self.selMin = selMin
            self.types = types
            self.timerData = timerData
            self.timedMeditation = timedMeditation
        }
    }
    
    public enum Action: Equatable {
        case addNotificationResponse(Result<Int, UserNotificationClient.Error>)
        case cancelButtonTapped
        case didFinishLaunching(notification: UserNotification?)
        case didReceiveBackgroundNotification(BackgroundNotification)
        case pauseButtonTapped
        case pickMeditationTime(Int)
        case pickTypeOfMeditation(Int)
        case remoteCountResponse(Result<Int, RemoteClient.Error>)
        case requestAuthorizationResponse(Result<Bool, UserNotificationClient.Error>)
        case startTimerPushed(duration: Double)
        case timerFired
        case timerFinished
        case userNotification(UserNotificationClient.Action)
    }
    
    var remoteClient: RemoteClient
    public var userNotificationClient: UserNotificationClient
    var mainQueue: AnySchedulerOf<DispatchQueue>
    var now : () -> Date
    var uuid : () -> UUID
    
    public init(remoteClient: RemoteClient, userNotificationClient: UserNotificationClient, mainQueue: AnySchedulerOf<DispatchQueue>, now: @escaping () -> Date, uuid: @escaping () -> UUID) {
        self.remoteClient = remoteClient
        self.userNotificationClient = userNotificationClient
        self.mainQueue = mainQueue
        self.now = now
        self.uuid = uuid
    }
    
    public func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
        struct TimerId: Hashable {}
        switch action {
        case .didFinishLaunching(_):
            return .merge(
                userNotificationClient
                    .delegate()
                    .map(TimerFeature.Action.userNotification),
                userNotificationClient.requestAuthorization([.alert, .badge, .sound])
                    .catchToEffect()
                    .map(TimerFeature.Action.requestAuthorizationResponse)
            )
            
        case let .didReceiveBackgroundNotification(backgroundNotification):
            let fetchCompletionHandler = backgroundNotification.fetchCompletionHandler
            guard backgroundNotification.content == .countAvailable else {
                return .fireAndForget {
                    backgroundNotification.fetchCompletionHandler(.noData)
                }
            }
            
            return remoteClient.fetchRemoteCount()
                .catchToEffect()
                .handleEvents(receiveOutput: { result in
                    switch result {
                    case .success:
                        fetchCompletionHandler(.newData)
                    case .failure:
                        fetchCompletionHandler(.failed)
                    }
                })
                .eraseToEffect()
                .map(TimerFeature.Action.remoteCountResponse)
            
        case .pickTypeOfMeditation(let index):
            state.selType = index
            Analytics.logEvent(Strings.typeEvent, parameters: [Strings.typeEvent: state.currentType as NSObject])
            return .none
            
        case .pickMeditationTime(let index):
            state.selMin = index
            Analytics.logEvent(Strings.durationEvent, parameters: [Strings.durationEvent: state.minutes as NSObject])
            return .none
            
        case let .startTimerPushed(duration:seconds):
            Analytics.logEvent(Strings.startEvent, parameters: nil)
            state.timerData = TimerData(endDate: now() + seconds)
            
            state.timedMeditation =  Meditation(id: uuid(),
                                                date: now().timeIntervalSince1970,
                                                duration: seconds,
                                                entry: "",
                                                title: state.currentType
            )
            
            let duration = state.timedMeditation!.duration
            let userActions = "User Actions"
            
            let content = UNMutableNotificationContent()
            content.title = "\(state.timedMeditation!.title) Complete"
            content.body = Strings.notificationBody
            content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: Icons.bell))
            content.badge = 1
            content.categoryIdentifier = userActions
            content.userInfo.updateValue(state.timedMeditation!.id.uuidString, forKey: "uuid-string")
            
            let request = UNNotificationRequest(
                identifier: "example_notification",
                content: content,
                trigger: UNTimeIntervalNotificationTrigger(timeInterval: duration!, repeats: false)
            )
            
            let snoozeAction = UNNotificationAction(identifier: "Snooze", title: "Snooze", options: [])
            let deleteAction = UNNotificationAction(identifier: "Delete", title: "Delete", options: [.destructive])
            let category = UNNotificationCategory(identifier: userActions, actions: [snoozeAction, deleteAction], intentIdentifiers: [], options: [])
            
            return  Effect.merge(
                Effect.timer(id: TimerId(), every: 1, on: mainQueue)
                    .map { _ in TimerFeature.Action.timerFired },
                userNotificationClient.removePendingNotificationRequestsWithIdentifiers(["example_notification"])
                    .fireAndForget(),
                userNotificationClient.add(request)
                    .map(Int.init)
                    .receive(on: DispatchQueue.main)
                    .catchToEffect()
                    .map(TimerFeature.Action.addNotificationResponse),
                userNotificationClient.setNotificationCategories([category])
                    .fireAndForget()
            )
            
        case .remoteCountResponse(.success(_)):
            return .none
            
        case .remoteCountResponse(.failure):
            return .none
            
        case .requestAuthorizationResponse:
            return .none
            
        case .timerFired:
            let currentDate = Date()
            guard let date = state.timerData?.endDate,
                  currentDate < date,
                  DateInterval(start: currentDate, end: date).duration >= 0 else {
                return Effect(value: .timerFinished)
            }
            
            let seconds = DateInterval(start: currentDate, end: date).duration
            state.timerData?.timeLeft = seconds
            
            return .none
            
        case .timerFinished:
            state.timerData = nil
            Analytics.logEvent(Strings.finishedEvent, parameters: nil)
            return Effect.cancel(id: TimerId())
            
        case .addNotificationResponse(.success(_)):
            return .none
            
        case .addNotificationResponse(.failure(let error)):
            print(error)
            return .none
            
        case let .userNotification(.didReceiveResponse(response, completion)):
            let _ = UserNotification(userInfo: response.notification.request.content.userInfo())
            return .fireAndForget(completion)
            
        case .userNotification(.willPresentNotification(_, completion: let completion)):
            return .fireAndForget {
                completion([.list, .banner, .sound])
            }
            
        case .userNotification(.openSettingsForNotification(_)):
            return .none
            
        case .cancelButtonTapped:
            state.timerData = nil
            Analytics.logEvent(Strings.cancelEvent, parameters: nil)
            return Effect.merge(
                .cancel(id: TimerId()),
                userNotificationClient.removePendingNotificationRequestsWithIdentifiers(["example_notification"])
                    .fireAndForget()
            )
            
        case .pauseButtonTapped:
            switch state.paused {
            case false:
                state.paused = true
                Analytics.logEvent(Strings.pauseEvent, parameters: nil)
                return Effect.merge(
                    .cancel(id: TimerId()),
                    userNotificationClient.removePendingNotificationRequestsWithIdentifiers(["example_notification"])
                        .fireAndForget()
                )
            case true:
                state.paused = false
                Analytics.logEvent(Strings.startEvent, parameters: nil)
                return Effect(value: .startTimerPushed(duration: state.timerData!.timeLeft!))
            }
        }
    }
}


public struct TimerView: View {
    
    public var store: StoreOf<TimerFeature>
    
    public init(store: StoreOf<TimerFeature>) {
        self.store = store
    }
    
    public var body: some View {
        WithViewStore(self.store , observe: { $0 }) { viewStore in
            ScrollView {
                VStack(spacing: 15) {
                    Text(viewStore.currentType)
                        .font(.largeTitle)
                    Text(viewStore.timerData?.timeLeftLabel ?? "\(viewStore.minutes)")
                        .foregroundColor(.accentColor)
                        .font(.largeTitle)
                        .fontWeight(.black)
                    PickerFeature(
                        types: viewStore.types,
                        typeSelection:  viewStore.binding(
                            get: { $0.selType },
                            send: { .pickTypeOfMeditation($0) }
                        ),
                        minutesList: viewStore.minutesList,
                        minSelection: viewStore.binding(
                            get: { $0.selMin },
                            send: { .pickMeditationTime($0) }
                        )
                    )
                    Spacer()
                    if !viewStore.timerGoing {
                        Button(action: {
                            viewStore.send(.startTimerPushed(duration: viewStore.seconds))
                        }) { Text(Strings.start)
                                .font(.body)
                                .foregroundColor(.white)
                                .background(Circle()
                                    .frame(width: 66.0, height: 66.0, alignment: .center)
                                    .foregroundColor(.accentColor)
                                )
                        }
                    } else {
                        HStack {
                            Spacer()
                            Button( action: {
                                viewStore.send(.cancelButtonTapped)
                            })  { Text(Strings.cancel)
                                    .font(.body)
                                    .foregroundColor(.white)
                                    .background(Circle()
                                        .frame(width: 66.0, height: 66.0, alignment: .center)
                                        .foregroundColor(.secondary)
                                    )
                            }
                            Spacer()
                            if (!viewStore.paused) {
                                Button( action: {
                                    viewStore.send(.pauseButtonTapped)
                                }) { Text(Strings.pause)
                                        .font(.body)
                                        .foregroundColor(.white)
                                        .background(Circle()
                                            .frame(width: 66.0, height: 66.0, alignment: .center)
                                            .foregroundColor(.accentColor)
                                        )
                                }
                            } else {
                                Button( action: {
                                    viewStore.send(.pauseButtonTapped)
                                }) { Text(Strings.start)
                                        .font(.body)
                                        .foregroundColor(.white)
                                        .background(Circle()
                                            .frame(width: 66.0, height: 66.0, alignment: .center)
                                            .foregroundColor(.accentColor)
                                        )
                                }
                            }
                            Spacer()
                        }
                    }
                }
                .padding(.top, 20)
                .padding(.bottom, 30)
            }
        }
    }
}

1 Like

Saif, can you also share the feature back before it was using the reducer protocol?

@justinmilo before the Reducer protocol the implementation looked like this:

HomeFeature:

public struct HomeState: Equatable {
    
    public init(meditations: IdentifiedArrayOf<Meditation>) {
        self.meditations = IdentifiedArray(
            uniqueElements: meditations.map {
                ItemRowState(item: $0, route: nil)
            }
        )
    }
    
    public var meditations: IdentifiedArrayOf<ItemRowState>
    var meditationsReversed: IdentifiedArrayOf<ItemRowState> {
        IdentifiedArrayOf<ItemRowState>(uniqueElements: self.meditations.reversed() )
    }
    public var route: Route?
    public enum Route: Equatable {
        case open(TimerState)
        case closed(TimerState?, Route2?)
        public enum Route2: Equatable {
            case item(id:ItemRowState.ID)
            case add(EditEntryState)
        }
        case deleteAlert(Bool)
        case shouldShowSettings(Bool)
        case shouldShowNewMedView(Bool)
    }
    
    var timedSession: TimerState? {
        switch self.route {
        case .open(let sessionState):
            return sessionState
            
        case .closed(let t, _):
            return t
            
        case .none:
            return nil
            
        case .some(.deleteAlert(_)):
            return nil
            
        case .some(.shouldShowSettings(_)):
            return nil
            
        case .some(.shouldShowNewMedView(_)):
            return nil
        }
    }
}

public enum HomeAction: Equatable {
    
    case edit(id: UUID, action: ItemRowAction)
    case meditation(TimerAction)
    case presentTimedMeditationButtonTapped
    case setSheet(isPresented: Bool)
    case timerBottomBarPushed
}

public struct HomeEnv {
    
    var file: FileClient
    public var healthKit: HealthKitClient
    var uuid: () -> UUID
    var now: () -> Date
    public var medEnv: TimerEnvironment
    
    public init(file: FileClient, healthKit: HealthKitClient, uuid: @escaping () -> UUID, now: @escaping () -> Date, medEnv: TimerEnvironment) {
        self.file = file
        self.healthKit = healthKit
        self.uuid = uuid
        self.now = now
        self.medEnv = medEnv
    }
}

public let homeReducer = Reducer<HomeState, HomeAction, HomeEnv>.combine(
    itemRowReducer.forEach(state: \HomeState.meditations, action: /HomeAction.edit(id:action:), environment: {_ in ItemRowEnvironment()} ),
    timedSessionReducer.pullback(
        state: OptionalPath(\HomeState.route)
            .appending(path: OptionalPath(/HomeState.Route.open)),
        action: /HomeAction.meditation,
        environment: { global in global.medEnv }),
    timedSessionReducer.pullback(
        state: OptionalPath(\HomeState.route)
            .appending(
                path: OptionalPath(/HomeState.Route.closed)
                    .appending(
                        path: OptionalPath(extract: { root in
                            root.0
                        }, set: { (root, value) in
                            root.0 = value
                        })
                    )
            ),
        action: /HomeAction.meditation,
        environment: { global in global.medEnv }),
    Reducer{ state, action, environment in
        switch action {
            
            
            
        case .edit(id: let id, action: .shouldDelete(status: true)):
            let meds = state.meditations
            let rawMeditations = Array(meds.map { $0.item } )
            if let index = rawMeditations.firstIndex(where: { $0.id == id } ) {
                let reversedSet: IndexSet = IndexSet(IndexSet([index]).reversed())
                state.meditations.remove(atOffsets: reversedSet)
            }
            return Effect.fireAndForget {
                environment.file.save(Array(meds.map { $0.item }))
            }
            
        case .edit(id: let id, action: .setEditNavigation(isActive: true)):
            state.route = .closed(state.timedSession, .item(id: id))
            return .none
            
        case .edit(id: let id, action: .setEditNavigation(isActive: false)):
            return .none
            
        case .edit(id: _, action: _):
            return .none
            
        case .meditation(.timerFinished):
            guard let timedState = state.timedSession else { fatalError() }
            let edit = ItemRowState(item: timedState.timedMeditation!, route: nil)
            state.meditations.removeOrAdd(item: edit)
            state.route = .closed(nil, nil)
            let meds = state.meditations
            return .fireAndForget {
                environment.healthKit.saveMindfulMinutes(-timedState.timedMeditation!.duration!)
                environment.file.save(Array(meds.map { $0.item }))
            }
            
        case .meditation(_):
            return .none
            
        case .presentTimedMeditationButtonTapped:
            state.route = .open(TimerState())
            return .none
            
            
            
        case .timerBottomBarPushed:
            state.route = .open(state.timedSession!)
            return .none
            
        case .setSheet(let isPresented):
            state.route = .closed(state.timedSession, nil)
            return .none
        }
    }
)

public struct HomeView: View {
    
    public init(store: Store<HomeState, HomeAction>) {
        self.store = store
    }
    var store: Store<HomeState, HomeAction>
    @State private var timerGoing = true
    
    public var body: some View {
        WithViewStore(self.store) { viewStore in
            ZStack {
                NavigationView {
                    List {
                        ForEachStore( self.store.scope(
                            state: { $0.meditations },
                            action: HomeAction.edit(id:action:)) )
                        { meditationStore in
                            ItemRowView(store: meditationStore)
                        }
                    }
                }
                .sheet(
                    isPresented: viewStore.binding(
                        get: { (homeState: HomeState) -> Bool  in
                            guard case .open = homeState.route else {
                                return false
                            }
                            return true
                        },
                        send: HomeAction.setSheet(isPresented:)
                    )
                ) {
                    IfLetStore(self.store.scope(
                        state: { state in
                            guard case .open(let sessionState) = state.route else {
                                return nil
                            }
                            return sessionState
                        },
                        action: { local in
                            HomeAction.meditation(local)
                        }),
                               then:TimerView.init(store:)
                    )
                }
            }
        }
    }
}

TimerFeature:

public struct TimerState: Equatable {
    
    let minutesList: [Double] = (1 ... 60).map(Double.init).map{$0}
    var selType: Int
    var selMin: Int
    var types: [String]
    public var timerData: TimerData?
    public var timedMeditation: Meditation?
    public var preparatoryTasks: Meditation.PreparatoryTask?
    var seconds: Double { self.minutesList[self.selMin] * 60 }
    var minutes: Double { self.minutesList[self.selMin] }
    var currentType: String { self.types[self.selType]}
    var timerGoing: Bool { return  nil != timerData}
    var paused: Bool = false
    
    public init(selType: Int = 0, selMin: Int = 0, types: [String] = [
        "Concentration",
        "Do Nothing"
    ], timerData: TimerData? = nil, timedMeditation: Meditation? = nil, _tdcount: Int = 0) {
        self.selType = selType
        self.selMin = selMin
        self.types = types
        self.timerData = timerData
        self.timedMeditation = timedMeditation
    }
}

public enum TimerAction: Equatable {
    case addNotificationResponse(Result<Int, UserNotificationClient.Error>)
    case cancelButtonTapped
    case didFinishLaunching(notification: UserNotification?)
    case didReceiveBackgroundNotification(BackgroundNotification)
    case pauseButtonTapped
    case pickMeditationTime(Int)
    case pickTypeOfMeditation(Int)
    case remoteCountResponse(Result<Int, RemoteClient.Error>)
    case requestAuthorizationResponse(Result<Bool, UserNotificationClient.Error>)
    case startTimerPushed(duration: Double)
    case timerFired
    case timerFinished
    case userNotification(UserNotificationClient.Action)
}

public struct TimerEnvironment {
    
    var remoteClient: RemoteClient
    public var userNotificationClient: UserNotificationClient
    var mainQueue: AnySchedulerOf<DispatchQueue>
    var now : () -> Date
    var uuid : () -> UUID
    
    public init(remoteClient: RemoteClient, userNotificationClient: UserNotificationClient, mainQueue: AnySchedulerOf<DispatchQueue>, now: @escaping () -> Date, uuid: @escaping () -> UUID) {
        self.remoteClient = remoteClient
        self.userNotificationClient = userNotificationClient
        self.mainQueue = mainQueue
        self.now = now
        self.uuid = uuid
    }
}

public let timerReducer = Reducer<TimerState, TimerAction, TimerEnvironment>{
    state, action, environment in
    struct TimerId: Hashable {}
    
    switch action {
    case let .didFinishLaunching(notification):
        
        return .merge(
            environment.userNotificationClient
                .delegate()
                .map(TimerAction.userNotification),
            environment.userNotificationClient.requestAuthorization([.alert, .badge, .sound])
                .catchToEffect()
                .map(TimerAction.requestAuthorizationResponse)
        )
        
    case let .didReceiveBackgroundNotification(backgroundNotification):
        let fetchCompletionHandler = backgroundNotification.fetchCompletionHandler
        guard backgroundNotification.content == .countAvailable else {
            return .fireAndForget {
                backgroundNotification.fetchCompletionHandler(.noData)
            }
        }
        
        return environment.remoteClient.fetchRemoteCount()
            .catchToEffect()
            .handleEvents(receiveOutput: { result in
                switch result {
                case .success:
                    fetchCompletionHandler(.newData)
                case .failure:
                    fetchCompletionHandler(.failed)
                }
            })
            .eraseToEffect()
            .map(TimerAction.remoteCountResponse)
        
    case .pickTypeOfMeditation(let index):
        state.selType = index
        Analytics.logEvent(Strings.typeEvent, parameters: [Strings.typeEvent: state.currentType as NSObject])
        return .none
        
    case .pickMeditationTime(let index):
        state.selMin = index
        Analytics.logEvent(Strings.durationEvent, parameters: [Strings.durationEvent: state.minutes as NSObject])
        return .none
        
    case let .startTimerPushed(duration:seconds):
        Analytics.logEvent(Strings.startEvent, parameters: nil)
        state.timerData = TimerData(endDate: environment.now() + seconds)
        
        state.timedMeditation = Meditation(id: environment.uuid(),
                                            date: environment.now().timeIntervalSince1970,
                                            duration: seconds,
                                            entry: "",
                                            title: state.currentType,
                                            preparatoryTasks: state.preparatoryTasks
        )
        
        let duration = state.timedMeditation!.duration
        let userActions = "User Actions"
        
        let content = UNMutableNotificationContent()
        content.title = "\(state.timedMeditation!.title) Complete"
        content.body = Strings.notificationBody
        content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: Sounds.bell))
        content.badge = 1
        content.categoryIdentifier = userActions
        content.userInfo.updateValue(state.timedMeditation!.id.uuidString, forKey: "uuid-string")
        
        let request = UNNotificationRequest(
            identifier: "example_notification",
            content: content,
            trigger: UNTimeIntervalNotificationTrigger(timeInterval: duration!, repeats: false)
        )
        
        let snoozeAction = UNNotificationAction(identifier: "Snooze", title: "Snooze", options: [])
        let deleteAction = UNNotificationAction(identifier: "Delete", title: "Delete", options: [.destructive])
        let category = UNNotificationCategory(identifier: userActions, actions: [snoozeAction, deleteAction], intentIdentifiers: [], options: [])
        
        return  Effect.merge(
            Effect.timer(id: TimerId(), every: 1, on: environment.mainQueue)
                .map { _ in TimerAction.timerFired },
            environment.userNotificationClient.removePendingNotificationRequestsWithIdentifiers(["example_notification"])
                .fireAndForget(),
            environment.userNotificationClient.add(request)
                .map(Int.init)
                .receive(on: DispatchQueue.main)
                .catchToEffect()
                .map(TimerAction.addNotificationResponse),
            environment.userNotificationClient.setNotificationCategories([category])
                .fireAndForget()
        )
        
    case let .remoteCountResponse(.success(count)):
        return .none
        
    case .remoteCountResponse(.failure):
        return .none
        
    case .requestAuthorizationResponse:
        return .none
        
    case .timerFired:
        let currentDate = Date()
        guard let date = state.timerData?.endDate,
              currentDate < date,
              DateInterval(start: currentDate, end: date).duration >= 0 else {
            return Effect(value: .timerFinished)
        }
        
        let seconds = DateInterval(start: currentDate, end: date).duration
        state.timerData?.timeLeft = seconds
        
        return .none
        
    case .timerFinished:
        state.timerData = nil
        Analytics.logEvent(Strings.finishedEvent, parameters: nil)
        return Effect.cancel(id: TimerId())
        
    case .addNotificationResponse(.success(let meint)):
        return .none
        
    case .addNotificationResponse(.failure(let error)):
        print(error)
        return .none
        
    case let .userNotification(.didReceiveResponse(response, completion)):
        let notification = UserNotification(userInfo: response.notification.request.content.userInfo())
        return .fireAndForget(completion)
        
    case .userNotification(.willPresentNotification(_, completion: let completion)):
        return .fireAndForget {
            completion([.list, .badge, .sound])
        }
        
    case .userNotification(.openSettingsForNotification(_)):
        return .none
        
    case .cancelButtonTapped:
        state.timerData = nil
        Analytics.logEvent(Strings.cancelEvent, parameters: nil)
        return Effect.merge(
            .cancel(id: TimerId()),
            environment.userNotificationClient.removePendingNotificationRequestsWithIdentifiers(["example_notification"])
                .fireAndForget()
        )
        
    case .pauseButtonTapped:
        switch state.paused {
        case false:
            state.paused = true
            Analytics.logEvent(Strings.pauseEvent, parameters: nil)
            return Effect.merge(
                .cancel(id: TimerId()),
                environment.userNotificationClient.removePendingNotificationRequestsWithIdentifiers(["example_notification"])
                    .fireAndForget()
            )
        case true:
            state.paused = false
            Analytics.logEvent(Strings.startEvent, parameters: nil)
            return Effect(value: .startTimerPushed(duration: state.timerData!.timeLeft!))
        }
    }
    
}

public struct TimerView: View {
    
    public var store: Store<TimerState, TimerAction>
    
    public init(store: Store<TimerState, TimerAction>) {
        self.store = store
    }
    
    public var body: some View {
        WithViewStore( self.store ) { viewStore in
            ScrollView {
                VStack(spacing: 15) {
                    Text(viewStore.currentType)
                        .font(.largeTitle)
                    Text(viewStore.timerData?.timeLeftLabel ?? "\(viewStore.minutes)")
                        .foregroundColor(.accentColor)
                        .font(.largeTitle)
                        .fontWeight(.black)
                        PickerFeature(
                            types: viewStore.types,
                            typeSelection:  viewStore.binding(
                                get: { $0.selType },
                                send: { .pickTypeOfMeditation($0) }
                            ),
                            minutesList: viewStore.minutesList,
                            minSelection: viewStore.binding(
                                get: { $0.selMin },
                                send: { .pickMeditationTime($0) }
                            )
                        )
                    Spacer()
                    if !viewStore.timerGoing {
                        Button(action: {
                                        viewStore.send(.startTimerPushed(duration: viewStore.seconds))
                        }) { Text(Strings.start)
                                .font(.body)
                                .foregroundColor(.white)
                                .background(Circle()
                                    .frame(width: 66.0, height: 66.0, alignment: .center)
                                    .foregroundColor(.accentColor)
                                )
                        }
                    } else {
                        HStack {
                            Spacer()
                            Button( action: {
                                viewStore.send(.cancelButtonTapped)
                            })  { Text(Strings.cancel)
                                    .font(.body)
                                    .foregroundColor(.white)
                                    .background(Circle()
                                        .frame(width: 66.0, height: 66.0, alignment: .center)
                                        .foregroundColor(.secondary)
                                    )
                            }
                            Spacer()
                            if (!viewStore.paused) {
                                Button( action: {
                                    viewStore.send(.pauseButtonTapped)
                                }) { Text(Strings.pause)
                                        .font(.body)
                                        .foregroundColor(.white)
                                        .background(Circle()
                                            .frame(width: 66.0, height: 66.0, alignment: .center)
                                            .foregroundColor(.accentColor)
                                        )
                                }
                            } else {
                                Button( action: {
                                                viewStore.send(.pauseButtonTapped)
                                }) { Text(Strings.start)
                                        .font(.body)
                                        .foregroundColor(.white)
                                        .background(Circle()
                                            .frame(width: 66.0, height: 66.0, alignment: .center)
                                            .foregroundColor(.accentColor)
                                        )
                                }
                            }
                            Spacer()
                        }
                    }
                }
                .padding(.top, 20)
                .padding(.bottom, 30)
            }
        }
    }
}