@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)
}
}
}
}