Skip to content

Instantly share code, notes, and snippets.

@ilyapuchka
Created November 7, 2018 12:24
Show Gist options
  • Save ilyapuchka/5d9f80d4c79b05fc42e697b90f748799 to your computer and use it in GitHub Desktop.
Save ilyapuchka/5d9f80d4c79b05fc42e697b90f748799 to your computer and use it in GitHub Desktop.
import UIKit
import BabylonUI
import ReactiveSwift
import BabylonCore
import BabylonMaps
import Result
struct PharmaciesMapConfiguration: MapConfiguration {
let showMapSearch: Bool
let showsCancelButton: Bool
let selectionPolicy: MapReturnSelectionPolicy
init(_ selectionPolicy: MapReturnSelectionPolicy = .userTapsAnnotationView, showCancel: Bool = true, showMapSearch: Bool = true) {
self.selectionPolicy = selectionPolicy
self.showsCancelButton = showCancel
self.showMapSearch = showMapSearch
}
}
protocol PharmaciesChildBuilders {
func makeMap(
response: @escaping (MapResponse) -> Void,
modal: Flow,
presenting: Flow
) -> UIViewController
func makeDirectionsMap(
for place: Place,
modal: Flow,
presenting: Flow
) -> UIViewController
}
extension PharmaciesRendererContext {
public static func appointmentPrescriptions(isNewPrescriptionsEnabled: Bool) -> PharmaciesRendererContext {
return PharmaciesRendererContext(
isModal: true,
showsInfoHeader: isNewPrescriptionsEnabled,
onSelection: .dismiss
)
}
public static let clinicalRecords = PharmaciesRendererContext(
isModal: false,
showsInfoHeader: false,
onSelection: .showDirections
)
}
public struct PharmaciesBuilder: PharmaciesChildBuilders {
private let accessible: AuthenticatedAccessible
private let mapBuilder: MapBuilderProtocol
private let content: ClinicalRecordsContentProtocol
private let visuals: VisualDependenciesProtocol
public init(
accessible: AuthenticatedAccessible,
content: ClinicalRecordsContentProtocol,
visuals: VisualDependenciesProtocol,
mapBuilder: MapBuilderProtocol
) {
self.accessible = accessible
self.content = content
self.visuals = visuals
self.mapBuilder = mapBuilder
}
public func make(
context: PharmaciesRendererContext,
presentationFlow: Flow,
presenting: Flow,
pharmacySelectionObserver: Signal<PharmacyDTO, NoError>.Observer?
) -> UIViewController {
let businessController = PharmaciesBusinessController(accessible: accessible)
let viewModel = PharmaciesViewModel(
businessController: businessController,
pharmacySelectionObserver: pharmacySelectionObserver
)
let viewController = BabylonBoxViewController(
viewModel: viewModel,
renderer: PharmaciesRenderer.self,
rendererConfig: PharmaciesRenderer.Config(
context: context,
brandColors: visuals.styles.brandColors,
icons: content.icons
)
).with {
$0.navigationItem.largeTitleDisplayMode = .never
}
let flowController = PharmaciesFlowController(
presentationFlow: presentationFlow,
presenting: presenting,
builders: self
)
viewModel.routes
.observe(on: UIScheduler())
.observeValues(flowController.handle)
return viewController
}
public func makeMap(
response: @escaping (MapResponse) -> Void,
modal: Flow,
presenting: Flow
) -> UIViewController {
return mapBuilder.make(
locationType: .pharmacies,
configuration: PharmaciesMapConfiguration(),
mapResponse: response,
modal: modal,
presenting: presenting
)
}
func makeDirectionsMap(
for place: Place,
modal: Flow,
presenting: Flow
) -> UIViewController {
return mapBuilder.make(
selectedPlace: place,
configuration: PharmaciesMapConfiguration(.userAsksForDirections, showCancel: false, showMapSearch: false),
modal: modal,
presenting: presenting
)
}
}
import BabylonCore
import BabylonUI
final class PharmaciesFlowController {
private let presentationFlow: Flow
private let presenting: Flow
private let builders: PharmaciesChildBuilders
init(
presentationFlow: Flow,
presenting: Flow,
builders: PharmaciesChildBuilders
) {
self.presentationFlow = presentationFlow
self.presenting = presenting
self.builders = builders
}
func handle(_ route: PharmaciesViewModel.Route) {
switch route {
case let .showMap(response):
BabylonNavigationController { _, modal in
builders.makeMap(response: response, modal: modal, presenting: presentationFlow)
} |> presentationFlow.present
case let .showDirections(place):
builders.makeDirectionsMap(for: place, modal: presentationFlow, presenting: presenting)
|> presenting.present
case let .showAlert(error):
UIAlertController.make(error: error)
|> presentationFlow.present
case let .dismiss(completion):
presenting.dismiss(completion: completion)
}
}
}
import BabylonUI
import Bento
import BentoKit
import StyleSheets
import ReactiveSwift
import BabylonCore
public struct PharmaciesRendererContext {
let isModal: Bool
let showsInfoHeader: Bool
let onSelection: PharmaciesViewModel.Action.Selection
}
struct PharmaciesRenderer: BoxRenderer {
private let config: Config
private let observer: Sink<PharmaciesViewModel.Action>
private let title = LocalizationClinicalRecords.Pharmacies.pharmacies
private let leftBarItems: [BarButtonItem]
struct Config {
let context: PharmaciesRendererContext
let brandColors: BrandColorsProtocol
let icons: ClinicalRecordsIconsProtocol
}
init(observer: @escaping Sink<PharmaciesViewModel.Action>, appearance: BabylonAppAppearance, config: Config) {
self.config = config
self.observer = observer
self.leftBarItems = config.context.isModal
? [BarButtonItem(appearance: .text(LocalizationUI.Common.cancel), callback: .cancel >>> observer)]
: []
}
func render(state: PharmaciesViewModel.State) -> Screen<SectionId, RowId> {
switch state {
case .loading:
return render(emptyState: .loading)
case .loadingFailed:
return render(emptyState: .error)
case let .loaded(pharmacies, _), let .searching(pharmacies), let .showingDirections(pharmacies, _):
if pharmacies.isEmpty {
return render(emptyState: .empty)
} else {
return render(pharmacies: pharmacies)
}
case let .adding(pharmacies, pharamacy):
return render(pharmacies: pharmacies + [pharamacy])
case .deleting(var pharmacies, let index):
pharmacies.remove(at: index)
return render(pharmacies: pharmacies)
case let .dismissing(state, _):
return render(state: state)
}
}
var pharmacyStyleSheet: Component.TitledDescription.StyleSheet {
return Component.TitledDescription.StyleSheet()
.compose(\.backgroundColor, .white)
}
var addPharmacyStyleSheet: Component.TitledDescription.StyleSheet {
return Component.TitledDescription.StyleSheet()
.compose(\.backgroundColor, .white)
.compose(\.textStyles[0].textColor, config.brandColors.brandColor)
}
var sectionHeaderTitleStyleSheet: Component.TitledDescription.StyleSheet {
return Component.TitledDescription.StyleSheet()
.compose(\.layoutMargins, UIEdgeInsets(top: 22.0, left: 17.0, bottom: 4.0, right: 7.0))
.compose(\.backgroundColor, .clear)
.compose(\.textStyles[0].textColor, Colors.grey75)
.compose(\.textStyles[0].font, UIFont.preferredFont(forTextStyle: .footnote))
}
private func render(pharmacies: [PharmacyDTO]) -> Screen<SectionId, RowId> {
return Screen(
title: title,
leftBarItems: leftBarItems,
shouldUseSystemSeparators: true,
box: .empty
|-+ Section(
id: .pharmacies,
header: Component.TitledDescription(
texts: [.plain(LocalizationClinicalRecords.Pharmacies.saved)],
accessory: .none,
styleSheet: sectionHeaderTitleStyleSheet
)
)
|---* pharmacies.enumerated().map { index, pharmacy in
return Node(id: .pharmacy(pharmacy.id), component:
Component.TitledDescription(
texts: [.plain(pharmacy.name), .plain(pharmacy.displayAddress)],
accessory: .none,
didTap: .select(index: index, self.config.context.onSelection) >>> observer,
deleteAction: .action(
title: LocalizationUI.Common.delete,
callback: .deletePharmacy(index: index) >>> observer
),
styleSheet: self.pharmacyStyleSheet
))
}
|---+ Node(id: .addPharmacy, component:
Component.TitledDescription(
texts: [.plain(LocalizationClinicalRecords.Pharmacies.addPharmacy)],
accessory: .none,
didTap: .addPharmacy >>> observer,
styleSheet: self.addPharmacyStyleSheet
)),
pinnedToTopBox: .empty
|-? .iff(self.config.context.showsInfoHeader) {
Section(
id: .warning,
header: Component.TitledDescription(
texts: [.plain(LocalizationClinicalRecords.Pharmacies.callToCheckPrice)],
image: .init(value: .image(self.config.icons.infoIcon)),
accessory: .none,
styleSheet: Component.TitledDescription.StyleSheet()
.compose(\.backgroundColor, Colors.grey25)
.compose(\.textStyles[0].textColor, Colors.grey75)
.compose(\.imageOrLabel.tintColor, Colors.grey75)
)
)
}
)
}
private enum EmptyState {
case loading
case empty
case error
}
private func render(emptyState: EmptyState) -> Screen<SectionId, RowId> {
func render() -> Box<SectionId, RowId> {
switch emptyState {
case .loading:
return InfoRendererV2(brandColors: config.brandColors).renderLoading(sectionId: .info)
case .error:
let renderer = InfoRendererV2(brandColors: config.brandColors)
let error = AlertError(body: LocalizationUI.Error.generalFailureMessage)
let action: InfoRendererV2.Action = (LocalizationUI.Common.retry, .retry >>> observer)
return renderer.render(sectionId: .info, rowId: RowId.info, error: error, action: action)
case .empty:
let renderer = InfoRendererV2(
content: .image(config.icons.pharmacyInfoIcon),
brandColors: config.brandColors
)
let error = AlertError(
title: LocalizationClinicalRecords.Pharmacies.addPharmacyTitle,
body: LocalizationClinicalRecords.Pharmacies.addPharmacySubtitle
)
let action: InfoRendererV2.Action = (LocalizationUI.Common.add, .addPharmacy >>> observer)
return renderer.render(sectionId: .info, rowId: RowId.info, error: error, action: action)
}
}
// To avoid unnecessary fading and top-dow cell animations
// when transitioning from one state to another we have to use different form styles
let formStyle = { () -> BentoTableView.Layout in
switch emptyState {
case .loading: return .centerYAligned
case .error, .empty: return .centerYAlignedMinimumFading
}
}
return Screen(
title: title,
leftBarItems: leftBarItems,
formStyle: formStyle(),
shouldUseSystemSeparators: false,
box: render()
)
}
}
extension PharmaciesRenderer {
enum SectionId: Hashable {
case info
case warning
case pharmacies
}
enum RowId: Hashable {
case info(InfoRendererV2.ComponentId)
case pharmacy(PharmacyDTO.ID?)
case addPharmacy
}
}
import BabylonCore
import BabylonUI
import BabylonMaps
import Bento
import BentoKit
import ReactiveSwift
import ReactiveFeedback
import Result
final class PharmaciesViewModel: BoxViewModel {
let state: Property<State>
let routes: Signal<Route, NoError>
private let (actions, actionsObserver) = Signal<Action, NoError>.pipe()
private let pharmacySelectionObserver: Signal<PharmacyDTO, NoError>.Observer?
init(
businessController: PharmaciesBusinessControllerProtocol,
pharmacySelectionObserver: Signal<PharmacyDTO, NoError>.Observer?
) {
self.pharmacySelectionObserver = pharmacySelectionObserver
let (mapResponses, mapResponsesObserver) = Signal<MapResponse, NoError>.pipe()
state = Property(
initial: .loading,
reduce: PharmaciesViewModel.reduce,
feedbacks: [
PharmaciesViewModel
.userActions(actions: actions),
PharmaciesViewModel
.whenLoading(businessController: businessController),
PharmaciesViewModel
.whenFailed(),
PharmaciesViewModel
.whenSearching(mapResponses: mapResponses),
PharmaciesViewModel
.whenAdding(businessController: businessController),
PharmaciesViewModel
.whenDeleting(businessController: businessController),
PharmaciesViewModel
.whenShowingDirections(),
]
)
routes = state.signal.skipRepeats().filterMap { state in
switch state {
case let .loaded(_, error?):
return .showAlert(.make(error: error))
case .searching:
return .showMap(mapResponsesObserver.send(value:))
case let .showingDirections(_, place):
return .showDirections(place)
case let .dismissing(_, selectedPharmacy):
return .dismiss {
selectedPharmacy.map { pharmacySelectionObserver?.send(value: $0) }
}
default:
return nil
}
}
}
func send(action: PharmaciesViewModel.Action) {
actionsObserver.send(value: action)
}
static func reduce(_ state: State, _ event: Event) -> State {
switch state {
case .loading:
return reduceLoading(state: state, event: event)
case let .loadingFailed(error):
return reduceLoadingFailed(state: state, event: event, error: error)
case let .loaded(pharmacies, error):
return reduceLoaded(state: state, event: event, pharmacies: pharmacies, error: error)
case let .adding(pharmacies, pharmacy):
return reduceAdding(state: state, event: event, pharmacies: pharmacies, pharmacy: pharmacy)
case let .deleting(pharmacies, index):
return reduceDeleting(state: state, event: event, pharmacies: pharmacies, index: index)
case let .searching(pharmacies):
return reduceSearching(state: state, event: event, pharmacies: pharmacies)
case let .showingDirections(pharmacies, place):
return reduceShowingDirections(state: state, event: event, pharmacies: pharmacies, place: place)
default:
return state
}
}
}
// MARK: - Reducers
fileprivate extension PharmaciesViewModel {
static func reduceLoading(state: State, event: Event) -> State {
switch event {
case let .didFail(error):
return .loadingFailed(error)
case let .didLoad(pharmacies):
return .loaded(pharmacies, nil)
case .ui(.cancel):
return .dismissing(state, nil)
default:
return state
}
}
static func reduceLoadingFailed(state: State, event: Event, error: CoreError) -> State {
switch event {
case .ui(.retry):
return .loading
case .ui(.cancel):
return .dismissing(state, nil)
default:
return state
}
}
static func reduceLoaded(state: State, event: Event, pharmacies: [PharmacyDTO], error: CoreError?) -> State {
switch event {
case let .didLoad(pharmacies):
return .loaded(pharmacies, nil)
case .ui(.addPharmacy):
return .searching(pharmacies)
case let .ui(.deletePharmacy(index)):
return .deleting(pharmacies, index: index)
case let .ui(.select(index, action)):
switch action {
case .dismiss:
return .dismissing(state, pharmacies[index])
case .showDirections:
if let place = pharmacies[index].toPlace() {
return .showingDirections(pharmacies, place)
} else {
return .loaded(pharmacies, nil)
}
}
case .ui(.cancel):
return .dismissing(state, nil)
default:
return state
}
}
static func reduceAdding(state: State, event: Event, pharmacies: [PharmacyDTO], pharmacy: PharmacyDTO) -> State {
switch event {
case let .didAdd(pharmacy):
return .loaded(pharmacies + [pharmacy], nil)
case let .didFail(error):
return .loaded(pharmacies, error)
default:
return state
}
}
static func reduceDeleting(state: State, event: Event, pharmacies: [PharmacyDTO], index: Int) -> State {
switch event {
case .didDelete:
var pharmacies = pharmacies
pharmacies.remove(at: index)
return .loaded(pharmacies, nil)
case let .didFail(error):
return .loaded(pharmacies, error)
default:
return state
}
}
static func reduceSearching(state: State, event: Event, pharmacies: [PharmacyDTO]) -> State {
switch event {
case let .didSelectPlace(response):
if case let .selected(place) = response,
!pharmacies.contains(where: { $0.reference == place.reference }) {
return .adding(pharmacies, PharmacyDTO(place))
} else {
return .loaded(pharmacies, nil)
}
default:
return state
}
}
static func reduceShowingDirections(state: State, event: Event, pharmacies: [PharmacyDTO], place: Place) -> State {
switch event {
case let .didLoad(pharmacies):
return .loaded(pharmacies, nil)
default:
return state
}
}
}
// MARK: - Feedbacks
fileprivate extension PharmaciesViewModel {
static func userActions(actions: Signal<Action, NoError>) -> Feedback<State, Event> {
return Feedback { _ in
return actions.map(Event.ui)
}
}
static func whenLoading(
businessController: PharmaciesBusinessControllerProtocol
) -> Feedback<State, Event> {
return Feedback { state -> SignalProducer<Event, NoError> in
guard case .loading = state else { return .empty }
return businessController.fetch()
.map(Event.didLoad)
.replaceError(Event.didFail)
}
}
/// On failure immediately switch to loaded state and discard error
static func whenFailed() -> Feedback<State, Event> {
return Feedback { state -> SignalProducer<Event, NoError> in
guard case let .loaded(pharmacies, _?) = state else { return .empty }
return .value(.didLoad(pharmacies))
}
}
static func whenSearching(
mapResponses: Signal<MapResponse, NoError>
) -> Feedback<State, Event> {
return Feedback { state -> Signal<Event, NoError> in
guard case .searching = state else { return .empty }
return mapResponses.map(Event.didSelectPlace)
}
}
static func whenAdding(
businessController: PharmaciesBusinessControllerProtocol
) -> Feedback<State, Event> {
return Feedback { state -> SignalProducer<Event, NoError> in
guard case let .adding(_, pharmacy) = state,
let reference = pharmacy.reference else { return .empty }
return businessController.add(pharmacy: AddPharmacyRequest(reference: reference))
.map(Event.didAdd)
.replaceError(Event.didFail)
}
}
static func whenDeleting(
businessController: PharmaciesBusinessControllerProtocol
) -> Feedback<State, Event> {
return Feedback { state -> SignalProducer<Event, NoError> in
guard case let .deleting(pharmacies, index) = state else { return .empty }
return businessController.delete(pharmacy: pharmacies[index])
.map { _ in Event.didDelete }
.replaceError(Event.didFail)
}
}
fileprivate static func whenShowingDirections() -> Feedback<State, Event> {
return Feedback { state -> SignalProducer<Event, NoError> in
guard case let .showingDirections(pharmacies, _) = state else { return .empty }
return .value(.didLoad(pharmacies))
}
}
}
extension PharmaciesViewModel {
indirect enum State: Equatable {
case loading
case loadingFailed(CoreError)
case loaded([PharmacyDTO], CoreError?)
case searching([PharmacyDTO])
case showingDirections([PharmacyDTO], Place)
case adding([PharmacyDTO], PharmacyDTO)
case deleting([PharmacyDTO], index: Int)
case dismissing(State, PharmacyDTO?)
}
enum Event: Equatable {
case ui(Action)
case didLoad([PharmacyDTO])
case didFail(CoreError)
case didSelectPlace(MapResponse)
case didAdd(PharmacyDTO)
case didDelete
}
enum Action: Equatable {
case addPharmacy
case deletePharmacy(index: Int)
case select(index: Int, Selection)
case retry
case cancel
enum Selection: Equatable {
case dismiss
case showDirections
}
}
enum Route {
case showMap((MapResponse) -> Void)
case showDirections(Place)
case showAlert(AlertError)
case dismiss(completion: (() -> Void)?)
}
}
public extension PharmacyDTO {
init(_ place: Place) {
self.init(
id: .none,
name: place.name,
formattedAddress: "\(place.address?.firstLine ?? "")",
postCode: place.address?.postCode,
phoneNumber: place.phoneNumber,
faxNumber: .none,
reference: place.reference,
lat: place.location.latitude,
lng: place.location.longitude
)
}
func toPlace() -> Place? {
guard let lat = lat, let lng = lng else { return nil }
return Place(name: name,
reference: reference,
type: .pharmacies,
location: PlaceLocation(latitude: lat, longitude: lng),
openingHours: nil,
address: PlaceAddress(firstLine: formattedAddress ?? "", secondLine: "", postCode: postCode ?? ""),
phoneNumber: phoneNumber)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment