Skip to content

Commit

Permalink
Merge pull request #4 from alexdmotoc/places-details
Browse files Browse the repository at this point in the history
Add place details view
  • Loading branch information
alexdmotoc authored Oct 2, 2023
2 parents fdfb7a7 + 5c5d9d9 commit a990e3b
Show file tree
Hide file tree
Showing 35 changed files with 1,500 additions and 159 deletions.
8 changes: 8 additions & 0 deletions WeatherApp-iOS/DIContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,12 @@ enum DIContainer {
useCase: favouritesUseCase,
appSettings: appSettings
)

static func makePlaceDetailsViewModel(locationName: String) -> PlaceDetailsViewModel {
.init(
locationName: locationName,
detailsFetcher: RemotePlaceDetailsFetcherImpl(client: URLSessionHTTPClient()),
photoFetcher: PlacePhotoFetcherImpl(client: URLSessionHTTPClient())
)
}
}
1 change: 1 addition & 0 deletions WeatherApp-iOS/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@
"temperature.cell.max.format" = "H:%@";
"delete.title" = "Delete";
"longPressToDelete.title" = "Long press to delete";
"noPhotos.error.message" = "There are no images to show for this location";
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ class FavouritesListViewController: UIViewController {
private var dataSource: UICollectionViewDiffableDataSource<FavouriteItemsListData.Section, FavouriteItemsListData.Item>! = nil
private var collectionView: UICollectionView!

// MARK: - Public properties

var didSelectPlaceNamed: ((String) -> Void)?

// MARK: - Lifecycle

init(viewModel: FavouritesListViewModel) {
Expand Down Expand Up @@ -115,18 +119,6 @@ class FavouritesListViewController: UIViewController {
viewModel.didAppendItem = didAppendItem
}

private func displayError(_ error: Swift.Error) {
let alertTitle = NSLocalizedString("error.title", comment: "")
let alertController = UIAlertController(
title: alertTitle,
message: error.localizedDescription,
preferredStyle: .alert
)
let okAction = UIAlertAction(title: NSLocalizedString("dismiss.title", comment: ""), style: .cancel)
alertController.addAction(okAction)
present(alertController, animated: true, completion: nil)
}

private func didReloadItems(_ items: [FavouriteItemsListData.Item]) {
var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<FavouriteItemsListData.Item>()
sectionSnapshot.append(items)
Expand All @@ -151,6 +143,9 @@ extension FavouritesListViewController: UICollectionViewDelegate {
searchController.isActive = false
searchController.searchBar.text = ""
viewModel.search(for: row.searchCompletion)
} else if collectionView == self.collectionView {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
didSelectPlaceNamed?(item.locationName)
}

collectionView.deselectItem(at: indexPath, animated: true)
Expand Down
39 changes: 39 additions & 0 deletions WeatherApp-iOS/UIKit/PlaceDetails/PlaceCell.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//
// PlaceCell.swift
// WeatherApp-iOS
//
// Created by Alex Motoc on 29.08.2023.
//

import UIKit

class PlaceCell: UICollectionViewCell {

let imageView: UIImageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFill
imageView.backgroundColor = .systemGray
imageView.clipsToBounds = true
return imageView
}()

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

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

private func configure() {
contentView.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
contentView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor)
])
}
}
178 changes: 178 additions & 0 deletions WeatherApp-iOS/UIKit/PlaceDetails/PlaceDetailsViewController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
//
// PlaceDetailsViewController.swift
// WeatherApp-iOS
//
// Created by Alex Motoc on 29.08.2023.
//

import UIKit

class PlaceDetailsViewController: UIViewController {

// MARK: - Private properties

private let viewModel: PlaceDetailsViewModel
private typealias Section = PlaceDetailsViewModel.Section
private typealias Item = PlaceDetailsViewModel.Item
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private let columnCount: CGFloat = 3
private var itemWidth: CGFloat {
guard let screen = view.window?.windowScene?.screen else { return 0 }
return screen.bounds.width / columnCount
}

// MARK: - UI

private lazy var activityIndicator: UIActivityIndicatorView = {
let indicator = UIActivityIndicatorView()
indicator.translatesAutoresizingMaskIntoConstraints = false
indicator.hidesWhenStopped = true
indicator.startAnimating()
indicator.color = .systemGray
return indicator
}()

private lazy var collectionView: UICollectionView = {
let collection = UICollectionView(frame: .zero, collectionViewLayout: makeCollectionViewLayout())
collection.translatesAutoresizingMaskIntoConstraints = false
collection.backgroundColor = .clear
collection.delegate = self
return collection
}()

// MARK: - Lifecycle

init(viewModel: PlaceDetailsViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}

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

override func viewDidLoad() {
super.viewDidLoad()
title = viewModel.locationName
view.backgroundColor = .systemGroupedBackground
configureViewHierarchy()
configureDataSource()
bindViewModel()
viewModel.loadDetails()
}

// MARK: - Private methods

private func configureViewHierarchy() {
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
view.trailingAnchor.constraint(equalTo: collectionView.trailingAnchor),
view.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor)
])

view.addSubview(activityIndicator)
NSLayoutConstraint.activate([
activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}

private func configureDataSource() {
let config = UICollectionView.CellRegistration<PlaceCell, Item> {
[weak self] cell, indexPath, item in
guard let self else { return }
Task {
do {
let data = try await self.viewModel.photoFetcher.fetchPhoto(
reference: item.reference,
maxWidth: Int(self.itemWidth),
maxHeight: nil
)
DispatchQueue.main.async {
cell.imageView.image = UIImage(data: data)
}
} catch {
self.displayError(error)
}
}
}
dataSource = .init(collectionView: collectionView, cellProvider: {
collectionView, indexPath, item in
collectionView.dequeueConfiguredReusableCell(using: config, for: indexPath, item: item)
})
}

private func bindViewModel() {
viewModel.didEncounterError = displayError
viewModel.didLoadDetails = populateCollectionView
}

private func populateCollectionView(_ details: [Item]) {
activityIndicator.stopAnimating()
var snapshot = NSDiffableDataSourceSectionSnapshot<Item>()
snapshot.append(details)
dataSource.apply(snapshot, to: .main, animatingDifferences: true)
if details.isEmpty { displayError(Error.noPhotos) }
}
}

// MARK: - UICollectionViewDelegate

extension PlaceDetailsViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
let viewController = ZoomableImageViewController(photo: item, photoFetcher: viewModel.photoFetcher)
let nav = UINavigationController(rootViewController: viewController)
present(nav, animated: true)
}
}

// MARK: - Utils

private extension PlaceDetailsViewController {
func makeCollectionViewLayout() -> UICollectionViewLayout {
let spacing: CGFloat = 3

let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0 / columnCount),
heightDimension: .fractionalHeight(1.0)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)

let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalWidth(1.0 / columnCount)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
repeatingSubitem: item,
count: Int(columnCount)
)
group.interItemSpacing = .fixed(spacing)

let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = spacing
section.contentInsets = NSDirectionalEdgeInsets(
top: spacing,
leading: spacing,
bottom: spacing,
trailing: spacing
)

let layout = UICollectionViewCompositionalLayout(section: section)
return layout
}

enum Error: Swift.Error, LocalizedError {
case noPhotos

var errorDescription: String? {
switch self {
case .noPhotos:
return NSLocalizedString("noPhotos.error.message", comment: "")
}
}
}
}
57 changes: 57 additions & 0 deletions WeatherApp-iOS/UIKit/PlaceDetails/PlaceDetailsViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//
// PlaceDetailsViewModel.swift
// WeatherApp-iOS
//
// Created by Alex Motoc on 29.08.2023.
//

import Foundation
import WeatherApp

final class PlaceDetailsViewModel {
let locationName: String
let photoFetcher: PlacePhotoFetcher
private let detailsFetcher: RemotePlaceDetailsFetcher

var didLoadDetails: (([Item]) -> Void)?
var didEncounterError: ((Error) -> Void)?

init(
locationName: String,
detailsFetcher: RemotePlaceDetailsFetcher,
photoFetcher: PlacePhotoFetcher
) {
self.locationName = locationName
self.detailsFetcher = detailsFetcher
self.photoFetcher = photoFetcher
}

func loadDetails() {
Task {
do {
let details = try await detailsFetcher.fetchDetails(placeName: locationName)
DispatchQueue.main.async {
self.didLoadDetails?(details.photoRefs.map {
.init(reference: $0.reference, width: $0.width, height: $0.height)
})
}
} catch {
didEncounterError?(error)
}
}
}
}

// MARK: - Diffable Data

extension PlaceDetailsViewModel {
enum Section {
case main
}

struct Item: Hashable {
let reference: String
let width: Int
let height: Int
}
}
20 changes: 20 additions & 0 deletions WeatherApp-iOS/UIKit/Utils/UIView+Utils.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// UIView+Utils.swift
// WeatherApp-iOS
//
// Created by Alex Motoc on 30.08.2023.
//

import UIKit

extension UIView {
func pin(subview: UIView) {
addSubview(subview)
NSLayoutConstraint.activate([
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
subview.topAnchor.constraint(equalTo: topAnchor),
trailingAnchor.constraint(equalTo: subview.trailingAnchor),
bottomAnchor.constraint(equalTo: subview.bottomAnchor)
])
}
}
22 changes: 22 additions & 0 deletions WeatherApp-iOS/UIKit/Utils/UIViewController+Utils.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// UIViewController+Utils.swift
// WeatherApp-iOS
//
// Created by Alex Motoc on 29.08.2023.
//

import UIKit

extension UIViewController {
func displayError(_ error: Swift.Error) {
let alertTitle = NSLocalizedString("error.title", comment: "")
let alertController = UIAlertController(
title: alertTitle,
message: error.localizedDescription,
preferredStyle: .alert
)
let okAction = UIAlertAction(title: NSLocalizedString("dismiss.title", comment: ""), style: .cancel)
alertController.addAction(okAction)
present(alertController, animated: true, completion: nil)
}
}
Loading

0 comments on commit a990e3b

Please sign in to comment.