[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:
@@ -1,58 +0,0 @@
|
||||
//
|
||||
// AppDelegate.swift
|
||||
// AppStoreReviews
|
||||
//
|
||||
// Created by Dmitrii Ivanov on 21/07/2020.
|
||||
// Copyright © 2020 ING. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public class FeedViewController: UITableViewController {
|
||||
|
||||
public init() {
|
||||
super.init(style: .plain)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
tableView.register(ReviewCell.self, forCellReuseIdentifier: "cellId")
|
||||
tableView.rowHeight = 160
|
||||
}
|
||||
|
||||
public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return 100
|
||||
}
|
||||
|
||||
public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let c = tableView.dequeueReusableCell(withIdentifier: "cellId", for: indexPath) as! ReviewCell
|
||||
c.update(item: randomReview())
|
||||
return c
|
||||
}
|
||||
|
||||
public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
let vc = DetailsViewController(review: randomReview())
|
||||
navigationController!.pushViewController(vc, animated: true)
|
||||
}
|
||||
|
||||
func randomReview() -> Review {
|
||||
let author = ["Dan Auerbach", "Bo Diddley", "Otis Rush", "Jimi Hendrix", "Albert King", "Buddy Guy", "Muddy Waters", "Eric Clapton"].randomElement()!
|
||||
let version = ["3.11", "3.12"].randomElement()!
|
||||
let rating = Int.random(in: 1...5)
|
||||
let title = ["Awesome app", "Could be better", "Gimme my money back!!", "Lemme tell you a story..."].randomElement()!
|
||||
let id = UUID().uuidString
|
||||
let content = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
|
||||
return Review(author: author,
|
||||
version: version,
|
||||
rating: rating,
|
||||
title: title,
|
||||
id: id,
|
||||
content: content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// Review.swift
|
||||
// ReviewsFeed
|
||||
//
|
||||
// Created by Dmitrii Ivanov on 21/07/2020.
|
||||
// Copyright © 2020 ING. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Review {
|
||||
|
||||
// MARK: Constants
|
||||
let author: String
|
||||
let comment: String
|
||||
let id: Int
|
||||
let rating: Rating
|
||||
let title: String
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Structs
|
||||
extension Review {
|
||||
struct Rating {
|
||||
|
||||
// MARK: Constants
|
||||
let stars: Int
|
||||
let appVersion: String
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// FeedViewModel.swift
|
||||
// ReviewsFeed
|
||||
//
|
||||
// Created by Javier Cicchelli on 18/03/2024.
|
||||
// Copyright © 2024 Röck+Cöde. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ReviewsiTunesKit
|
||||
|
||||
extension FeedViewController {
|
||||
final class ViewModel: ObservableObject {
|
||||
|
||||
// MARK: Type aliases
|
||||
typealias Configuration = FeedViewController.Configuration
|
||||
|
||||
// MARK: Constants
|
||||
private let configuration: Configuration
|
||||
|
||||
// MARK: Properties
|
||||
@Published var loading: Bool = false
|
||||
|
||||
var items: [Review] = []
|
||||
|
||||
// MARK: Initialisers
|
||||
init(configuration: Configuration = .init()) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
lazy private var iTunesService: iTunesService = {
|
||||
.init(configuration: .init(session: configuration.session))
|
||||
}()
|
||||
|
||||
// MARK: Functions
|
||||
func fetch() {
|
||||
Task {
|
||||
loading = true
|
||||
|
||||
do {
|
||||
let output = try await iTunesService.getReviews(.init(
|
||||
appID: configuration.appID,
|
||||
countryCode: configuration.countryCode
|
||||
))
|
||||
|
||||
items = output.reviews
|
||||
.map { review -> Review in
|
||||
.init(
|
||||
author: review.author,
|
||||
comment: review.content,
|
||||
id: review.id,
|
||||
rating: .init(
|
||||
stars: review.rating,
|
||||
appVersion: review.version
|
||||
),
|
||||
title: review.title
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// TODO: handle this error gracefully.
|
||||
}
|
||||
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
//
|
||||
// AppDelegate.swift
|
||||
// AppStoreReviews
|
||||
//
|
||||
// Created by Dmitrii Ivanov on 21/07/2020.
|
||||
// Copyright © 2020 ING. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Review {
|
||||
let author: String
|
||||
let version: String
|
||||
let rating: Int
|
||||
let title: String
|
||||
let id: String
|
||||
let content: String
|
||||
|
||||
func ratingVersionText() -> String {
|
||||
var stars = ""
|
||||
for _ in 0..<rating {
|
||||
stars += "⭐️"
|
||||
}
|
||||
return "\(stars) (ver: \(version))"
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
//
|
||||
// AppDelegate.swift
|
||||
// AppStoreReviews
|
||||
//
|
||||
// Created by Dmitrii Ivanov on 21/07/2020.
|
||||
// Copyright © 2020 ING. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ReviewCell: UITableViewCell {
|
||||
|
||||
private var titleLabel = UILabel()
|
||||
private var authorLabel = UILabel()
|
||||
private var textPreviewLabel = UILabel()
|
||||
private var ratingVersionLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
setupLabels()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func update(item: Review) {
|
||||
ratingVersionLabel.text = item.ratingVersionText()
|
||||
authorLabel.text = "from: \(item.author)"
|
||||
titleLabel.text = "\(item.title)"
|
||||
textPreviewLabel.text = "\(item.content)"
|
||||
}
|
||||
|
||||
private func setupLabels() {
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
authorLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
textPreviewLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
ratingVersionLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
titleLabel.font = UIFont.boldSystemFont(ofSize: 18)
|
||||
titleLabel.numberOfLines = 2
|
||||
ratingVersionLabel.font = UIFont.italicSystemFont(ofSize: 18)
|
||||
authorLabel.font = UIFont.italicSystemFont(ofSize: 18)
|
||||
textPreviewLabel.font = UIFont.systemFont(ofSize: 14)
|
||||
textPreviewLabel.numberOfLines = 3
|
||||
|
||||
let stack = UIStackView()
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(stack)
|
||||
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8).isActive = true
|
||||
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8).isActive = true
|
||||
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8).isActive = true
|
||||
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8).isActive = true
|
||||
|
||||
stack.axis = .vertical
|
||||
stack.distribution = .equalSpacing
|
||||
stack.alignment = .leading
|
||||
|
||||
stack.addArrangedSubview(ratingVersionLabel)
|
||||
stack.addArrangedSubview(authorLabel)
|
||||
stack.addArrangedSubview(titleLabel)
|
||||
stack.addArrangedSubview(textPreviewLabel)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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..."
|
||||
))
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
//
|
||||
// AppDelegate.swift
|
||||
// AppStoreReviews
|
||||
// ReviewsFeed
|
||||
//
|
||||
// Created by Dmitrii Ivanov on 21/07/2020.
|
||||
// Copyright © 2020 ING. All rights reserved.
|
||||
@@ -43,7 +43,7 @@ class DetailsViewController: UIViewController {
|
||||
view.addSubview(titleLabel)
|
||||
view.addSubview(contentLabel)
|
||||
|
||||
ratingVersionLabel.text = review.ratingVersionText()
|
||||
ratingVersionLabel.text = review.rating.appVersion
|
||||
ratingVersionLabel.font = UIFont.italicSystemFont(ofSize: 18)
|
||||
|
||||
authorLabel.text = review.author
|
||||
@@ -53,7 +53,7 @@ class DetailsViewController: UIViewController {
|
||||
titleLabel.numberOfLines = 0
|
||||
titleLabel.font = UIFont.boldSystemFont(ofSize: 22)
|
||||
|
||||
contentLabel.text = review.content
|
||||
contentLabel.text = review.comment
|
||||
contentLabel.numberOfLines = 0
|
||||
|
||||
ratingVersionLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8).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