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