<repo_name>onsissond/Stories<file_sep>/Stories/Core/Services/NotificationService/NotificationProvider.swift
//
//  Created by onsissond.
//

import RxSwift
import UserNotifications

private let _storyIdentifierPrefix = "Story Notification"

struct NotificationProvider {
    var setupNotification: ([Story]) -> Single<SetupNotificationResult>
    var removeStoryNotifications: () -> Void
}

extension NotificationProvider {
    enum SetupNotificationResult {
        case failure
        case success
    }
}

extension NotificationProvider {
    static var live = NotificationProvider(
        setupNotification: _setupNotification(),
        removeStoryNotifications: { _removeStoryNotifications() }
    )
}

extension NotificationProvider {
    private static func _setupNotification(
        notificationCenter: UNUserNotificationCenter = .current(),
        options: UNAuthorizationOptions = [.alert, .sound],
        calendar: Calendar = .current,
        maxNotifications: Int = 10
    ) -> ([Story]) -> Single<SetupNotificationResult> {
        return { stories in
            .zip(stories.prefix(maxNotifications).map(
                _setupStoryNotification(
                    notificationCenter: notificationCenter,
                    options: options,
                    calendar: calendar
                )
            ))
            .map { results in
                results.allSatisfy { $0 == .success } ? .success : .failure
            }
        }
    }

    private static func _setupStoryNotification(
        notificationCenter: UNUserNotificationCenter = .current(),
        options: UNAuthorizationOptions = [.alert, .sound],
        calendar: Calendar = .current
    ) -> (Story) -> Single<SetupNotificationResult> {
        return { story in
            .create { subscriber in
                notificationCenter.requestAuthorization(
                    options: options
                ) { didAllow, _ in
                    guard didAllow else {
                        subscriber(.success(.failure))
                        return
                    }
                    notificationCenter.add(.init(
                        story: story,
                        calendar: calendar,
                        identifier: _storyIdentifierPrefix
                    )) { error in
                        guard error == nil else {
                            subscriber(.success(.failure))
                            return
                        }
                        subscriber(.success(.success))
                    }
                }
                return Disposables.create()
            }
            .subscribeOn(SerialDispatchQueueScheduler(qos: .utility))
            .observeOn(MainScheduler.instance)
        }
    }


    private static func _removeStoryNotifications(
        notificationCenter: UNUserNotificationCenter = .current()
    ) {
        notificationCenter.getPendingNotificationRequests { requests in
            let storiesIdentifiers = requests
                .map(\.identifier)
                .filter { $0.hasPrefix(_storyIdentifierPrefix) }
            notificationCenter.removePendingNotificationRequests(
                withIdentifiers: storiesIdentifiers
            )
        }
    }
}

private extension UNMutableNotificationContent {
    convenience init(story: Story) {
        self.init()
        title = L10n.Notification.title
        subtitle = story.preview.title
        body = story.preview.description ?? ""
    }
}


private extension UNNotificationRequest {
    convenience init(story: Story, calendar: Calendar, identifier: String) {
        let content = UNMutableNotificationContent(story: story)
        var triggerDate = calendar.dateComponents(
            [.year, .month, .day, .hour, .minute, .second],
            from: story.publishDate
        )
        triggerDate.hour = 12

        let trigger = UNCalendarNotificationTrigger(
            dateMatching: triggerDate,
            repeats: false
        )
        let identifier = identifier.appending(story.id)
        self.init(
            identifier: identifier,
            content: content,
            trigger: trigger
        )
    }
}
<file_sep>/Stories/UI/StoryContentView/SummaryContentStoryView/SummaryContentStoryView.swift
//
//  Created by onsissond.
//

import UIKit
import RxSwift
import RxCocoa

final class SummaryContentStoryView: UIView {
    private let _disposeBag = DisposeBag()
    private lazy var _dismissButton: UIButton = .makeStoryDismiss()
    private lazy var _imageView: UIImageView = {
        $0.contentMode = .scaleAspectFill
        return $0
    }(UIImageView())

    private lazy var _stackView: UIStackView = {
        $0.axis = .vertical
        $0.alignment = .fill
        $0.spacing = 8
        return $0
    }(UIStackView())

    private lazy var _headerView = SummaryHeaderStoryView()
    private lazy var _flightView = SummaryInfoStoryView()
    private lazy var _accommodationView = SummaryInfoStoryView()
    private lazy var _nutritionView = SummaryInfoStoryView()
    private lazy var _entertainmentView = SummaryInfoStoryView()
    private lazy var _summaryFooterStoryView = SummaryFooterStoryView()
    private lazy var _buyTicketButton: UIButton = {
        $0.setTitle(L10n.SummaryContentStoryView.BuyButton.title, for: .normal)
        return $0
    }(UIButton())
    private lazy var _feedbackButton: UIButton = {
        $0.setTitle("Неинтересно", for: .normal)
        $0.setTitleColor(.lightGray, for: .normal)
        return $0
    }(UIButton())

    private lazy var _gradientLayer: CAGradientLayer = {
        let layer = CAGradientLayer()
        layer.startPoint = CGPoint(x: 1, y: 0)
        layer.endPoint = CGPoint(x: 1, y: 1)
        layer.colors = [
            UIColor.clear.cgColor,
            UIColor(hex: 0xF1D2F).withAlphaComponent(0.4).cgColor,
            UIColor(hex: 0xF1D2F).withAlphaComponent(0.6).cgColor,
            UIColor(hex: 0x11834).cgColor
        ]
        layer.locations = [0, 0.08, 0.65, 1]
        return layer
    }()

    private var _viewState: SummaryStoryContent?

    init() {
        super.init(frame: .zero)
        _setupSubviews()
        _setupLayout()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        _gradientLayer.frame = frame
    }

    private func _setupLayout() {
        _imageView.layer.insertSublayer(_gradientLayer, at: 0)
    }

    private func _setupSubviews() {
        addSubview(_imageView)
        _imageView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _imageView.topAnchor.constraint(equalTo: topAnchor),
            _imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
            _imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
            _imageView.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])
        addSubview(_stackView)
        if hasEyebrow {
            _stackView.topAnchor.constraint(equalTo: topAnchor, constant: 80).isActive = true
        } else {
            _stackView.topAnchor.constraint(equalTo: topAnchor, constant: 62).isActive = true
        }
        _stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16).isActive = true
        _stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16).isActive = true
        if hasEyebrow {
            _stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -32).isActive = true
        } else {
            _stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16).isActive = true
        }
        _stackView.translatesAutoresizingMaskIntoConstraints = false

        addSubview(_dismissButton)
        _dismissButton.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _dismissButton.topAnchor.constraint(equalTo: _stackView.topAnchor),
            _dismissButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16)
        ])

        _stackView.addArrangedSubview(_headerView)
        _stackView.addArrangedSubview(_flightView)
        _stackView.addArrangedSubview(_accommodationView)
        _stackView.addArrangedSubview(_nutritionView)
        _stackView.addArrangedSubview(_entertainmentView)
        _stackView.addArrangedSubview(UIView())
        _stackView.addArrangedSubview(_summaryFooterStoryView)
        _stackView.addArrangedSubview(_buyTicketButton)
        _stackView.addArrangedSubview(_feedbackButton)
    }
}

extension SummaryContentStoryView: StoryContentView {
    func render(viewState: StoryContent) -> Bool {
        guard case .summary(let viewState) = viewState else {
            return false
        }
        _viewState = viewState
        _imageView.kf.setImage(
            with: viewState.image.imageURL,
            options: [.transition(.fade(0.5))]
        )
        _headerView.render(viewState: .init(
            period: viewState.period.value,
            title: viewState.title
        ))
        _flightView.render(viewState: .trip(
            from: viewState.trip.from,
            to: viewState.trip.to
        ))
        _accommodationView.render(viewState: .accommodation(
            title: viewState.accommodation.title,
            description: viewState.accommodation.description
        ))
        _nutritionView.render(viewState: .nutrition(
            title: viewState.nutrition.title,
            description: viewState.nutrition.description
        ))
        _entertainmentView.render(viewState: .entertainment(
            title: viewState.entertainment.title,
            description: viewState.entertainment.description
        ))
        _summaryFooterStoryView.render(viewState: .init(
            title: viewState.price.title,
            subtitle: viewState.price.subtitle,
            price: viewState.price.value
        ))
        return true
    }
}

extension SummaryContentStoryView {
    enum Event {
        case dismiss
        case openDeeplink(URL)
        case requestFeedback
    }

    var events: ControlEvent<Event> {
        ControlEvent(events: Observable.merge([
            _dismissButton.rx.controlEvent(.touchUpInside).map { _ in .dismiss },
            _buyTicketButton.rx.controlEvent(.touchUpInside).map { [weak self] in
                self?._viewState?.deeplink
            }
            .filterNil()
            .map(Event.openDeeplink),
            _feedbackButton.rx.controlEvent(.touchUpInside).map { _ in .requestFeedback }
        ]))
    }
}
<file_sep>/Stories/UI/StoriesViewController/SwitchableView.swift
//
//  Created by onsissond.
//

import UIKit

class SwitchableView: UIView {
    private var _contentViews: [StoryContentView]

    init(contentViews: [StoryContentView]) {
        _contentViews = contentViews
        super.init(frame: .zero)
        _setupSubviews()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func _setupSubviews() {
        _contentViews.forEach { view in
            addSubview(view)
            view.topAnchor.constraint(equalTo: topAnchor).isActive = true
            view.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
            view.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
            view.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
            view.translatesAutoresizingMaskIntoConstraints = false
        }
    }
}

extension SwitchableView: StoryContentView {
    func render(viewState: StoryContent) -> Bool {
        _contentViews.forEach {
            $0.isHidden = !$0.render(viewState: viewState)
        }
        return true
    }
}
<file_sep>/Stories/Core/Domain/StoryContent.swift
//
//  Created by onsissond.
//

import UIKit

enum StoryContent: Equatable {
    case regular(RegularStoryContent)
    case summary(SummaryStoryContent)
}

struct ImageStoryContent: Equatable {
    var imageURL: URL
}

struct RegularStoryContent: Equatable {
    struct Info: Equatable {
        var title: String
        var subtitle: String
    }
    var image: ImageStoryContent
    var title: String = ""
    var description: String = ""
    var periodInfo: Info?
    var priceInfo: Info?
}

struct SummaryStoryContent: Equatable {
    var image: ImageStoryContent
    var period: Period
    var title: String = ""
    var price: Price
    var deeplink: URL
    var trip: Trip
    var accommodation: Attachment
    var nutrition: Attachment
    var entertainment: Attachment
}

extension SummaryStoryContent {
    struct Period: Equatable {
        var title: String
        var value: String
    }

    struct Price: Equatable {
        var title: String
        var subtitle: String
        var value: String
    }

    struct Trip: Equatable {
        var to: TripInfo
        var from: TripInfo
    }

    struct Attachment: Equatable {
        var title: String
        var description: String
    }

    struct TripInfo: Equatable {
        var transport: Transport
        var title: String
        var subtitle: String
    }
}

// MARK: - Decodable
extension StoryContent: Decodable {
    enum CodingKeys: String, CodingKey {
        case type
    }

    enum ContentType: String, Decodable {
        case regular
        case summary
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let type = try container.decode(ContentType.self, forKey: .type)
        switch type {
        case .regular:
            self = .regular(try RegularStoryContent(from: decoder))
        case .summary:
            self = .summary(try SummaryStoryContent(from: decoder))
        }
    }
}

extension RegularStoryContent: Decodable {
    enum CodingKeys: String, CodingKey {
        case title
        case text
        case price
        case period
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        image = try ImageStoryContent(from: decoder)
        title = try container.decode(String.self, forKey: .title)
        description = try container.decode(String.self, forKey: .text)
        priceInfo = try container.decodeIfPresent(RegularStoryContent.Info.self, forKey: .price)
        periodInfo = try container.decodeIfPresent(RegularStoryContent.Info.self, forKey: .period)
    }
}

extension RegularStoryContent.Info: Decodable {
    enum CodingKeys: String, CodingKey {
        case title
        case subtitle = "value"
    }
}

extension SummaryStoryContent: Decodable {
    enum CodingKeys: String, CodingKey {
        case title
        case period
        case price
        case trip
        case deeplink
        case accommodation
        case nutrition
        case entertainment
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        image = try ImageStoryContent(from: decoder)
        title = try container.decode(String.self, forKey: .title)
        price = try container.decode(Price.self, forKey: .price)
        period = try container.decode(Period.self, forKey: .period)
        deeplink = try container.decode(URL.self, forKey: .deeplink)
        trip = try container.decode(Trip.self, forKey: .trip)
        accommodation = try container.decode(Attachment.self, forKey: .accommodation)
        nutrition = try container.decode(Attachment.self, forKey: .nutrition)
        entertainment = try container.decode(Attachment.self, forKey: .entertainment)
    }
}

extension SummaryStoryContent.Period: Decodable {}

extension SummaryStoryContent.Price: Decodable {}

extension SummaryStoryContent.Trip: Decodable {}

extension SummaryStoryContent.TripInfo: Decodable {
    enum CodingKeys: String, CodingKey {
        case type
        case title
        case description
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        transport = try container.decode(Transport.self, forKey: .type)
        title = try container.decode(String.self, forKey: .title)
        subtitle = try container.decode(String.self, forKey: .description)
    }
}

extension SummaryStoryContent.Attachment: Decodable {}

extension ImageStoryContent: Decodable {
    enum CodingKeys: String, CodingKey {
        case image
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        imageURL = try container.decode(URL.self, forKey: .image)
    }
}
<file_sep>/Stories/UI/FeedbackViewController/WKWebViewController.swift
//
//  Created by onsissond.
//

import RxSwift
import WebKit

class WKWebViewController: UIViewController {

    private var _webViewNavigationHandler: ((WKNavigationActionPolicy) -> Void)?

    private lazy var _webView: WKWebView = {
        let webView = WKWebView(frame: .zero)
        webView.navigationDelegate = self
        return webView
    }()

    private lazy var _indicator: UIActivityIndicatorView = {
        let indicator = UIActivityIndicatorView()
        indicator.hidesWhenStopped = true
        indicator.startAnimating()
        return indicator
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        _addSubviews()
    }

    private func _addSubviews() {
        _webView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(_webView)
        NSLayoutConstraint.activate([
            _webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            _webView.topAnchor.constraint(equalTo: view.topAnchor),
            _webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            _webView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])

        _indicator.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(_indicator)
        NSLayoutConstraint.activate([
            _indicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            _indicator.centerXAnchor.constraint(equalTo: view.centerXAnchor)
        ])
    }
}

extension WKWebViewController: WKNavigationDelegate {
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        _indicator.stopAnimating()
    }
}

extension WKWebViewController {
    func render(viewState: URL) {
        _webView.load(.init(url: viewState))
    }
}
<file_sep>/Stories/UI/StoriesViewController/StoriesViewController.swift
//
//  Created by onsissond.
//

import UIKit
import RxSwift
import ComposableArchitecture

enum StoriesCellIdentifier: String {
    case regular
}
class StoriesViewController: UIViewController {
    private let _store: StoriesSystem.LocalStore
    private let _disposeBag = DisposeBag()
    private lazy var _viewStore = ViewStore(_store)
    private var _viewTranslation = CGPoint.zero

    private lazy var collectionView: UICollectionView = {
        $0.bounces = false
        $0.isScrollEnabled = false
        $0.isPagingEnabled = true
        $0.showsHorizontalScrollIndicator = false
        $0.register(
            StoryCell.self,
            forCellWithReuseIdentifier: StoriesCellIdentifier.regular.rawValue
        )
        $0.delegate = self
        $0.dataSource = self
        return $0
    }(UICollectionView(frame: .zero, collectionViewLayout: .cube))

    init(store: StoriesSystem.LocalStore) {
        _store = store
        super.init(nibName: nil, bundle: nil)
        modalPresentationStyle = .overFullScreen
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        _setupGesturesRecognizers()
        _setupSubviews()
        _setupSubscriptions()
        _startCurrentStory()
    }

    private func _startCurrentStory() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            if self._store.state.currentStory != 0 {
                self.collectionView.selectItem(
                    at: .init(row: self._store.state.currentStory, section: 0),
                    animated: false,
                    scrollPosition: .centeredHorizontally
                )
            }
            self._viewStore.send(.storyAction(
                storyIndex: self._viewStore.state.currentStory,
                action: .run
            ))
        }
    }

    private func _setupSubscriptions() {
        _viewStore.publisher.map(\.currentStory)
            .distinctUntilChanged()
            .bind(onNext: { [weak self] currentStory in
                self?.collectionView.selectItem(
                    at: IndexPath(row: currentStory, section: 0),
                    animated: true,
                    scrollPosition: .centeredHorizontally
                )
            })
            .disposed(by: _disposeBag)

        _viewStore.publisher.map(\.feedbackAlert)
            .distinctUntilChanged()
            .filterNil()
            .bind(onNext: { [weak self] in
                guard let self = self else { return }
                self.present(
                    AlertBuilder()
                        .with(alertState: $0, sendAction: self._viewStore.send)
                        .build(),
                    animated: true
                )
            })
            .disposed(by: _disposeBag)

        _viewStore.publisher.map(\.feedbackURL)
            .distinctUntilChanged()
            .filterNil()
            .bind(onNext: { [weak self] in
                let webView = WKWebViewController()
                webView.presentationController?.delegate = self
                webView.render(viewState: $0)
                self?.present(webView, animated: true, completion: {
                    self?._viewStore.send(.launchedFeedback)
                })
            })
            .disposed(by: _disposeBag)
    }

    private func _setupSubviews() {
        view.addSubview(collectionView)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.topAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }
}

extension StoriesViewController {
    private func _setupGesturesRecognizers() {
        let longPressRecognizer = UILongPressGestureRecognizer(
            target: self, action: #selector(longPressed)
        )
        longPressRecognizer.minimumPressDuration = 0.2
        view.addGestureRecognizer(longPressRecognizer)

        view.addGestureRecognizer(UIPanGestureRecognizer(
            target: self, action: #selector(handleDismiss)
        ))
    }

    @objc private func longPressed(sender: UILongPressGestureRecognizer) {
        guard let indexPath = collectionView.indexPathsForVisibleItems.first else {
            return
        }
        if sender.state == .began {
            _viewStore.send(.storyAction(storyIndex: indexPath.row, action: .pause))
        } else if sender.state == .ended {
            _viewStore.send(.storyAction(storyIndex: indexPath.row, action: .continue))
        }
    }

    @objc func handleDismiss(sender: UIPanGestureRecognizer) {
        switch sender.state {
        case .changed:
            _viewTranslation = sender.translation(in: view)
            if _viewTranslation.y < 0 { break }
            _viewStore.send(
                .storyAction(storyIndex: _viewStore.currentStory, action: .pause)
            )
            UIView.animate(
                withDuration: 0.5,
                delay: 0,
                usingSpringWithDamping: 0.7,
                initialSpringVelocity: 1,
                options: .curveEaseOut,
                animations: {
                self.view.transform = CGAffineTransform(
                    translationX: 0,
                    y: self._viewTranslation.y
                )
            })
        case .ended:
            if _viewTranslation.y < 200 {
                UIView.animate(
                    withDuration: 0.5,
                    delay: 0,
                    options: .curveEaseOut,
                    animations: { [weak self] in
                        self?.view.transform = .identity
                    },
                    completion: { [weak self] _ in
                        guard let self = self else { return }
                        self._viewStore.send(.storyAction(
                            storyIndex: self._viewStore.currentStory,
                            action: .continue
                        ))
                    })
            } else {
                _viewStore.send(.storyAction(
                    storyIndex: _viewStore.currentStory,
                    action: .dismiss
                ))
            }
        default:
            break
        }
    }
}

extension StoriesViewController: UICollectionViewDataSource {
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        1
    }

    func collectionView(
        _ collectionView: UICollectionView,
        numberOfItemsInSection section: Int
    ) -> Int {
        _store.state.stories.count
    }
}

extension StoriesViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(
        _ collectionView: UICollectionView,
        cellForItemAt indexPath: IndexPath
    ) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(
            withReuseIdentifier: StoriesCellIdentifier.regular.rawValue,
            for: indexPath
        )
        if let cell = cell as? StoryCell {
            cell.render(store: _store.scope(
                state: \.stories[indexPath.row],
                action: { .storyAction(storyIndex: indexPath.row, action: $0) }
            ))
        }
        return cell
    }

    func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        sizeForItemAt indexPath: IndexPath
    ) -> CGSize {
        view.bounds.size
    }

    func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        insetForSectionAt section: Int
    ) -> UIEdgeInsets {
        .zero
    }

    func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        minimumLineSpacingForSectionAt section: Int
    ) -> CGFloat {
        0
    }

    func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        minimumInteritemSpacingForSectionAt section: Int
    ) -> CGFloat {
        0
    }
}

extension StoriesViewController: UIAdaptivePresentationControllerDelegate {
    func presentationControllerDidDismiss(
        _ presentationController: UIPresentationController
    ) {
        _viewStore.send(.storyAction(
            storyIndex: _viewStore.state.currentStory,
            action: .continue
        ))
    }
}

extension AlertState where Action == StoriesSystem.Action {
    static var feedback = AlertState<StoriesSystem.Action>(
        title: L10n.Alert.Feedback.title,
        message: L10n.Alert.Feedback.message,
        primaryButton: .cancel(
            L10n.Alert.Feedback.Button.cancel,
            send: .dismissFeedbackAlert
        ),
        secondaryButton: .default(
            L10n.Alert.Feedback.Button.ok,
            send: .launchFeedback
        )
    )
}

<file_sep>/Stories/Core/API/FetchStoriesRequest.swift
//
//  Created by onsissond.
//

import Moya

struct FetchStoriesRequest: TargetType {

    var baseURL: URL {
        URL(string: "http://fakeurl/")!
    }

    var path: String {
        "v1/stories/config/"
    }

    var method: Moya.Method {
        .get
    }

    var task: Task {
        .requestPlain
    }

    var headers: [String: String]? {
        nil
    }

    var sampleData: Data {
        """
        [
          {
            "id": "1",
            "title": "Сочи",
            "subtitle": "Неделя за 50 000 ₽ на одного",
            "thumb": "https://firebasestorage.googleapis.com/v0/b/mobile-transport-app.appspot.com/o/story%2Fsochi%2F1.jpg?alt=media&token=<PASSWORD>",
            "publishDate": "2021-05-21",
            "expireDate": "2021-05-23",
            "pages": [
              {
                "type": "regular",
                "image": "https://firebasestorage.googleapis.com/v0/b/mobile-transport-app.appspot.com/o/story%2Fsochi%2F1.jpg?alt=media",
                "title": "Погода",
                "text": "Сухая солнечная погода обычно устанавливается в Сочи к середине апреля. Температура держится в районе 15 градусов тепла, но возможны пасмурные и дождливые дни, так что ветровка и непромокаемая обувь нужны обязательно."
              },
              {
                "type": "regular",
                "image": "https://firebasestorage.googleapis.com/v0/b/mobile-transport-app.appspot.com/o/story%2Fsochi%2F2.jpg?alt=media",
                "title": "Парк «Дендрарий»",
                "text": "Коллекция знаменитого сочинского дендрария насчитывает около 1800 деревьев, цветов и кустарников. Уделите прогулке по парку не менее трёх часов. Взрослый билет стоит 250 ₽, детский — 120 ₽; экскурсия на электромобиле — 150 ₽ с человека; билет на канатку между верхней и нижней частями парка — 350 ₽ взрослый и 200 ₽ детский."
              },
              {
                "type": "regular",
                "image": "https://firebasestorage.googleapis.com/v0/b/mobile-transport-app.appspot.com/o/story%2Fsochi%2F3.jpg?alt=media",
                "title": "Морской вокзал и Зимний театр",
                "text": "Главные архитектурные символы Сочи и идеальные образцы сталинского ампира. Прогулка от Морпорта до театра по улице Орджоникидзе займёт полчаса. По пути загляните в легендарную хинкальную «Белые ночи» (средник чек на человека — 1000 ₽ с напитком)."
              },
              {
                "type": "regular",
                "image": "https://firebasestorage.googleapis.com/v0/b/mobile-transport-app.appspot.com/o/story%2Fsochi%2F4.jpg?alt=media",
                "title": "Тисо-самшитовая роща",
                "text": "Для прогулки на природе отлично подойдёт тисо-самшитовая роща —  небольшой реликтовый лес с оборудованной тропой. Лучше всего зайти с главного входа и закончить каньоном «Чёртовы ворота», где можно искупаться и пообедать на базе отдыха. По пути увидите развалины Хостинской крепости VIII века. Прогулка по роще взрослым обойдётся в 300 ₽, детям до 14 лет — 150 ₽, до 7 лет — бесплатно."
              },
              {
                "type": "regular",
                "image": "https://firebasestorage.googleapis.com/v0/b/mobile-transport-app.appspot.com/o/story%2Fsochi%2F5.jpg?alt=media",
                "title": "Где жить",
                "text": "От отеля Allure of the Seа (от 4000 ₽/ночь) удобно добираться до тисо-самшитовой рощи, агурских водопадов, Орлиных скал, горы Ахун. Заказать экскурсию можно в отеле. Есть детский клуб, пинг-понг, бильярд, бассейн, столовая и буфет. До пляжа — 400 метров по тенистой аллее.\\n\\nБолее бюджетный вариант — GRACE O’DIN (от 1900 ₽/ночь) на Северной в пяти минутах от вокзала. До основных достопримечательностей отсюда можно добраться пешком."
              },
              {
                "type": "summary",
                "image": "https://firebasestorage.googleapis.com/v0/b/mobile-transport-app.appspot.com/o/story%2Fsochi%2F6.jpg?alt=media",
                "title": "<NAME>",
                "price": {
                  "title": "Бюджет поездки:",
                  "subtitle": "в расчете на одного человека",
                  "value": "50 000 ₽"
                },
                "period": {
                  "title": "Когда",
                  "value": "27 мар - 1 апр"
                },
                "deeplink": "https://www.google.com",
                "trip": {
                  "to": {
                    "type": "avia",
                    "title": "Уральские авиалинии от 4 156  Р",
                    "description": "туда 27 марта в 20:00"
                  },
                  "from": {
                    "type": "avia",
                    "title": "Уральские авиалинии от 5 000  Р",
                    "description": "обратно 1 апреля в 13:40"
                  }
                },
                "accommodation": {
                  "title": "11 000–24 000 ₽",
                  "description": "Неделя в двухместном номере с завтраком"
                },
                "nutrition": {
                  "title": "1000 ₽",
                  "description": "Средний чек (с напитком) в популярных недорогих кафе"
                },
                "entertainment": {
                  "title": "от 10 000 ₽",
                  "description": "Экскурсии, прогулки, развлечения, сувениры"
                }
              }
            ]
          },
          {
            "id": "2",
            "title": "Тула",
            "subtitle": "Выходные за 30 000 ₽ на одного",
            "thumb": "https://firebasestorage.googleapis.com/v0/b/mobile-transport-app.appspot.com/o/story%2Ftula%2F1.jpg?alt=media",
            "publishDate": "2021-03-21",
            "expireDate": "2021-03-29",
            "pages": [
              {
                "type": "regular",
                "image": "https://firebasestorage.googleapis.com/v0/b/mobile-transport-app.appspot.com/o/story%2Ftula%2F1.jpg?alt=media",
                "title": "Погода",
                "text": "В апреле в Туле бывает дождливо и пасмурно, температура выше 10 градусов поднимется только к концу месяца, так что прогулкам на свежем воздухе лучше предпочесть осмотр музеев и ужины в модных ресторанах."
              },
              {
                "type": "regular",
                "image": "https://firebasestorage.googleapis.com/v0/b/mobile-transport-app.appspot.com/o/story%2Ftula%2F2.jpg?alt=media",
                "title": "Тульский Кремль",
                "text": "Крепость с 500-летней историей: здесь в Смутное время бояре присягали на верность Лжедмитрию I. В одной из крепостных стен есть лавки с тульскими пряниками, белёвской пастилой, суворовскими конфетами и, конечно, самоварами. Взрослый билет в Кремль стоит 250 ₽."
              },
              {
                "type": "regular",
                "image": "https://firebasestorage.googleapis.com/v0/b/mobile-transport-app.appspot.com/o/story%2Ftula%2F3.jpg?alt=media",
                "title": "Креативное пространство «Искра»",
                "text": "Здесь есть кофейни и бары, дизайнерские магазинчики, барбершопы, чайные, коворкинг, хостел. В рюмочной Lil Pil на территории «Искры» подают настойки, зерновые дистилляты и креплёное вино. Ещё одна интересная локация— мясной ресторан пивоварни Salden’s, которая поставляет пиво по всей России (средний чек — 1500 ₽ на человека без алкоголя). "
              },
              {
                "type": "regular",
                "image": "https://firebasestorage.googleapis.com/v0/b/mobile-transport-app.appspot.com/o/story%2Ftula%2F4.jpg?alt=media",
                "title": "Поленово",
                "text": "Усадьба <NAME>, где хранятся его работы и личные вещи. В доме вы увидите эскиз знаменитой картины «Христос и грешница», а спустившись к Оке — меланхоличные пейзажи, которыми вдохновлялся художник. Музей находится в часе езды от Тулы, взрослый билет с экскурсией стоит 350 ₽, детский — 150 ₽."
              },
              {
                "type": "regular",
                "image": "https://firebasestorage.googleapis.com/v0/b/mobile-transport-app.appspot.com/o/story%2Ftula%2F5.jpg?alt=media",
                "title": "Где жить",
                "text": "Курортный спа-отель Grumant (от 4500 ₽/ночь с завтраком) находится рядом со знаменитой усадьбой Ясная Поляна, родовым имением Льва Толстого. В отеле есть бассейн, сауна и хаммам, боулинг; до центра Тулы — полчаса езды.\\n\\nВариант в той же ценовой категории, но в центре города — бутик-отель 11 Hotel & Garden. В 10 минутах ходьбы от отеля расположен «Ликёрка лофт» — ещё одно модное городское пространство с граффити, ресторанчиками и инстаграмными видами."
              },
              {
                "type": "summary",
                "image": "https://firebasestorage.googleapis.com/v0/b/mobile-transport-app.appspot.com/o/story%2Ftula%2F6.jpg?alt=media",
                "title": "Выходные в Туле",
                "price": {
                  "title": "Бюджет поездки:",
                  "subtitle": "в расчете на одного человека",
                  "value": "30 000 ₽"
                },
                "period": {
                  "title": "Когда",
                  "value": "27-28 марта"
                },
                "deeplink": "https://www.google.com",
                "trip": {
                  "to": {
                    "type": "train",
                    "title": "Ласточка от 1 171 ₽",
                    "description": "туда 27 марта в 08:47"
                  },
                  "from": {
                    "type": "train",
                    "title": "Ласточка-премиум от 1 220 ₽",
                    "description": "обратно 28 марта в 20:39"
                  }
                },
                "accommodation": {
                  "title": "13 500 ₽",
                  "description": "Три ночи в двухместном номере с завтраком"
                },
                "nutrition": {
                  "title": "2000–2500 ₽",
                  "description": "Средний чек (с алкоголем) в модных ресторанах города и пригорода"
                },
                "entertainment": {
                  "title": "до 5000 ₽",
                  "description": "Экскурсии, развлечения, шопинг и сувениры"
                }
              }
            ]
          },
          {
            "id": "3",
            "title": "Казань",
            "subtitle": "Царство янтаря на берегах бодрящего Балтийского моря",
            "thumb": "https://picsum.photos/id/10/200/300",
            "publishDate": "2021-03-27",
            "expireDate": "2021-04-02",
            "pages": [
              {
                "type": "regular",
                "image": "https://picsum.photos/id/10/720/1280",
                "title": "Весна в Калинграде",
                "text": "Суровое море, прекрасная прибалтийская природа и насыщенная история региона: каждая эпоха нашла в городе и окрестностях свое отражение.",
                "price": {
                  "title": "Бюджет",
                  "value": "от 55000 Р"
                },
                "period": {
                  "title": "Когда",
                  "value": "27 мар - 1 апр"
                }
              },
              {
                "type": "regular",
                "image": "https://picsum.photos/id/1000/720/1280",
                "title": "Остров Канта",
                "text": "Остров Канта, именовавшийся ранее островом Кнайпхоф, занимает место посреди реки Преголи. В лучшие свои дни город Кнайпхоф был центром судоходства и торговли, активно застраивался, располагал десятками дорог и сотнями домов и соединялся с материком пятью мостами.",
                "price": {
                  "title": "Бюджет",
                  "value": "55000"
                }
              },
              {
                "type": "regular",
                "image": "https://picsum.photos/id/900/720/1280",
                "title": "Форт «Дёнхофф»",
                "text": "Если вам по душе архитектурный стиль Кафедрального собора, обязательно посетите хотя бы несколько городских ворот и фортов. При наличии свободного времени прогуляйтесь по маршруту бывшего оборонительного кольца Кёнигсберга"
              },
              {
                "type": "summary",
                "image": "https://picsum.photos/id/1019/720/1280",
                "title": "Весна в Калинграде",
                "price": {
                  "title": "Бюджет поездки:",
                  "subtitle": "в расчете на одного человека",
                  "value": "55 000 ₽"
                },
                "period": {
                  "title": "Когда",
                  "value": "27 мар - 1 апр"
                },
                "deeplink": "https://www.google.com",
                "trip": {
                  "to": {
                    "type": "avia",
                    "title": "Аэрофлот от 5 000  Р",
                    "description": "обратно 28 марта в 18:30"
                  },
                  "from": {
                    "type": "avia",
                    "title": "Аэрофлот от 5 000  Р",
                    "description": "обратно 28 марта в 18:30"
                  }
                },
                "accommodation": {
                  "title": "18000",
                  "description": "7 ночей, двухместный номер с завтраком, исторический центр города"
                },
                "nutrition": {
                  "title": "1000",
                  "description": "Средний чек в лучших ресторанах города"
                },
                "entertainment": {
                  "title": "2000",
                  "description": "Прочие расходы на развлечения, билеты и экскурсии"
                }
              }
            ]
          }
        ]
        """.data(using: .utf8)!
    }
}
<file_sep>/StoriesAppUITests/StoriesAppUITests.swift
//
//  Created by onsissond.
//

import XCTest

class StoriesAppUITests: XCTestCase {

    func testExample() {}
}
<file_sep>/Stories/UI/StoriesViewController/AlertBuilder+.swift
//
//  Created by onsissond.
//

import ComposableArchitecture

extension AlertBuilder {
    func with<T>(
        alertState: ComposableArchitecture.AlertState<T>,
        sendAction: @escaping (T) -> Void
    ) -> AlertBuilder {
        var builder = with(title: alertState.title)
        if let message = alertState.message {
            builder = builder.with(message: message)
        }
        [alertState.primaryButton, alertState.secondaryButton].forEach {
            switch $0 {
            case .some(let button):
                switch button.type {
                case .cancel(let title):
                    guard let title = title else { break }
                    builder = builder.withCancel(title: title, action: {
                        guard let action = button.action else { return }
                        sendAction(action)
                    })
                case .default(let title):
                    builder = builder.withAction(title: title, isPreffered: true) {
                        guard let action = button.action else { return }
                        sendAction(action)
                    }
                case .destructive(let title):
                    builder = builder.withDestructive(title: title, action: {
                        guard let action = button.action else { return }
                        sendAction(action)
                    })
                }
            case .none:
                break
            }
        }
        return builder
    }
}
<file_sep>/Stories/Core/Services/StoriesService.swift
//
//  Created by onsissond.
//

import RxSwift

struct Stories: Equatable {
    var activeStories: [Story]
    var futureStories: [Story]
}

struct StoriesService {
    typealias FetchStories = (_ date: Date) -> Single<Stories>
    var fetchStories: (HTTPClient) -> FetchStories
}

extension StoriesService {
    static var live = StoriesService(
        fetchStories: _fetchStories
    )
}

extension StoriesService {
    private static var _fetchStories: (HTTPClient) -> FetchStories = { httpClient in
        return { currentDate in
            httpClient.fetchStories
                .observeOn(SerialDispatchQueueScheduler(qos: .userInteractive))
                .map { stories in
                    stories.sorted { $0.publishDate < $1.publishDate }
                }
                .map { stories in
                    Stories(
                        activeStories: stories.filter {
                            $0.publishDate < currentDate &&
                                $0.expireDate > currentDate
                        },
                        futureStories: stories.filter {
                            $0.publishDate > currentDate
                        }
                    )
                }
                .observeOn(MainScheduler.instance)
        }
    }
}
<file_sep>/Stories/UI/StoriesViewController/InstaProgressView/InstaProgressView.swift
//
//  InstaProgressView.swift
//  InstaProgressView
//
//  Created by <NAME> on 11/19/20.
//

import UIKit

public class InstaProgressView: UIStackView {
    private let _duration: TimeInterval
    private let _progressTintColor: UIColor
    private let _trackTintColor: UIColor

    weak var delegate: InstaProgressViewDelegate?

    public init(
        progressTintColor: UIColor,
        trackTintColor: UIColor,
        spaceBetweenSegments: CGFloat,
        duration: TimeInterval
    ) {
        self._duration = duration
        self._progressTintColor = progressTintColor
        self._trackTintColor = trackTintColor
        super.init(frame: CGRect.zero)
        spacing = spaceBetweenSegments
        _setupAppearence()
    }

    required init(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func _setupAppearence() {
        axis = .horizontal
        distribution = .fillEqually
        alignment = .fill
    }

    private func _createProgressView(
        _ progressTintColor: UIColor,
        _ trackTintColor: UIColor
    ) -> AnimatableProgressView {
        AnimatableProgressView(
            duration: _duration,
            progressTintColor: _progressTintColor,
            trackTintColor: _trackTintColor
        )
    }
}

extension InstaProgressView {
    struct ViewState: Equatable {
        var pagesCount: Int
        var progress: [AnimatorState]
        var currentPage: Int
    }

    enum AnimatorState: Equatable {
        case initial
        case `continue`
        case pause
        case stop
        case start(UUID)
        case finish
    }

    func render(viewState: ViewState) {
        if viewState.pagesCount != arrangedSubviews.count {
            arrangedSubviews.forEach { $0.removeFromSuperview() }
            (0..<viewState.pagesCount)
                .map { _ in _createProgressView(_progressTintColor, _trackTintColor) }
                .forEach(addArrangedSubview)
        }
        zip(
            viewState.progress,
            arrangedSubviews.compactMap({ $0 as? AnimatableProgressView })
        ).forEach { animatorState, progressView in
            switch animatorState {
            case .initial:
                progressView.render(viewState: .initial)
            case .pause:
                progressView.render(viewState: .pause)
            case .continue:
                progressView.render(viewState: .continue)
            case .stop:
                progressView.render(viewState: .stop)
            case .start:
                progressView.render(viewState: .run {
                    self.delegate?.next()
                })
            case .finish:
                progressView.render(viewState: .finish)
            }
        }
    }

    private func _progressView(index: Int) -> AnimatableProgressView {
        arrangedSubviews.compactMap({ $0 as? AnimatableProgressView })[index]
    }
}

class AnimatableProgressView: UIProgressView {
    private var _animator: UIViewPropertyAnimator
    private var _viewState: ViewState?

    init(
        duration: TimeInterval,
        progressTintColor: UIColor,
        trackTintColor: UIColor
    ) {
        _animator = UIViewPropertyAnimator(
            duration: duration,
            curve: .easeInOut
        )
        super.init(frame: .zero)
        self.progressTintColor = progressTintColor
        self.trackTintColor = trackTintColor
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

extension AnimatableProgressView {
    enum ViewState {
        case run(() -> Void)
        case stop
        case pause
        case `continue`
        case finish
        case initial
    }

    func render(viewState: ViewState) {
        _viewState = viewState
        switch viewState {
        case .initial:
            _animator.stopAnimation(true)
            progress = 0
        case .run(let completion):
            _animator.stopAnimation(true)
            progress = 0.01
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
                guard let self = self,
                      case .run = self._viewState else { return }
                self._animator.addAnimations {
                    self.setProgress(1, animated: true)
                }
                self._animator.addCompletion { _ in
                    completion()
                }
                self._animator.startAnimation()
            }
        case .stop:
            _animator.stopAnimation(true)
        case .pause:
            _animator.pauseAnimation()
        case .continue:
            _animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
        case .finish:
            _animator.stopAnimation(true)
            progress = 1
        }
    }
}
<file_sep>/Stories/Core/Services/NotificationService/NotificationSettingsStorage.swift
//
//  Created by onsissond.
//

private let subscriptionKey = "Stories Subsciption"

struct NotificationSettingsStorage {
    var loadSubscriptionStatus: () -> StoriesSubscriptionStatus?
    var saveSubscriptionStatus: (StoriesSubscriptionStatus) -> Void
}

extension NotificationSettingsStorage {
    enum StoriesSubscriptionStatus: String {
        case on, off, failure
    }
}

extension NotificationSettingsStorage {
    static var live = NotificationSettingsStorage(
        loadSubscriptionStatus: _loadStoriesSubscriptionStatus(),
        saveSubscriptionStatus: _saveStoriesSubscriptionStatus()
    )
}

extension NotificationSettingsStorage {
    private static func _loadStoriesSubscriptionStatus(
        userDefaults: UserDefaults = .standard
    ) -> () -> StoriesSubscriptionStatus? {
        return {
            userDefaults.string(forKey: subscriptionKey)
                .map { StoriesSubscriptionStatus(rawValue: $0)! }
        }
    }

    private static func _saveStoriesSubscriptionStatus(
        userDefaults: UserDefaults = .standard
    ) -> (StoriesSubscriptionStatus) -> Void {
        return { status in
            UserDefaults.standard.setValue(
                status.rawValue,
                forKeyPath: subscriptionKey
            )
        }
    }
}
<file_sep>/Stories/UI/Resources/Strings/Strings.swift
// swiftlint:disable all
// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen

import Foundation

// swiftlint:disable superfluous_disable_command
// swiftlint:disable file_length

// MARK: - Strings

// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length
// swiftlint:disable nesting type_body_length type_name
internal enum L10n {

  internal enum Alert {
    internal enum Feedback {
      /// Помогите улучшить наш сервис рекомендаций, ответив на несколько вопросов
      internal static let message = L10n.tr("Localizable", "Alert.Feedback.message")
      /// Это займет 1 минуту
      internal static let title = L10n.tr("Localizable", "Alert.Feedback.title")
      internal enum Button {
        /// Не в этот раз
        internal static let cancel = L10n.tr("Localizable", "Alert.Feedback.Button.cancel")
        /// Да, давайте
        internal static let ok = L10n.tr("Localizable", "Alert.Feedback.Button.ok")
      }
    }
    internal enum TurnOnNotifications {
      /// Чтобы включить подписки разреши уведомления
      internal static let message = L10n.tr("Localizable", "Alert.TurnOnNotifications.message")
      /// Включить подписки!
      internal static let title = L10n.tr("Localizable", "Alert.TurnOnNotifications.title")
      internal enum Button {
        /// Отмена
        internal static let cancel = L10n.tr("Localizable", "Alert.TurnOnNotifications.Button.cancel")
        /// Настройки
        internal static let ok = L10n.tr("Localizable", "Alert.TurnOnNotifications.Button.ok")
      }
    }
  }

  internal enum FutureStoryPreview {
    /// %ld
    internal static func days(_ p1: Int) -> String {
      return L10n.tr("Localizable", "FutureStoryPreview.days", p1)
    }
  }

  internal enum FutureStoryPreviewCell {
    internal enum SubscribeButton {
      /// Напомнить
      internal static let disabled = L10n.tr("Localizable", "FutureStoryPreviewCell.subscribeButton.disabled")
      /// Не напоминать
      internal static let enabled = L10n.tr("Localizable", "FutureStoryPreviewCell.subscribeButton.enabled")
      /// Повторить
      internal static let failure = L10n.tr("Localizable", "FutureStoryPreviewCell.subscribeButton.failure")
    }
  }

  internal enum Notification {
    /// Доступно новое путешествие!
    internal static let title = L10n.tr("Localizable", "Notification.title")
  }

  internal enum SummaryContentStoryView {
    internal enum BuyButton {
      /// Купить билет!
      internal static let title = L10n.tr("Localizable", "SummaryContentStoryView.BuyButton.title")
    }
  }

  internal enum TurnOnNotificationsAlert {
    /// Чтобы включить подписки разреши уведомления
    internal static let message = L10n.tr("Localizable", "TurnOnNotificationsAlert.message")
    /// Включить подписки!
    internal static let title = L10n.tr("Localizable", "TurnOnNotificationsAlert.title")
    internal enum Button {
      /// Отмена
      internal static let cancel = L10n.tr("Localizable", "TurnOnNotificationsAlert.Button.cancel")
      /// Настройки
      internal static let ok = L10n.tr("Localizable", "TurnOnNotificationsAlert.Button.ok")
    }
  }
}
// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length
// swiftlint:enable nesting type_body_length type_name

// MARK: - Implementation Details

extension L10n {
  private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String {
    // swiftlint:disable:next nslocalizedstring_key
    let format = NSLocalizedString(key, tableName: table, bundle: Bundle(for: BundleToken.self), comment: "")
    return String(format: format, locale: Locale.current, arguments: args)
  }
}

private final class BundleToken {}
<file_sep>/Stories/Core/Domain/Transport.swift
//
//  Created by onsissond.
//

enum Transport: String, Equatable, Decodable {
    case avia, train, bus
}
<file_sep>/Stories/UI/StoryContentView/RegularContentStoryView/RegularContentStoryView.swift
//
//  Created by onsissond.
//

import UIKit
import RxCocoa
import Kingfisher

final class RegularContentStoryView: UIView {
    private lazy var _dismissButton: UIButton = .makeStoryDismiss()

    private lazy var _imageView: UIImageView = {
        $0.contentMode = .scaleAspectFill
        return $0
    }(UIImageView())

    private lazy var _stackView: UIStackView = {
        $0.axis = .vertical
        $0.spacing = 16
        return $0
    }(UIStackView())

    private lazy var _titleLabel: UILabel = {
        $0.font = .preferredFont(forTextStyle: .title1)
        $0.textColor = .white
        $0.numberOfLines = 0
        return $0
    }(UILabel())

    private lazy var _descriptionLabel: UILabel = {
        $0.font = .preferredFont(forTextStyle: .headline)
        $0.textColor = .white
        $0.numberOfLines = 0
        return $0
    }(UILabel())

    private lazy var _footerContentView = FooterContentView()

    private lazy var _gradientLayer: CAGradientLayer = .storyGradient

    init() {
        super.init(frame: .zero)
        _setupSubviews()
        _setupLayout()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        _gradientLayer.frame = frame
    }

    private func _setupLayout() {
        _imageView.layer.insertSublayer(_gradientLayer, at: 0)
    }

    private func _setupSubviews() {
        addSubview(_imageView)
        _imageView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _imageView.topAnchor.constraint(equalTo: topAnchor),
            _imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
            _imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
            _imageView.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])

        addSubview(_dismissButton)
        _dismissButton.translatesAutoresizingMaskIntoConstraints = false
        if hasEyebrow {
            _dismissButton.topAnchor.constraint(equalTo: topAnchor, constant: 80).isActive = true
        } else {
            _dismissButton.topAnchor.constraint(equalTo: topAnchor, constant: 62).isActive = true
        }
        _dismissButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16).isActive = true

        addSubview(_stackView)
        _stackView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
            _stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
            _stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -64)
        ])

        _stackView.addArrangedSubview(_titleLabel)
        _stackView.addArrangedSubview(_descriptionLabel)
        _stackView.addArrangedSubview(_footerContentView)
    }
}

extension RegularContentStoryView: StoryContentView {
    func render(viewState: StoryContent) -> Bool {
        guard case .regular(let viewState) = viewState else {
            return false
        }
        _imageView.kf.setImage(
            with: viewState.image.imageURL,
            options: [
                .transition(.fade(0.5))
            ]
        )
        _titleLabel.text = viewState.title
        _descriptionLabel.text = viewState.description
        _footerContentView.render(viewState: .init(
            periodInfo: viewState.periodInfo,
            priceInfo: viewState.priceInfo
        ))
        _footerContentView.isHidden = viewState.periodInfo == nil &&
            viewState.priceInfo == nil
        return true
    }
}

extension RegularContentStoryView {
    enum Event {
        case dismiss
    }

    var events: ControlEvent<Event> {
        ControlEvent(
            events: _dismissButton.rx.controlEvent(.touchUpInside)
                .map { _ in .dismiss }
        )
    }
}
<file_sep>/StoriesApp/SceneDelegate.swift
//
//  Created by onsissond.
//

import UIKit
import Stories

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        guard let scene = (scene as? UIWindowScene) else { return }
        window = UIWindow(windowScene: scene)
        window?.rootViewController = StoriesFactory.create(dependency: .init(
            httpClient: .mock,
            dateProvider: { .mock("2021-03-28", dateFormat: "yyyy-MM-dd")! },
            openURL: { UIApplication.shared.open($0) }
        ), payload: Void())
        window?.makeKeyAndVisible()
    }
}

<file_sep>/Stories/UI/StoriesViewController/CubeAnimator/CubeAttributesAnimator.swift
//
//  Created by onsissond.
//

import UIKit

/// An animator that applies a cube transition effect when you scroll.
struct CubeAttributesAnimator: LayoutAttributesAnimator {
    /// The perspective that will be applied to the cells. Must be negative. -1/500 by default.
    /// Recommended range [-1/2000, -1/200].
    var perspective: CGFloat

    /// The higher the angle is, the _steeper_ the cell would be when transforming.
    var totalAngle: CGFloat

    init(perspective: CGFloat = -1 / 500, totalAngle: CGFloat = .pi / 2) {
        self.perspective = perspective
        self.totalAngle = totalAngle
    }

    func animate(collectionView: UICollectionView, attributes: AnimatedCollectionViewLayoutAttributes) {
        let position = attributes.middleOffset

        guard let contentView = attributes.contentView else { return }

        if abs(position) >= 1 {
            contentView.layer.transform = CATransform3DIdentity
            contentView.layer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
        } else if attributes.scrollDirection == .horizontal {
            let rotateAngle = totalAngle * position
            let anchorPoint = CGPoint(x: position > 0 ? 0 : 1, y: 0.5)

            // As soon as we changed anchor point, we'll need to either update frame/position
            // or transform to offset the position change. frame doesn't work for iOS 14 any
            // more so we'll use transform.
            let anchorPointOffsetValue = contentView.layer.bounds.width / 2
            let anchorPointOffset = position > 0 ? -anchorPointOffsetValue : anchorPointOffsetValue
            var transform = CATransform3DMakeTranslation(anchorPointOffset, 0, 0)
            contentView.layer.anchorPoint = anchorPoint

            if contentView.translatesAutoresizingMaskIntoConstraints == true {
                // not use transformX/Y
                transform = CATransform3DMakeTranslation(0, 0, 0)
                // reset origin
                var frame = attributes.frame
                frame.origin = .zero
                contentView.frame = frame
            }
            transform.m34 = perspective

            transform = CATransform3DRotate(transform, rotateAngle, 0, 1, 0)
            contentView.layer.transform = transform
        } else {
            let rotateAngle = totalAngle * position
            let anchorPoint = CGPoint(x: 0.5, y: position > 0 ? 0 : 1)

            // As soon as we changed anchor point, we'll need to either update frame/position
            // or transform to offset the position change. frame doesn't work for iOS 14 any
            // more so we'll use transform.
            let anchorPointOffsetValue = contentView.layer.bounds.height / 2
            let anchorPointOffset = position > 0 ? -anchorPointOffsetValue : anchorPointOffsetValue

            var transform = CATransform3DMakeTranslation(0, anchorPointOffset, 0)
            transform.m34 = perspective
            transform = CATransform3DRotate(transform, rotateAngle, -1, 0, 0)

            contentView.layer.transform = transform
            contentView.layer.anchorPoint = anchorPoint
        }

        collectionView.isUserInteractionEnabled = false
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
            collectionView.isUserInteractionEnabled = true
        }
    }
}
<file_sep>/Stories/Core/API/FetchStories.swift
//
//  Created by onsissond.
//

import RxSwift
import Moya
import RxMoya

extension HTTPClient {
    var fetchStories: Single<[Story]> {
        rx.request(MultiTarget(FetchStoriesRequest()))
            .subscribeOn(SerialDispatchQueueScheduler(qos: .utility))
            .map([Story].self)
            .catchErrorJustReturn([])
            .observeOn(MainScheduler.instance)
    }
}
<file_sep>/Stories/UI/StoriesViewController/InstaProgressView/InstaProgressViewDelegate.swift
//
//  InstaProgressViewDelegate.swift
//  InstaProgressView
//
//  Created by <NAME> on 11/19/20.
//

import Foundation

protocol InstaProgressViewDelegate: class {
    func next()
    func back()
}
<file_sep>/Stories/UI/StoryContentView/StoryGradient.swift
//
//  Created by onsissond.
//

import UIKit

extension CAGradientLayer {
    static var storyGradient: CAGradientLayer {
        let layer = CAGradientLayer()
        layer.startPoint = CGPoint(x: 1, y: 0)
        layer.endPoint = CGPoint(x: 1, y: 1)
        layer.colors = [
            UIColor.clear.cgColor,
            UIColor(hex: 0xF1D2F).withAlphaComponent(0.53).cgColor,
            UIColor(hex: 0x11834).cgColor
        ]
        layer.locations = [0, 0.5, 1]
        return layer
    }
}
<file_sep>/Stories/UI/StoryContentView/SummaryContentStoryView/SummaryFooterStoryView.swift
//
//  Created by onsissond.
//

import UIKit

final class SummaryFooterStoryView: UIView {

    private lazy var _priceLabel: UILabel = {
        $0.setContentHuggingPriority(.required, for: .horizontal)
        $0.font = .preferredFont(forTextStyle: .title2)
        $0.textColor = .white
        return $0
    }(UILabel())

    private lazy var _titleLabel: UILabel = {
        $0.font = .preferredFont(forTextStyle: .headline)
        $0.textColor = .white
        $0.setContentHuggingPriority(.required, for: .vertical)
        return $0
    }(UILabel())

    private lazy var _subtitleLabel: UILabel = {
        $0.font = .preferredFont(forTextStyle: .caption2)
        $0.textColor = .lightGray
        $0.setContentHuggingPriority(.required, for: .vertical)
        return $0
    }(UILabel())

    init() {
        super.init(frame: .zero)
        _setupSubviews()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func _setupSubviews() {
        addSubview(_titleLabel)
        _titleLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _titleLabel.topAnchor.constraint(equalTo: topAnchor),
            _titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor)
        ])

        addSubview(_subtitleLabel)
        _subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _subtitleLabel.topAnchor.constraint(equalTo: _titleLabel.bottomAnchor, constant: 4),
            _subtitleLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
            _subtitleLabel.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])

        addSubview(_priceLabel)
        _priceLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _priceLabel.topAnchor.constraint(equalTo: _titleLabel.topAnchor),
            _priceLabel.leadingAnchor.constraint(equalTo: _titleLabel.trailingAnchor, constant: 8),
            _priceLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
            _priceLabel.bottomAnchor.constraint(greaterThanOrEqualTo: bottomAnchor)
        ])
    }
}

extension SummaryFooterStoryView {
    struct ViewState {
        var title: String
        var subtitle: String
        var price: String
    }

    func render(viewState: ViewState) {
        _priceLabel.text = viewState.price
        _titleLabel.text = viewState.title
        _subtitleLabel.text = viewState.subtitle
    }
}
<file_sep>/Stories/UI/StoryContentView/SummaryContentStoryView/SummaryHeaderStoryView.swift
//
//  Created by onsissond.
//

import UIKit

final class SummaryHeaderStoryView: UIView {
    private lazy var _periodLabel: UILabel = {
        $0.font = .preferredFont(forTextStyle: .headline)
        $0.textColor = .white
        $0.setContentHuggingPriority(.required, for: .vertical)
        return $0
    }(UILabel())

    private lazy var _titleLabel: UILabel = {
        $0.font = .preferredFont(forTextStyle: .title1)
        $0.textColor = .white
        $0.numberOfLines = 0
        $0.setContentHuggingPriority(.required, for: .vertical)
        return $0
    }(UILabel())

    init() {
        super.init(frame: .zero)
        _setupSubviews()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func _setupSubviews() {
        addSubview(_periodLabel)
        _periodLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _periodLabel.topAnchor.constraint(equalTo: topAnchor),
            _periodLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
            _periodLabel.trailingAnchor.constraint(equalTo: trailingAnchor)
        ])

        addSubview(_titleLabel)
        _titleLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _titleLabel.topAnchor.constraint(equalTo: _periodLabel.bottomAnchor, constant: 4),
            _titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
            _titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
            _titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])
    }
}

extension SummaryHeaderStoryView {
    struct ViewState {
        var period: String
        var title: String
    }

    func render(viewState: ViewState) {
        _periodLabel.text = viewState.period
        _titleLabel.text = viewState.title
    }
}
<file_sep>/Stories/Core/Domain/StoryPreview.swift
//
//  Created by onsissond.
//

import UIKit

struct RegularStoryPreview: Equatable {
    var title: String
    var description: String?
    var imageURL: URL
}

extension RegularStoryPreview: Decodable {
    enum CodingKeys: String, CodingKey {
        case title
        case description = "subtitle"
        case image = "thumb"
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        title = try container.decode(String.self, forKey: .title)
        description = try container.decode(String.self, forKey: .description)
        imageURL = try container.decode(URL.self, forKey: .image)
    }
}
<file_sep>/Stories/UI/StoriesViewController/CubeAnimator/LayoutAttributesAnimator.swift
//
//  Created by onsissond.
//

import UIKit

protocol LayoutAttributesAnimator {
    func animate(collectionView: UICollectionView, attributes: AnimatedCollectionViewLayoutAttributes)
}

class AnimatedCollectionViewLayoutAttributes: UICollectionViewLayoutAttributes {
    var contentView: UIView?
    var scrollDirection: UICollectionView.ScrollDirection = .vertical

    /// The ratio of the distance between the start of the cell and the start of the collectionView and the height/width of the cell depending on the scrollDirection. It's 0 when the start of the cell aligns the start of the collectionView. It gets positive when the cell moves towards the scrolling direction (right/down) while getting negative when moves opposite.
    var startOffset: CGFloat = 0

    /// The ratio of the distance between the center of the cell and the center of the collectionView and the height/width of the cell depending on the scrollDirection. It's 0 when the center of the cell aligns the center of the collectionView. It gets positive when the cell moves towards the scrolling direction (right/down) while getting negative when moves opposite.
    var middleOffset: CGFloat = 0

    /// The ratio of the distance between the **start** of the cell and the end of the collectionView and the height/width of the cell depending on the scrollDirection. It's 0 when the **start** of the cell aligns the end of the collectionView. It gets positive when the cell moves towards the scrolling direction (right/down) while getting negative when moves opposite.
    var endOffset: CGFloat = 0

    override func copy(with zone: NSZone? = nil) -> Any {
        let copy = super.copy(with: zone) as! AnimatedCollectionViewLayoutAttributes
        copy.contentView = contentView
        copy.scrollDirection = scrollDirection
        copy.startOffset = startOffset
        copy.middleOffset = middleOffset
        copy.endOffset = endOffset
        return copy
    }

    override func isEqual(_ object: Any?) -> Bool {
        guard let o = object as? AnimatedCollectionViewLayoutAttributes else { return false }

        return super.isEqual(o)
            && o.contentView == contentView
            && o.scrollDirection == scrollDirection
            && o.startOffset == startOffset
            && o.middleOffset == middleOffset
            && o.endOffset == endOffset
    }
}
<file_sep>/Stories/UI/StoryContentView/UIButton+StoryDismiss.swift
//
//  Created by onsissond.
//

import UIKit

extension UIButton {
    static func makeStoryDismiss() -> UIButton {
        let button = UIButton()
        button.setImage(Asset.Icon.dismiss.image, for: .normal)
        return button
    }
}
<file_sep>/Stories/RxOptional/Observable+FilterNil.swift
//
//  Created by onsissond.
//

import RxSwift

public extension ObservableType where Element: OptionalType {
  /**
   Unwraps and filters out `nil` elements.
   - returns: `Observable` of source `Observable`'s elements, with `nil` elements filtered out.
   */

  func filterNil() -> Observable<Element.Wrapped> {
    return flatMap { element -> Observable<Element.Wrapped> in
      guard let value = element.value else {
        return Observable<Element.Wrapped>.empty()
      }
      return Observable<Element.Wrapped>.just(value)
    }
  }
}

public protocol OptionalType {
  associatedtype Wrapped
  var value: Wrapped? { get }
}

extension Optional: OptionalType {
  /// Cast `Optional<Wrapped>` to `Wrapped?`
  public var value: Wrapped? {
    return self
  }
}
<file_sep>/Stories/UI/StoriesViewController/StoryCell.swift
//
//  Created by onsissond.
//

import UIKit
import ComposableArchitecture
import RxSwift

enum StorySystem {
    typealias LocalStore = Store<State, Action>
    typealias LocalViewStore = ViewStore<State, Action>

    struct State: Equatable {
        var story: Story
        var progressState: InstaProgressView.ViewState
        var currentPage: Int {
            get { progressState.currentPage }
            set { progressState.currentPage = newValue }
        }

        init(story: Story) {
            self.story = story
            progressState = InstaProgressView.ViewState(
                pagesCount: story.content.count,
                progress: story.content.map { _ in .initial },
                currentPage: 0
            )
        }
    }

    enum Action: Equatable {
        case nextStory
        case previousStory
        case nextPage
        case previousPage
        case finish
        case run
        case nullify
        case pause
        case `continue`
        case dismiss
        case openDeepLink(URL)
        case requestFeedback
    }

    struct Environment {
        var openURL: (URL) -> Void
    }
}

extension StorySystem {
    static var reducer = Reducer<State, Action, Environment> { state, action, env in
        switch action {
        case .nextPage:
            if state.currentPage == state.story.content.count - 1 {
                return Effect(value: .nextStory)
            } else if state.currentPage < state.story.content.count - 1 {
                state.progressState.progress[state.currentPage] = .finish
                state.currentPage += 1
                state.progressState.progress[state.currentPage] = .start(UUID())
            }
        case .previousPage:
            if state.currentPage == 0 {
                return Effect(value: .previousStory)
            } else if state.currentPage > 0 {
                state.progressState.progress[state.currentPage] = .initial
                state.currentPage -= 1
                state.progressState.progress[state.currentPage] = .start(UUID())
            }
        case .run:
            for index in (0..<state.progressState.pagesCount) {
                if index < state.currentPage {
                    state.progressState.progress[index] = .finish
                } else if index == state.currentPage {
                    state.progressState.progress[index] = .start(UUID())
                } else {
                    state.progressState.progress[index] = .initial
                }

            }
        case .nullify:
            state.progressState.progress = state.progressState.progress
                .map { _ in .initial }
        case .finish:
            state.progressState.progress[state.currentPage] = .finish
        case .pause, .requestFeedback:
            state.progressState.progress[state.currentPage] = .pause
        case .continue:
            state.progressState.progress[state.currentPage] = .continue
        case let .openDeepLink(url):
            env.openURL(url)
        case .nextStory, .previousStory, .dismiss:
            break
        }
        return .none
    }
}

protocol StoryContentView: UIView {
    func render(viewState: StoryContent) -> Bool
}

class StoryCell: UICollectionViewCell {
    private lazy var _storyContentView = SwitchableView(contentViews: [
        _createRegularContentStoryView(),
        _createSummaryContentStoryView()
    ])
    private lazy var _progressView = InstaProgressView(
        progressTintColor: .white,
        trackTintColor: UIColor.white.withAlphaComponent(0.5),
        spaceBetweenSegments: 8,
        duration: 10
    )

    private lazy var _reusableDisposeBag = DisposeBag()
    private lazy var _disposeBag = DisposeBag()
    private var _viewStore: StorySystem.LocalViewStore?
    private lazy var _dataSource = PublishSubject<InstaProgressView.ViewState>()

    override init(frame: CGRect) {
        super.init(frame: frame)
        _setupSubviews()
        _setupAppearence()
        _setupGesturesRecognizers()
        _setupSubscriptions()
        _progressView.delegate = self
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func _setupAppearence() {
        contentView.clipsToBounds = true
    }

    private func _setupSubscriptions() {
        _dataSource
            .distinctUntilChanged()
            .bind(onNext: { [weak self] in
                self?._progressView.render(
                    viewState: $0
                )
            })
            .disposed(by: _disposeBag)
    }

    private func _setupSubviews() {
        contentView.addSubview(_storyContentView)
        _storyContentView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _storyContentView.topAnchor.constraint(equalTo: topAnchor),
            _storyContentView.leadingAnchor.constraint(equalTo: leadingAnchor),
            _storyContentView.trailingAnchor.constraint(equalTo: trailingAnchor),
            _storyContentView.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])

        contentView.addSubview(_progressView)
        if hasEyebrow {
            _progressView.topAnchor.constraint(equalTo: topAnchor, constant: 48).isActive = true
        } else {
            _progressView.topAnchor.constraint(equalTo: topAnchor, constant: 24).isActive = true
        }
        _progressView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8).isActive = true
        _progressView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8).isActive = true
        _progressView.heightAnchor.constraint(equalToConstant: 2).isActive = true
        _progressView.translatesAutoresizingMaskIntoConstraints = false
    }

    private func _createRegularContentStoryView() -> StoryContentView {
        let view = RegularContentStoryView()
        view.events.bind(onNext: { [weak self] in
            switch $0 {
            case .dismiss:
                self?._viewStore?.send(.dismiss)
            }
        }).disposed(by: _disposeBag)
        return view
    }

    private func _createSummaryContentStoryView() -> StoryContentView {
        let view = SummaryContentStoryView()
        view.events.bind(onNext: { [weak self] in
            switch $0 {
            case .dismiss:
                self?._viewStore?.send(.dismiss)
            case let .openDeeplink(url):
                self?._viewStore?.send(.openDeepLink(url))
            case .requestFeedback:
                self?._viewStore?.send(.requestFeedback)
            }
        }).disposed(by: _disposeBag)
        return view
    }

    private func _setupGesturesRecognizers() {
        let swipeLeft = UITapGestureRecognizer(target: self, action: #selector(handleGesture))
        addGestureRecognizer(swipeLeft)
    }

    @objc private func handleGesture(gesture: UITapGestureRecognizer) {
        let location = gesture.location(in: self)
        if location.x < layer.frame.size.width / 2 {
            _viewStore?.send(.previousPage)
        } else {
            _viewStore?.send(.nextPage)
        }
    }
}

extension StoryCell {
    func render(store: StorySystem.LocalStore) {
        _viewStore = ViewStore(store)
        _reusableDisposeBag = DisposeBag()
        _viewStore?.publisher.subscribe(onNext: { [weak self] in
            _ = self?._storyContentView.render(
                viewState: $0.story.content[$0.currentPage]
            )
        }).disposed(by: _reusableDisposeBag)

        _viewStore?.publisher.map(\.progressState)
            .subscribe(onNext: _dataSource.onNext)
            .disposed(by: _reusableDisposeBag)
    }
}

extension StoryCell: InstaProgressViewDelegate {
    func next() {
        _viewStore?.send(.nextPage)
    }

    func back() {
        _viewStore?.send(.previousStory)
    }
}

extension UIView {
    var hasEyebrow: Bool {
        UIScreen.main.bounds.height >= 812
    }
}
<file_sep>/README.md
# Stories
MVP of feature about stories.
<p float="left">
  <img src="/Docs/Images/MainScreen.png" width="200" />
  <img src="/Docs/Images/RegularStory.png" width="200" /> 
  <img src="/Docs/Images/SummeryStory.png" width="200" />
</p>

Architecture:

[Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture)
<file_sep>/Stories/Core/Services/NotificationService/NotificationService.swift
//
//  Created by onsissond.
//

struct NotificationService {
    var notificationProvider: NotificationProvider
    var notificationSettingsStorage: NotificationSettingsStorage
}

extension NotificationService {
    static var live = NotificationService(
        notificationProvider: .live,
        notificationSettingsStorage: .live
    )
}
<file_sep>/Stories/UI/StoriesViewController/StoriesSystem.swift
//
//  Created by onsissond.
//

import UIKit
import RxSwift
import ComposableArchitecture

enum StoriesSystem {
    typealias LocalStore = Store<State, Action>

    struct State: Equatable {
        var stories: [StorySystem.State] = []
        var currentStory = 0
        var dismiss = false
        var feedbackAlert: AlertState<Action>?
        var feedbackURL: URL?
    }

    enum Action: Equatable {
        case storyAction(storyIndex: Int, action: StorySystem.Action)
        case launchFeedback
        case launchedFeedback
        case dismissFeedbackAlert
    }

    struct Environment {
        var storyEnvironment: StorySystem.Environment
    }
}

extension StoriesSystem.State {
    init(stories: [Story], currentStory: Int) {
        self.stories = stories.map(StorySystem.State.init)
        self.currentStory = currentStory
    }
}

extension StoriesSystem {
    static var reducer = Reducer<State, Action, Environment> { state, action, _ in
        switch action {
        case .launchFeedback:
            state.feedbackAlert = nil
            state.feedbackURL = URL(string: "https://www.google.com")!
            return .init(value: .storyAction(
                storyIndex: state.currentStory,
                action: .pause
            ))
        case .launchedFeedback:
            state.feedbackURL = nil
        case .dismissFeedbackAlert:
            state.feedbackAlert = nil
            return .init(value: .storyAction(
                storyIndex: state.currentStory,
                action: .continue
            ))
        case let .storyAction(index, .previousStory):
            if index - 1 >= 0 {
                state.currentStory = index - 1
                return .concatenate(
                    Effect(value: .storyAction(storyIndex: index, action: .nullify)),
                    Effect(value: .storyAction(storyIndex: state.currentStory, action: .run))
                )
            } else {
                return Effect(value: .storyAction(storyIndex: state.currentStory, action: .run))
            }
        case let .storyAction(index, .nextStory):
            if index + 1 < state.stories.count {
                state.currentStory = index + 1
                return .concatenate(
                    Effect(value: .storyAction(storyIndex: index, action: .finish)),
                    Effect(value: .storyAction(storyIndex: state.currentStory, action: .run))
                )
            } else {
                return Effect(value: .storyAction(storyIndex: index, action: .finish))
            }
        case .storyAction(_, .requestFeedback):
            state.feedbackAlert = .feedback
        case .storyAction:
            return .none
        }
        return .none
    }
    .combined(
        with: StorySystem.reducer.forEach(
            state: \.stories,
            action: /StoriesSystem.Action.storyAction,
            environment: \.storyEnvironment
        )
    )
}
<file_sep>/Stories/UI/StoriesPreviewViewController/StoriesPreviewSystem.swift
//
//  Created by onsissond.
//

import UIKit
import RxSwift
import ComposableArchitecture

enum StoriesPreviewSystem {
    typealias LocalStore = ComposableArchitecture.Store<State, Action>

    enum SubscriptionState: Equatable {
        case off
        case on
        case failure(UUID, needShowAlert: Bool)
    }
    enum SetupNotificationMode {
        case manual
        case automatic
    }
    struct State: Equatable {
        var storiesState: StoriesSystem.State?
        var stories: [Story] = []
        var futureStories: [Story] = []
        var futureStory: FutureStory?
        var subscriptionState: SubscriptionState = .off
    }
    enum Action: Equatable {
        case viewDidLoad
        case loadedStories(Stories)
        case setupFutureStories([Story])
        case switchNotifications
        case openStories(index: Int)
        case openSettings
        case dismissStories
        case setSubscriptionState(SubscriptionState)
        case setupNotification(SetupNotificationMode)
        case storiesAction(StoriesSystem.Action)
    }
    struct Environment {
        var fetchStories: () -> Effect<Stories>
        var currentDate: () -> Date
        var uuid: () -> UUID
        var calendar: () -> Calendar
        var notificationService: NotificationService
        var storiesEnvironment: StoriesSystem.Environment
    }
}

extension StoriesPreviewSystem.State {
    enum Item {
        case story(Story)
        case future(FutureStory)
    }

    var dataSource: [Item] {
        stories.map(Item.story) +
            (futureStory.map(Item.future).map { [$0] } ?? [])
    }
}

extension StoriesPreviewSystem {
    static var reducer = ComposableArchitecture.Reducer<State, Action, Environment> { state, action, env in
        switch action {
        case .viewDidLoad:
            state.subscriptionState = env.notificationService
                .notificationSettingsStorage
                .loadSubscriptionStatus()
                .map { .init($0, uuidProvider: env.uuid) }
                ?? .off
            return env.fetchStories().map(Action.loadedStories)
        case .loadedStories(let stories):
            state.stories = stories.activeStories
            return .init(value: .setupFutureStories(stories.futureStories))
        case .openStories(let index):
            state.storiesState = .init(
                stories: state.stories,
                currentStory: index
            )
            return .none
        case .setupFutureStories(let stories):
            state.futureStories = stories
            state.futureStory = state.futureStories.first.map {
                .init(
                    story: $0,
                    calendar: env.calendar(),
                    currentDate: env.currentDate()
                )
            }
            switch state.subscriptionState {
            case .on: return .init(value: .setupNotification(.automatic))
            case .off, .failure: return .none
            }
        case .openSettings:
            env.storiesEnvironment.storyEnvironment.openURL(
                URL(string: UIApplication.openSettingsURLString)!
            )
            return .none
        case .switchNotifications:
            switch state.subscriptionState {
            case .on:
                state.subscriptionState = .off
                env.notificationService.notificationSettingsStorage
                    .saveSubscriptionStatus(.off)
                env.notificationService.notificationProvider
                    .removeStoryNotifications()
            case .off, .failure:
                return .init(value: .setupNotification(.manual))
            }
            return .none
        case .setupNotification(let mode):
            env.notificationService.notificationProvider.removeStoryNotifications()
            return env.notificationService.notificationProvider.setupNotification(
                state.futureStories
            )
            .map { result -> StoriesPreviewSystem.SubscriptionState? in
                .init(result, mode: mode, uuidProvider: env.uuid)
            }
            .asObservable()
            .filterNil()
            .map(Action.setSubscriptionState)
            .eraseToEffect()
        case .setSubscriptionState(let value):
            state.subscriptionState = value
            env.notificationService.notificationSettingsStorage
                .saveSubscriptionStatus(.init(state: value))
            return .none
        case .dismissStories:
            state.storiesState = nil
            return .none
        case .storiesAction(.storyAction(_, .dismiss)):
            return Observable.just(())
                .map { .dismissStories }
                .eraseToEffect()
        case .storiesAction:
            return .none
        }
    }
    .combined(with: StoriesSystem.reducer.optional().pullback(
        state: \.storiesState,
        action: /Action.storiesAction,
        environment: \.storiesEnvironment
    ))
}

private extension StoriesPreviewSystem.SubscriptionState {
    init?(
        _ result: NotificationProvider.SetupNotificationResult,
        mode: StoriesPreviewSystem.SetupNotificationMode,
        uuidProvider: () -> UUID
    ) {
        switch result {
        case .success:
            self = .on
        case .failure:
            guard mode == .manual else {
                return nil
            }
            self = .failure(uuidProvider(), needShowAlert: true)
        }
    }
}

private extension NotificationSettingsStorage.StoriesSubscriptionStatus {
    init(state: StoriesPreviewSystem.SubscriptionState) {
        switch state {
        case .off: self = .off
        case .on: self = .on
        case .failure: self = .failure
        }
    }
}

private extension StoriesPreviewSystem.SubscriptionState {
    init(
        _ storiesSubscriptionStatus: NotificationSettingsStorage.StoriesSubscriptionStatus,
        uuidProvider: () -> UUID
    ) {
        switch storiesSubscriptionStatus {
        case .on:
            self = .on
        case .off:
            self = .off
        case .failure:
            self = .failure(uuidProvider(), needShowAlert: false)
        }
    }
}

private extension FutureStory {
    init(story: Story, calendar: Calendar, currentDate: Date) {
        self.init(
            imageURL: story.preview.imageURL,
            daysToFutureStory: {
                calendar.dateComponents(
                    [.day],
                    from: currentDate,
                    to: story.publishDate
                ).day ?? 0
            }()
        )
    }
}
<file_sep>/Stories/UI/StoryContentView/RegularContentStoryView/FooterStoryView.swift
//
//  Created by onsissond.
//

import UIKit

final class FooterStoryView: UIView {
    private lazy var _titleLabel: UILabel = {
        $0.font = .preferredFont(forTextStyle: .subheadline)
        $0.textColor = .white
        return $0
    }(UILabel())

    private lazy var _subtitleLabel: UILabel = {
        $0.font = .preferredFont(forTextStyle: .title3)
        $0.textColor = .white
        return $0
    }(UILabel())

    init() {
        super.init(frame: .zero)
        _setupSubviews()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func _setupSubviews() {
        addSubview(_titleLabel)
        _titleLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _titleLabel.topAnchor.constraint(equalTo: topAnchor),
            _titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
            _titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor)
        ])

        addSubview(_subtitleLabel)
        _subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _subtitleLabel.topAnchor.constraint(equalTo: _titleLabel.bottomAnchor, constant: 4),
            _subtitleLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
            _subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
            _subtitleLabel.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])
    }
}

extension FooterStoryView {
    struct ViewState {
        let title: String
        let subtitle: String
    }

    func render(viewState: ViewState) {
        _titleLabel.text = viewState.title
        _subtitleLabel.text = viewState.subtitle
    }
}

class FooterContentView: UIView {
    private lazy var _stackView: UIStackView = {
        $0.axis = .horizontal
        $0.alignment = .fill
        $0.distribution = .fillEqually
        $0.spacing = 8
        return $0
    }(UIStackView())
    private lazy var _dateView = FooterStoryView()
    private lazy var _priceView = FooterStoryView()

    init() {
        super.init(frame: .zero)
        _setupSubviews()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func _setupSubviews() {
        addSubview(_stackView)
        _stackView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _stackView.topAnchor.constraint(equalTo: topAnchor),
            _stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
            _stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
            _stackView.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])
        _stackView.addArrangedSubview(_priceView)
        _stackView.addArrangedSubview(_dateView)
    }
}

extension FooterContentView {
    struct ViewState {
        let periodInfo: RegularStoryContent.Info?
        let priceInfo: RegularStoryContent.Info?
    }
    func render(viewState: ViewState) {
        if let periodInfo = viewState.periodInfo {
            _dateView.render(viewState: .init(
                title: periodInfo.title,
                subtitle: periodInfo.subtitle
            ))
        }
        _dateView.isHidden = viewState.periodInfo == nil
        if let priceInfo = viewState.priceInfo {
            _priceView.render(viewState: .init(
                title: priceInfo.title,
                subtitle: priceInfo.subtitle
            ))
        }
        _priceView.isHidden = viewState.priceInfo == nil
    }
}
<file_sep>/Stories/UI/StoriesPreviewViewController/UI/FutureStoryPreviewCell.swift
//
//  Created by onsissond.
//

import UIKit
import ComposableArchitecture
import RxSwift

final class FutureStoryPreviewCell: UICollectionViewCell {
    private lazy var _contentView: UIView = {
        $0.backgroundColor = UIColor(hex: 0xAFD4FF)
        $0.layer.cornerRadius = 10
        $0.layer.masksToBounds = true
        return $0
    }(UIView())

    private lazy var _imageView: UIImageView = {
        $0.contentMode = .scaleAspectFill
        $0.layer.cornerRadius = 10
        $0.layer.masksToBounds = true
        return $0
    }(UIImageView())

    private lazy var _blurEffectView = UIVisualEffectView(
        effect: UIBlurEffect(style: .dark)
    )

    private lazy var _titleLabel: UILabel = {
        $0.textColor = .white
        $0.numberOfLines = 0
        $0.textAlignment = .center
        $0.font = .preferredFont(forTextStyle: .caption1)
        return $0
    }(UILabel())

    private lazy var _subscribeLabel: UILabel = {
        $0.textColor = .white
        $0.textAlignment = .center
        $0.font = .preferredFont(forTextStyle: .caption1)
        $0.backgroundColor = .black
        $0.layer.cornerRadius = 4
        $0.layer.masksToBounds = true
        $0.setContentCompressionResistancePriority(.required, for: .vertical)
        return $0
    }(UILabel())

    override init(frame: CGRect) {
        super.init(frame: frame)
        _setupSubviews()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func _setupSubviews() {
        contentView.addSubview(_contentView)
        _contentView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _contentView.topAnchor.constraint(equalTo: contentView.topAnchor),
            _contentView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
            _contentView.leftAnchor.constraint(equalTo: contentView.leftAnchor),
            _contentView.rightAnchor.constraint(equalTo: contentView.rightAnchor)
        ])

        _contentView.addSubview(_imageView)
        _imageView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _imageView.topAnchor.constraint(equalTo: _contentView.topAnchor, constant: 2),
            _imageView.leadingAnchor.constraint(equalTo: _contentView.leadingAnchor, constant: 2),
            _imageView.trailingAnchor.constraint(equalTo: _contentView.trailingAnchor, constant: -2),
            _imageView.bottomAnchor.constraint(equalTo: _contentView.bottomAnchor, constant: -2)
        ])

        _imageView.addSubview(_blurEffectView)
        _blurEffectView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _blurEffectView.topAnchor.constraint(equalTo: _imageView.topAnchor),
            _blurEffectView.leadingAnchor.constraint(equalTo: _imageView.leadingAnchor),
            _blurEffectView.trailingAnchor.constraint(equalTo: _imageView.trailingAnchor),
            _blurEffectView.bottomAnchor.constraint(equalTo: _imageView.bottomAnchor)
        ])

        _contentView.addSubview(_titleLabel)
        _titleLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _titleLabel.leadingAnchor.constraint(equalTo: _contentView.leadingAnchor, constant: 8),
            _titleLabel.trailingAnchor.constraint(equalTo: _contentView.trailingAnchor, constant: -8)
        ])

        _contentView.addSubview(_subscribeLabel)
        _subscribeLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _subscribeLabel.heightAnchor.constraint(equalToConstant: 28),
            _subscribeLabel.topAnchor.constraint(equalTo: _titleLabel.bottomAnchor, constant: 12),
            _subscribeLabel.leadingAnchor.constraint(equalTo: _contentView.leadingAnchor, constant: 8),
            _subscribeLabel.trailingAnchor.constraint(equalTo: _contentView.trailingAnchor, constant: -8),
            _subscribeLabel.bottomAnchor.constraint(equalTo: _contentView.bottomAnchor, constant: -16)
        ])
    }
}

extension FutureStoryPreviewCell {
    struct ViewState {
        enum SubscriptionState {
            case off
            case on
            case failure
        }
        let imageURL: URL
        let subscriptionState: SubscriptionState
        let daysToFutureStory: Int
    }

    func render(viewState: ViewState) {
        _imageView.kf.setImage(
            with: viewState.imageURL,
            options: [.transition(.fade(0.5))]
        )
        _titleLabel.attributedText = NSMutableAttributedString(
            numberOfDays: viewState.daysToFutureStory
        )
        _titleLabel.textAlignment = .center

        switch viewState.subscriptionState {
        case .off:
            _subscribeLabel.text = L10n.FutureStoryPreviewCell.SubscribeButton.disabled
        case .on:
            _subscribeLabel.text = L10n.FutureStoryPreviewCell.SubscribeButton.enabled
        case .failure:
            _subscribeLabel.text = L10n.FutureStoryPreviewCell.SubscribeButton.failure
        }
    }
}

private extension NSMutableAttributedString {
    convenience init(numberOfDays: Int) {
        self.init(
            string: L10n.FutureStoryPreview.days(numberOfDays)
        )
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.lineSpacing = 5
        addAttribute(
            .paragraphStyle,
            value: paragraphStyle,
            range: NSRange(location: 0, length: length)
        )
    }
}
<file_sep>/Stories/Foundation/HTTPClient.swift
//
//  Created by onsissond.
//

import Moya

public typealias HTTPClient = MoyaProvider<MultiTarget>

#if DEBUG
extension MoyaProvider {
    public static var mock: MoyaProvider {
        MoyaProvider(
            endpointClosure: MoyaProvider.defaultEndpointMapping,
            stubClosure: MoyaProvider.immediatelyStub
        )
    }
}
#endif
<file_sep>/Stories/UI/StoriesPreviewViewController/UI/StoriesPreviewViewController.swift
//
//  Created by onsissond.
//

import UIKit
import RxSwift
import ComposableArchitecture

enum StoriesPreviewCellIdentifier: String {
    case regular
    case future
}

class StoriesPreviewViewController: UIViewController {
    private let _store: StoriesPreviewSystem.LocalStore
    private let _disposeBag = DisposeBag()
    private lazy var _viewStore = ViewStore(_store)
    private weak var _presentedVC: UIViewController?

    private lazy var _collectionView: UICollectionView = {
        $0.contentInset = .init(top: 4, left: 16, bottom: 4, right: 16)
        $0.showsHorizontalScrollIndicator = false
        $0.register(
            StoryPreviewCell.self,
            forCellWithReuseIdentifier: StoriesPreviewCellIdentifier.regular.rawValue
        )
        $0.register(
            FutureStoryPreviewCell.self,
            forCellWithReuseIdentifier: StoriesPreviewCellIdentifier.future.rawValue
        )
        $0.delegate = self
        $0.dataSource = self
        return $0
    }(UICollectionView(frame: .zero, collectionViewLayout: _flowLayout))

    private lazy var _flowLayout: UICollectionViewFlowLayout = {
        $0.scrollDirection = .horizontal
        return $0
    }(UICollectionViewFlowLayout())

    init(store: StoriesPreviewSystem.LocalStore) {
        _store = store
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        _setupAppearence()
        _setupSubviews()
        _setupSubscriptions()
        _viewStore.send(.viewDidLoad)
    }

    private func _setupSubscriptions() {
        _viewStore.publisher.map(\.stories)
            .distinctUntilChanged()
            .bind(onNext: { [weak self] _ in
                self?._collectionView.reloadData()
            })
            .disposed(by: _disposeBag)

        _viewStore.publisher.map(\.subscriptionState)
            .distinctUntilChanged()
            .do(onNext: { [weak self] _ in
                guard let self = self else { return }
                if let row = self._store.state.dataSource.firstIndex(where: {
                    if case .future = $0 { return true }
                    return false
                }) {
                    self._collectionView.reloadItems(
                        at: [.init(row: row, section: 0)]
                    )
                }
            })
            .subscribe()
            .disposed(by: _disposeBag)

        _viewStore.publisher.map(\.subscriptionState)
            .bind(onNext: { [weak self] state in
                guard let self = self else { return }
                switch state {
                case .failure(_, let needShowAlert) where needShowAlert == true:
                    self._showTurnOnNotificationAlert()
                default:
                    break
                }
            })
            .disposed(by: _disposeBag)

        _store.scope(
            state: \.storiesState,
            action: StoriesPreviewSystem.Action.storiesAction
        ).ifLet(then: { [weak self] in
            let vc = StoriesViewController(store: $0)
            self?._presentedVC = vc
            self?.present(vc, animated: true)
        }, else: { [weak self] in
            self?._presentedVC?.dismiss(animated: true, completion: nil)
        })
        .disposed(by: _disposeBag)
    }

    private func _setupAppearence() {
        _collectionView.backgroundColor = .clear
        view.backgroundColor = .white
    }

    private func _setupSubviews() {
        view.addSubview(_collectionView)
        _collectionView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            _collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            _collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            _collectionView.heightAnchor.constraint(equalToConstant: 150)
        ])
    }

    private func _showTurnOnNotificationAlert() {
        present(
            AlertBuilder()
                .with(title: L10n.Alert.TurnOnNotifications.title)
                .with(message: L10n.Alert.TurnOnNotifications.message)
                .withAction(title: L10n.Alert.TurnOnNotifications.Button.cancel)
                .withAction(
                    title: L10n.Alert.TurnOnNotifications.Button.ok,
                    isPreffered: true
                ) { [weak self] in
                    self?._viewStore.send(.openSettings)
                }
                .build(),
            animated: true
        )
    }
}

extension StoriesPreviewViewController: UICollectionViewDataSource {
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        1
    }

    func collectionView(
        _ collectionView: UICollectionView,
        numberOfItemsInSection section: Int
    ) -> Int {
        _store.state.dataSource.count
    }
}

extension StoriesPreviewViewController: UICollectionViewDelegate {
    func collectionView(
        _ collectionView: UICollectionView,
        didSelectItemAt indexPath: IndexPath
    ) {
        switch _store.state.dataSource[indexPath.row] {
        case .story:
            _viewStore.send(.openStories(index: indexPath.row))
        case .future:
            _viewStore.send(.switchNotifications)
        }
    }
}

extension StoriesPreviewViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(
        _ collectionView: UICollectionView,
        cellForItemAt indexPath: IndexPath
    ) -> UICollectionViewCell {
        switch _store.state.dataSource[indexPath.row] {
        case .story(let story):
            let cell = collectionView.dequeueReusableCell(
                withReuseIdentifier: StoriesPreviewCellIdentifier.regular.rawValue,
                for: indexPath
            )
            if let cell = cell as? StoryPreviewCell {
                cell.render(viewState: .init(storyPreview: story.preview))
            }
            return cell
        case .future(let story):
            let cell = collectionView.dequeueReusableCell(
                withReuseIdentifier: StoriesPreviewCellIdentifier.future.rawValue,
                for: indexPath
            )
            if let cell = cell as? FutureStoryPreviewCell {
                cell.render(viewState: .init(
                    futureStory: story,
                    subscriptionState: _store.state.subscriptionState
                ))
            }
            return cell
        }
    }

    func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        sizeForItemAt indexPath: IndexPath
    ) -> CGSize {
        CGSize(
            width: collectionView.contentSize.height * 0.8,
            height: collectionView.contentSize.height
        )
    }

    func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        insetForSectionAt section: Int
    ) -> UIEdgeInsets {
        .zero
    }

    func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        minimumLineSpacingForSectionAt section: Int
    ) -> CGFloat {
        8
    }

    func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        minimumInteritemSpacingForSectionAt section: Int
    ) -> CGFloat {
        0
    }
}

private extension FutureStoryPreviewCell.ViewState {
    init(
        futureStory: FutureStory,
        subscriptionState: StoriesPreviewSystem.SubscriptionState
    ) {
        self.init(
            imageURL: futureStory.imageURL,
            subscriptionState: .init(subscriptionState: subscriptionState),
            daysToFutureStory: futureStory.daysToFutureStory
        )
    }
}

private extension FutureStoryPreviewCell.ViewState.SubscriptionState {
    init(subscriptionState: StoriesPreviewSystem.SubscriptionState) {
        switch subscriptionState {
        case .failure: self = .failure
        case .off: self = .off
        case .on: self = .on
        }
    }
}

private extension StoryPreviewCell.ViewState {
    init(storyPreview: RegularStoryPreview) {
        self.init(
            imageURL: storyPreview.imageURL,
            title: storyPreview.title,
            description: storyPreview.description
        )
    }
}
<file_sep>/Stories/UI/StoryContentView/SummaryContentStoryView/SumeryInfoStoryView.swift
//
//  Created by onsissond.
//

import UIKit

final class SummaryInfoStoryView: UIView {
    private lazy var _contentView: UIView = {
        $0.layer.cornerRadius = 10
        $0.layer.masksToBounds = true
        $0.backgroundColor = UIColor.black.withAlphaComponent(0.6)
        return $0
    }(UIView())

    private lazy var _stackView: UIStackView = {
        $0.axis = .vertical
        $0.spacing = 8
        return $0
    }(UIStackView())

    private lazy var _infoView = SummaryInfoDetailsStoryView()
    private lazy var _extraInfoView = SummaryInfoDetailsStoryView()

    init() {
        super.init(frame: .zero)
        _setupSubviews()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func _setupSubviews() {
        addSubview(_contentView)
        _contentView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _contentView.topAnchor.constraint(equalTo: topAnchor),
            _contentView.leadingAnchor.constraint(equalTo: leadingAnchor),
            _contentView.trailingAnchor.constraint(equalTo: trailingAnchor),
            _contentView.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])

        _contentView.addSubview(_stackView)
        _stackView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _stackView.topAnchor.constraint(equalTo: topAnchor, constant: 16),
            _stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
            _stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
            _stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16)
        ])

        _stackView.addArrangedSubview(_infoView)
        _stackView.addArrangedSubview(_extraInfoView)
    }
}

extension SummaryInfoStoryView {
    enum ViewState {
        case trip(from: SummaryStoryContent.TripInfo, to: SummaryStoryContent.TripInfo)
        case accommodation(title: String, description: String)
        case nutrition(title: String, description: String)
        case entertainment(title: String, description: String)
    }

    func render(viewState: ViewState) {
        switch viewState {
        case let .trip(tripFrom, tripTo):
            _infoView.render(viewState: .init(
                icon: tripTo.transport.icon,
                title: tripTo.title,
                subtitle: tripTo.subtitle
            ))
            let needIcon = tripTo.transport != tripFrom.transport
            _extraInfoView.render(viewState: .init(
                icon: needIcon ? tripFrom.transport.icon : nil,
                title: tripFrom.title,
                subtitle: tripFrom.subtitle
            ))
            _extraInfoView.isHidden = false
        case let .accommodation(title, description),
             let .nutrition(title, description),
             let .entertainment(title, description):
            _infoView.render(viewState: .init(
                icon: viewState.icon,
                title: title,
                subtitle: description
            ))
            _extraInfoView.isHidden = true
        }
    }
}

private extension Transport {
    var icon: UIImage {
        switch self {
        case .avia:
            return Asset.Icon.flight.image
        case .train:
            return Asset.Icon.train.image
        case .bus:
            return Asset.Icon.bus.image
        }
    }
}

private extension SummaryInfoStoryView.ViewState {
    var icon: UIImage? {
        switch self {
        case .trip:
            return nil
        case .accommodation:
            return Asset.Icon.accommodation.image
        case .nutrition:
            return Asset.Icon.nutrition.image
        case .entertainment:
            return Asset.Icon.entertainment.image
        }
    }
}
<file_sep>/Stories/Core/Domain/Story.swift
//
//  Created by onsissond.
//

import UIKit

struct Story: Equatable {
    var id: String
    var preview: RegularStoryPreview
    var content: [StoryContent]
    var publishDate: Date
    var expireDate: Date
}

// MARK: - Decodable
extension Story: Decodable {
    enum CodingKeys: String, CodingKey {
        case id
        case pages
        case publishDate
        case expireDate
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        id = try container.decode(String.self, forKey: .id)
        preview = try RegularStoryPreview(from: decoder)
        content = try container.decode([StoryContent].self, forKey: .pages)

        let publishDateString = try container.decode(String.self, forKey: .publishDate)
        let expireDateString = try container.decode(String.self, forKey: .expireDate)
        let dateFormatter = DateFormatter.plusThreeDateFormatter
        publishDate = dateFormatter.date(from: publishDateString)!
        expireDate = dateFormatter.date(from: expireDateString)!
    }
}

/// Вспомогательные функции для форматирования дат.
 extension DateFormatter {

     /// DateFormatter, настроенный на формат "yyyy-MM-dd" и на часовой пояс +3.
     public static var plusThreeDateFormatter: DateFormatter {
         let formatter = DateFormatter()

         formatter.timeZone = TimeZone(secondsFromGMT: 60*60*3)!
         formatter.dateFormat = "yyyy-MM-dd"

         return formatter
     }
 }
<file_sep>/Stories/UI/StoriesPreviewViewController/UI/StoryPreviewCell.swift
//
//  Created by onsissond.
//

import UIKit
import Kingfisher

final class StoryPreviewCell: UICollectionViewCell {
    private lazy var _contentView: UIView = {
        $0.backgroundColor = UIColor(hex: 0xAFD4FF)
        $0.layer.cornerRadius = 10
        $0.layer.masksToBounds = true
        return $0
    }(UIView())

    private lazy var _imageView: UIImageView = {
        $0.contentMode = .scaleAspectFill
        $0.layer.cornerRadius = 10
        $0.layer.masksToBounds = true
        return $0
    }(UIImageView())

    private lazy var _gradientLayer: CAGradientLayer = .storyGradient

    private lazy var _titleLabel: UILabel = {
        $0.textColor = .white
        $0.numberOfLines = 0
        $0.font = .preferredFont(forTextStyle: .caption1)
        return $0
    }(UILabel())

    private lazy var _descriptionLabel: UILabel = {
        $0.textColor = .white
        $0.numberOfLines = 0
        $0.font = .systemFont(ofSize: 10)
        return $0
    }(UILabel())

    override init(frame: CGRect) {
        super.init(frame: frame)
        _setupSubviews()
        _setupAppearence()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        _gradientLayer.frame = contentView.frame
    }

    private func _setupAppearence() {
        _imageView.layer.addSublayer(_gradientLayer)
    }

    private func _setupSubviews() {
        contentView.addSubview(_contentView)
        _contentView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _contentView.topAnchor.constraint(equalTo: contentView.topAnchor),
            _contentView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
            _contentView.leftAnchor.constraint(equalTo: contentView.leftAnchor),
            _contentView.rightAnchor.constraint(equalTo: contentView.rightAnchor)
        ])
        _contentView.addSubview(_imageView)
        _imageView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _imageView.topAnchor.constraint(equalTo: _contentView.topAnchor, constant: 2),
            _imageView.leadingAnchor.constraint(equalTo: _contentView.leadingAnchor, constant: 2),
            _imageView.trailingAnchor.constraint(equalTo: _contentView.trailingAnchor, constant: -2),
            _imageView.bottomAnchor.constraint(equalTo: _contentView.bottomAnchor, constant: -2)
        ])

        _contentView.addSubview(_titleLabel)
        _titleLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _titleLabel.leadingAnchor.constraint(equalTo: _contentView.leadingAnchor, constant: 8),
            _titleLabel.trailingAnchor.constraint(equalTo: _contentView.trailingAnchor, constant: -8)
        ])

        _contentView.addSubview(_descriptionLabel)
        _descriptionLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _descriptionLabel.topAnchor.constraint(equalTo: _titleLabel.bottomAnchor),
            _descriptionLabel.leadingAnchor.constraint(equalTo: _contentView.leadingAnchor, constant: 8),
            _descriptionLabel.trailingAnchor.constraint(equalTo: _contentView.trailingAnchor, constant: -8),
            _descriptionLabel.bottomAnchor.constraint(equalTo: _contentView.bottomAnchor, constant: -8)
        ])
    }
}

extension StoryPreviewCell {
    struct ViewState {
        let imageURL: URL
        let title: String
        let description: String?
    }

    func render(viewState: ViewState) {
        _imageView.kf.setImage(
            with: viewState.imageURL,
            options: [.transition(.fade(0.5))]
        )
        _titleLabel.text = viewState.title
        _descriptionLabel.text = viewState.description
    }
}
<file_sep>/Stories/UI/StoriesPreviewViewController/Entity/FutureStory.swift
//
//  Created by onsissond.
//

import Foundation

struct FutureStory: Equatable {
    var imageURL: URL
    var daysToFutureStory: Int
}

<file_sep>/Stories/RxOptional/AlertBuilder.swift
//
//  Created by onsissond.
//

import UIKit

public enum AlertType {
    case alert
    case actionSheet

    var prefferedStyle: UIAlertController.Style {
        switch self {
        case .alert:
            return .alert
        case .actionSheet:
            return .actionSheet
        }
    }
}

public final class AlertBuilder {

    private let _alert: UIAlertController

    public init(_ alertType: AlertType = .alert) {
        _alert = UIAlertController(
            title: "",
            message: nil,
            preferredStyle: alertType.prefferedStyle
        )
    }

    public func with(title: String) -> AlertBuilder {
        _alert.title = title
        return self
    }

    public func with(message: String) -> AlertBuilder {
        _alert.message = message
        return self
    }

    public func withAction(title: String, isPreffered: Bool = false, action: (() -> Void)? = nil) -> AlertBuilder {
        let newAlertAction = UIAlertAction(title: title, style: .default) { _ in action?() }
        _alert.addAction(newAlertAction)
        if isPreffered {
            _alert.preferredAction = newAlertAction
        }
        return self
    }

    public func withDestructive(title: String, action: (() -> Void)? = nil) -> AlertBuilder {
        let newAlertAction = UIAlertAction(title: title, style: .destructive) { _ in action?() }
        _alert.addAction(newAlertAction)
        return self
    }

    public func withCancel(title: String, action: (() -> Void)? = nil) -> AlertBuilder {
        let newAlertAction = UIAlertAction(title: title, style: .cancel) { _ in action?() }
        _alert.addAction(newAlertAction)
        return self
    }

    public func build() -> UIAlertController {
        _alert
    }
}
<file_sep>/Stories/Foundation/Date+mock.swift
//
//  Created by onsissond.
//

import Foundation

extension Date {
    static var mock: Date {
        Date(timeIntervalSinceReferenceDate: 0)
    }

    static func mock(
        _ stringValue: String,
        dateFormat: String = "yyyy-MM-dd HH:mm:ss",
        timeZone: TimeZone = .current
    ) -> Date? {
        let formatter: DateFormatter = {
            $0.dateFormat = dateFormat
            $0.timeZone = timeZone
            return $0
        }(DateFormatter())
        return formatter.date(from: stringValue)
    }
}
<file_sep>/Stories/UI/StoryContentView/SummaryContentStoryView/SummaryInfoDetailsStoryView.swift
//
//  Created by onsissond.
//

import UIKit

final class SummaryInfoDetailsStoryView: UIView {
    private lazy var _iconView: UIImageView = {
        $0.contentMode = .center
        $0.setContentHuggingPriority(.required, for: .horizontal)
        return $0
    }(UIImageView())

    private lazy var _titleLabel: UILabel = {
        $0.font = .preferredFont(forTextStyle: .headline)
        $0.textColor = .white
        $0.numberOfLines = 0
        $0.setContentHuggingPriority(.required, for: .vertical)
        return $0
    }(UILabel())

    private lazy var _subtitleLabel: UILabel = {
        $0.font = .systemFont(ofSize: 12)
        $0.textColor = .lightGray
        $0.numberOfLines = 0
        $0.setContentHuggingPriority(.required, for: .vertical)
        return $0
    }(UILabel())

    init() {
        super.init(frame: .zero)
        _setupSubviews()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func _setupSubviews() {
        addSubview(_iconView)
        _iconView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _iconView.topAnchor.constraint(equalTo: topAnchor),
            _iconView.leadingAnchor.constraint(equalTo: leadingAnchor),
            _iconView.widthAnchor.constraint(equalToConstant: 24),
            _iconView.heightAnchor.constraint(equalToConstant: 24)
        ])
        
        addSubview(_titleLabel)
        _titleLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _titleLabel.topAnchor.constraint(equalTo: topAnchor),
            _titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 32),
            _titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor)
        ])
        addSubview(_subtitleLabel)

        _subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            _subtitleLabel.topAnchor.constraint(equalTo: _titleLabel.bottomAnchor),
            _subtitleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 32),
            _subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor),
            _subtitleLabel.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])
    }
}

extension SummaryInfoDetailsStoryView {
    struct ViewState {
        var icon: UIImage?
        var title: String
        var subtitle: String
    }

    func render(viewState: ViewState) {
        _iconView.image = viewState.icon
        _titleLabel.text = viewState.title
        _subtitleLabel.text = viewState.subtitle
    }
}
<file_sep>/Stories/UI/StoriesFactory.swift
//
//  Created by onsissond.
//

import RxSwift
import UIKit

public enum StoriesFactory {
    public struct Dependency {
        var httpClient: HTTPClient
        var dateProvider: () -> Date
        var openURL: (URL) -> Void

        public init(
            httpClient: HTTPClient,
            dateProvider: @escaping () -> Date,
            openURL: @escaping (URL) -> Void
        ) {
            self.httpClient = httpClient
            self.dateProvider = dateProvider
            self.openURL = openURL
        }
    }

    public static func create(
        dependency: Dependency,
        payload: Void
    ) -> UIViewController {
        StoriesPreviewViewController(
            store: StoriesPreviewSystem.LocalStore(
                initialState: .init(),
                reducer: StoriesPreviewSystem.reducer,
                environment: StoriesPreviewSystem.Environment(
                    dependency: dependency
                )
            )
        )
    }
}

private extension StoriesPreviewSystem.Environment {
    init(
        dependency: StoriesFactory.Dependency
    ) {
        let storiesService = StoriesService.live
        let fetchStories = storiesService.fetchStories(dependency.httpClient)
        self.init(
            fetchStories: {
                fetchStories(dependency.dateProvider())
                    .asObservable()
                    .eraseToEffect()
            },
            currentDate: dependency.dateProvider,
            uuid: UUID.init,
            calendar: { Calendar.current },
            notificationService: .live,
            storiesEnvironment: .init(
                storyEnvironment: .init(
                    openURL: dependency.openURL
                )
            )
        )
    }
}
<file_sep>/StoriesAppTests/StoriesAppTests.swift
//
//  Created by onsissond.
//

import XCTest
@testable import StoriesApp

class StoriesAppTests: XCTestCase {
    func testExample() {}
}
