-
-
Save ilyapuchka/5d9f80d4c79b05fc42e697b90f748799 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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