[Framework] Feed list view in the Feed framework (#9)
This PR contains the work done to provide the existing `FeedViewController` view controller with real life data by integrating the `iTunesService` service into its view model. In addition, the list item cell has been design has been updated, and re-implemented using the `SwiftUI` framework. Reviewed-on: rock-n-code/app-reviews#9 Co-authored-by: Javier Cicchelli <javier@rock-n-code.com> Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #9.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// FeedItem.swift
|
||||
// ReviewsFeed
|
||||
//
|
||||
// Created by Javier Cicchelli on 19/03/2024.
|
||||
// Copyright © 2024 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct FeedItem: View {
|
||||
|
||||
// MARK: Constants
|
||||
private let item: Review
|
||||
|
||||
// MARK: Initialisers
|
||||
init(_ item: Review) {
|
||||
self.item = item
|
||||
}
|
||||
|
||||
// MARK: Body
|
||||
var body: some View {
|
||||
VStack(
|
||||
alignment: .leading,
|
||||
spacing: 16
|
||||
) {
|
||||
HStack(
|
||||
alignment: .bottom,
|
||||
spacing: 8
|
||||
) {
|
||||
Image(systemName: "person.crop.circle")
|
||||
|
||||
Text(item.author)
|
||||
.font(.subheadline)
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text(item.title)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
.fullWidth()
|
||||
|
||||
Text(item.comment)
|
||||
.font(.body)
|
||||
.lineLimit(4)
|
||||
.fullWidth()
|
||||
}
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
HStack(alignment: .bottom) {
|
||||
ForEach(1...5, id: \.self) { index in
|
||||
if #available(iOS 15.0, *) {
|
||||
Image(systemName: "star")
|
||||
.symbolVariant(index <= item.rating.stars ? .fill : .none)
|
||||
} else {
|
||||
Image(systemName: index <= item.rating.stars ? "star.fill" : "star")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(
|
||||
alignment: .bottom,
|
||||
spacing: 4
|
||||
) {
|
||||
Text(item.rating.appVersion)
|
||||
|
||||
Image(systemName: "iphone.gen3.circle")
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
#Preview("Feed Item") {
|
||||
FeedItem(.init(
|
||||
author: "Some author name here...",
|
||||
comment: "Some review comment here...",
|
||||
id: 0,
|
||||
rating: .init(
|
||||
stars: 1,
|
||||
appVersion: "v1.2.3"
|
||||
),
|
||||
title: "Some review title here..."
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// AppDelegate.swift
|
||||
// ReviewsFeed
|
||||
//
|
||||
// Created by Dmitrii Ivanov on 21/07/2020.
|
||||
// Copyright © 2020 ING. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class DetailsViewController: UIViewController {
|
||||
|
||||
private let review: Review
|
||||
|
||||
private var titleLabel = UILabel()
|
||||
private var authorLabel = UILabel()
|
||||
private var contentLabel = UILabel()
|
||||
private var ratingVersionLabel = UILabel()
|
||||
|
||||
init(review: Review) {
|
||||
self.review = review
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor.white
|
||||
setupViews()
|
||||
}
|
||||
|
||||
func setupViews() {
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
authorLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
ratingVersionLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
view.addSubview(ratingVersionLabel)
|
||||
view.addSubview(authorLabel)
|
||||
view.addSubview(titleLabel)
|
||||
view.addSubview(contentLabel)
|
||||
|
||||
ratingVersionLabel.text = review.rating.appVersion
|
||||
ratingVersionLabel.font = UIFont.italicSystemFont(ofSize: 18)
|
||||
|
||||
authorLabel.text = review.author
|
||||
authorLabel.font = UIFont.systemFont(ofSize: 18)
|
||||
|
||||
titleLabel.text = review.title
|
||||
titleLabel.numberOfLines = 0
|
||||
titleLabel.font = UIFont.boldSystemFont(ofSize: 22)
|
||||
|
||||
contentLabel.text = review.comment
|
||||
contentLabel.numberOfLines = 0
|
||||
|
||||
ratingVersionLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8).isActive = true
|
||||
ratingVersionLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8).isActive = true
|
||||
ratingVersionLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
|
||||
ratingVersionLabel.heightAnchor.constraint(equalToConstant: 24).isActive = true
|
||||
|
||||
authorLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8).isActive = true
|
||||
authorLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8).isActive = true
|
||||
authorLabel.topAnchor.constraint(equalTo: ratingVersionLabel.bottomAnchor, constant: 8).isActive = true
|
||||
authorLabel.heightAnchor.constraint(equalToConstant: 24).isActive = true
|
||||
|
||||
titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8).isActive = true
|
||||
titleLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8).isActive = true
|
||||
titleLabel.topAnchor.constraint(equalTo: authorLabel.bottomAnchor, constant: 8).isActive = true
|
||||
titleLabel.heightAnchor.constraint(lessThanOrEqualToConstant: 72).isActive = true
|
||||
|
||||
contentLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8).isActive = true
|
||||
contentLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8).isActive = true
|
||||
contentLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8).isActive = true
|
||||
contentLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: 24).isActive = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//
|
||||
// AppDelegate.swift
|
||||
// ReviewsFeed
|
||||
//
|
||||
// Created by Dmitrii Ivanov on 21/07/2020.
|
||||
// Copyright © 2020 ING. All rights reserved.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import ReviewsUIKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
public class FeedViewController: UITableViewController {
|
||||
|
||||
// MARK: Constants
|
||||
private let viewModel: ViewModel
|
||||
|
||||
// MARK: Properties
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
// MARK: Initialisers
|
||||
public init(configuration: Configuration = .init()) {
|
||||
self.viewModel = .init(configuration: configuration)
|
||||
|
||||
super.init(style: .plain)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: UIViewController
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tableView.register(
|
||||
UITableViewCell.self,
|
||||
forCellReuseIdentifier: .Cell.feedItem
|
||||
)
|
||||
|
||||
bindViewModel()
|
||||
|
||||
viewModel.fetch()
|
||||
}
|
||||
|
||||
// MARK: UITableViewDataSource
|
||||
public override func tableView(
|
||||
_ tableView: UITableView,
|
||||
numberOfRowsInSection section: Int
|
||||
) -> Int {
|
||||
viewModel.items.count
|
||||
}
|
||||
|
||||
public override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
guard let cell = tableView.dequeueReusableCell(withIdentifier: .Cell.feedItem) else {
|
||||
return .init()
|
||||
}
|
||||
|
||||
cell.contentConfiguration = {
|
||||
if #available(iOS 16.0, *) {
|
||||
UIHostingConfiguration {
|
||||
FeedItem(viewModel.items[indexPath.row])
|
||||
}
|
||||
} else {
|
||||
HostingConfiguration {
|
||||
FeedItem(viewModel.items[indexPath.row])
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
// MARK: UITableViewDelegate
|
||||
public override func tableView(
|
||||
_ tableView: UITableView,
|
||||
didSelectRowAt indexPath: IndexPath
|
||||
) {
|
||||
let details = DetailsViewController(review: viewModel.items[indexPath.row])
|
||||
|
||||
tableView.deselectRow(
|
||||
at: indexPath,
|
||||
animated: true
|
||||
)
|
||||
|
||||
navigationController?.pushViewController(details, animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private extension FeedViewController {
|
||||
|
||||
// MARK: Functions
|
||||
func bindViewModel() {
|
||||
viewModel.$loading
|
||||
.sink { loading in
|
||||
print("LOADING: \(loading)")
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$loading
|
||||
.dropFirst()
|
||||
.filter { $0 == false }
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.tableView.reloadData()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
extension FeedViewController {
|
||||
public struct Configuration {
|
||||
|
||||
// MARK: Constants
|
||||
let appID: String
|
||||
let countryCode: String
|
||||
let session: URLSessionConfiguration
|
||||
|
||||
// MARK: Initialisers
|
||||
public init(
|
||||
appID: String = "474495017",
|
||||
countryCode: String = "nl",
|
||||
session: URLSessionConfiguration = .ephemeral
|
||||
) {
|
||||
self.appID = appID
|
||||
self.countryCode = countryCode
|
||||
self.session = session
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String+Constants
|
||||
private extension String {
|
||||
enum Cell {
|
||||
static let feedItem = "FeedItemCell"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
#if DEBUG
|
||||
import ReviewsFoundationKit
|
||||
import ReviewsiTunesKit
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
#Preview("Feed View Controller with few reviews") {
|
||||
MockURLProtocol.response = .init(
|
||||
statusCode: 200,
|
||||
object: Feed(entries: [
|
||||
.init(
|
||||
id: 1,
|
||||
author: "Some author name #1 here",
|
||||
title: "Some review title #1 goes here...",
|
||||
content: "Some long, explanatory review comment #1 goes here...",
|
||||
rating: 3,
|
||||
version: "v1.0.0",
|
||||
updated: .init()
|
||||
),
|
||||
.init(
|
||||
id: 2,
|
||||
author: "Some author name #2 here",
|
||||
title: "Some review title #2 goes here...",
|
||||
content: "Some long, explanatory review comment #2 goes here...",
|
||||
rating: 5,
|
||||
version: "v1.0.0",
|
||||
updated: .init()
|
||||
),
|
||||
.init(
|
||||
id: 3,
|
||||
author: "Some author name #3 here",
|
||||
title: "Some review title #3 goes here...",
|
||||
content: "Some long, explanatory review comment #3 goes here...",
|
||||
rating: 1,
|
||||
version: "v1.0.0",
|
||||
updated: .init()
|
||||
),
|
||||
])
|
||||
)
|
||||
|
||||
return FeedViewController(configuration: .init(session: .mock))
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
#Preview("Feed View Controller with no reviews") {
|
||||
MockURLProtocol.response = .init(
|
||||
statusCode: 200,
|
||||
object: Feed(entries: [])
|
||||
)
|
||||
|
||||
return FeedViewController(configuration: .init(session: .mock))
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user