Skip to content

Commit

Permalink
Merge pull request #628 from yuta24/impl-announcements
Browse files Browse the repository at this point in the history
[iOS] Implement announcements
  • Loading branch information
ry-itto authored Feb 2, 2020
2 parents 8f37a33 + 30994cb commit 6959e43
Show file tree
Hide file tree
Showing 30 changed files with 589 additions and 2 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,3 +242,4 @@ This project uses some modern Android libraries and source codes.
* [Material](https://github.com/CosmicMind/Material)
* [Nuke](https://github.com/kean/Nuke)
* [SwiftGen](https://github.com/SwiftGen/SwiftGen)
* [SnapshotTesting](https://github.com/pointfreeco/swift-snapshot-testing)
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ interface DroidKaigiApi {

fun getSessionsAsync(): Deferred<Response>

fun getAnnouncements(
lang: LangParameter,
callback: (response: AnnouncementListResponse) -> Unit,
onError: (error: Exception) -> Unit
)

fun getAnnouncementsAsync(lang: LangParameter): Deferred<AnnouncementListResponse>

suspend fun getSponsors(): SponsorListResponse
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,21 @@ internal open class KtorDroidKaigiApi constructor(
getSessions()
}

override fun getAnnouncements(
lang: LangParameter,
callback: (response: AnnouncementListResponse) -> Unit,
onError: (error: Exception) -> Unit
) {
GlobalScope.launch(requireNotNull(coroutineDispatcherForCallback)) {
try {
val response = getAnnouncements(lang)
callback(response)
} catch (ex: Exception) {
onError(ex)
}
}
}

override fun getAnnouncementsAsync(lang: LangParameter): Deferred<AnnouncementListResponse> =
GlobalScope.async(requireNotNull(coroutineDispatcherForCallback)) {
getAnnouncements(lang)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import ios_combined
import RxSwift

protocol AnnouncementsDataProviderProtocol {
func fetch() -> Single<[Announcement]>
}

final class AnnouncementsDataProvider: AnnouncementsDataProviderProtocol {
enum Transformer {
static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXX"
return formatter
}()

static func transform(_ response: String) -> Announcement.Type_? {
switch response {
case "NOTIFICATION":
return .notification
case "ALERT":
return .alert
case "FEEDBACK":
return .feedback
default:
return .none
}
}

static func transform(_ response: AnnouncementResponse) -> Announcement? {
guard let publishedAt = dateFormatter.date(from: response.publishedAt)?.timeIntervalSince1970 else {
return .none
}
guard let type = transform(response.type) else {
return .none
}

return Announcement(
id: response.id_,
title: response.title,
content: response.content,
publishedAt: publishedAt,
type: type
)
}
}

func fetch() -> Single<[Announcement]> {
Single.create { observer in
ApiComponentKt.generateDroidKaigiApi().getAnnouncements(
lang: LangParameter.from(LangKt.defaultLang()),
callback: { response in
let response = response.compactMap(Transformer.transform).sorted { $0.publishedAt >= $1.publishedAt }
observer(.success(response))
},
onError: { exception in
observer(.error(KotlinError(localizedDescription: exception.description())))
}
)

return Disposables.create()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Foundation
import ios_combined
import RxCocoa
import RxSwift

protocol AnnouncementsViewModelType {
// Output
var announcements: Driver<[Announcement]> { get }
var error: Driver<KotlinError?> { get }

// Input
func viewDidLoad()
func pullToRefresh()
}

final class AnnouncementsViewModel: AnnouncementsViewModelType {
let announcements: Driver<[Announcement]>
let error: Driver<KotlinError?>

private let viewDidLoadRelay = PublishRelay<Void>()
private let pullToRefreshRelay = PublishRelay<Void>()

private let disposeBag = DisposeBag()

init(
provider: AnnouncementsDataProviderProtocol
) {
let announcementsRelay = BehaviorRelay<[Announcement]>(value: [])
let errorRelay = BehaviorRelay<KotlinError?>(value: nil)

announcements = announcementsRelay.asDriver()
error = errorRelay.asDriver()

let fetchResult = Observable.merge(viewDidLoadRelay.asObservable(), pullToRefreshRelay.asObservable())
.flatMap { provider.fetch().asObservable().materialize() }
.share()

fetchResult.compactMap { $0.element }
.bind(to: announcementsRelay)
.disposed(by: disposeBag)
fetchResult.map { $0.error as? KotlinError }
.bind(to: errorRelay)
.disposed(by: disposeBag)
}

func viewDidLoad() {
viewDidLoadRelay.accept(())
}

func pullToRefresh() {
pullToRefreshRelay.accept(())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import ios_combined
import RxCocoa
import RxSwift
import UIKit

final class AnnouncementsDataSource: NSObject, RxTableViewDataSourceType, UITableViewDataSource {
typealias Element = [Announcement]

private var items = [Announcement]()

func tableView(_ tableView: UITableView, observedEvent: Event<[Announcement]>) {
Binder(self) { target, items in
target.items = items
tableView.reloadData()
}
.on(observedEvent)
}

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return items.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AnnouncementCell.self), for: indexPath) as! AnnouncementCell // swiftlint:disable:this force_cast
cell.configure(items[indexPath.item])
return cell
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import ios_combined
import RxCocoa
import RxSwift
import UIKit

final class AnnouncementsViewController: UIViewController {
@IBOutlet weak var tableView: UITableView! {
didSet {
tableView.tableFooterView = UIView()
tableView.separatorStyle = .none
tableView.allowsSelection = false
tableView.refreshControl = refreshControl
tableView.register(UINib(nibName: String(describing: AnnouncementCell.self), bundle: .none), forCellReuseIdentifier: String(describing: AnnouncementCell.self))
}
}

override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}

private let refreshControl = UIRefreshControl()
private let viewModel: AnnouncementsViewModelType
private let disposeBag = DisposeBag()

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

init(viewModel: AnnouncementsViewModelType) {
self.viewModel = viewModel

super.init(nibName: nil, bundle: nil)
}

override func viewDidLoad() {
super.viewDidLoad()

navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: UIColor.black]
navigationController?.navigationBar.barTintColor = ApplicationScheme.shared.colorScheme.backgroundColor
navigationController?.navigationBar.tintColor = ApplicationScheme.shared.colorScheme.onBackgroundColor

let templateMenuImage = Asset.icMenu.image.withRenderingMode(.alwaysTemplate)
let menuItem = UIBarButtonItem(image: templateMenuImage,
style: .plain,
target: self,
action: nil)
let titleItem = UIBarButtonItem(title: L10n.announcements,
style: .plain,
target: nil,
action: nil)
titleItem.setTitleTextAttributes([.foregroundColor: UIColor.black], for: .disabled)
titleItem.isEnabled = false
navigationItem.leftBarButtonItems = [menuItem, titleItem]

menuItem.rx.tap
.bind(to: Binder(self) { target, _ in
target.navigationDrawerController?.toggleLeftView()
})
.disposed(by: disposeBag)

let dataSource = AnnouncementsDataSource()

refreshControl.rx.controlEvent(.valueChanged)
.bind(to: Binder(self) { target, _ in
target.refreshControl.beginRefreshing()
target.viewModel.pullToRefresh()
})
.disposed(by: disposeBag)

viewModel.announcements
.map { _ in }
.drive(Binder(self) { target, _ in
target.refreshControl.endRefreshing()
})
.disposed(by: disposeBag)

viewModel.announcements
.drive(tableView.rx.items(dataSource: dataSource))
.disposed(by: disposeBag)

viewModel.viewDidLoad()
}
}

extension AnnouncementsViewController {
static func instantiate() -> AnnouncementsViewController {
AnnouncementsViewController(viewModel: AnnouncementsViewModel(provider: AnnouncementsDataProvider()))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15705" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15706"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="AnnouncementsViewController" customModule="DroidKaigi_2020" customModuleProvider="target">
<connections>
<outlet property="tableView" destination="WIp-LM-x6u" id="CfU-t5-jUe"/>
<outlet property="view" destination="i5M-Pr-FkT" id="sfx-zR-JGt"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" id="i5M-Pr-FkT">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<tableView clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" translatesAutoresizingMaskIntoConstraints="NO" id="WIp-LM-x6u">
<rect key="frame" x="0.0" y="44" width="414" height="818"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
</tableView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="fnl-2z-Ty3" firstAttribute="trailing" secondItem="WIp-LM-x6u" secondAttribute="trailing" id="G9U-Ah-osY"/>
<constraint firstItem="WIp-LM-x6u" firstAttribute="leading" secondItem="fnl-2z-Ty3" secondAttribute="leading" constant="20" symbolic="YES" id="YVX-7K-KLE"/>
<constraint firstItem="fnl-2z-Ty3" firstAttribute="bottom" secondItem="WIp-LM-x6u" secondAttribute="bottom" id="lZL-21-9I6"/>
<constraint firstItem="WIp-LM-x6u" firstAttribute="top" secondItem="fnl-2z-Ty3" secondAttribute="top" id="tUo-OX-nt5"/>
</constraints>
<viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/>
<point key="canvasLocation" x="139" y="99"/>
</view>
</objects>
</document>
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import ios_combined
import UIKit

private extension Announcement.Type_ {
var iconImage: UIImage? {
switch self {
case .alert:
return #imageLiteral(resourceName: "warning_amber_24px")
case .feedback:
return #imageLiteral(resourceName: "assignment_24px")
case .notification:
return #imageLiteral(resourceName: "error_outline_24px")
default:
return nil
}
}
}

final class AnnouncementCell: UITableViewCell {
enum Constant {
static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale.current
formatter.calendar = Calendar.current
formatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "dMMM HH:mm", options: 0, locale: Locale.current)
return formatter
}()
}

@IBOutlet weak var iconImageContainerView: UIView! {
didSet {
iconImageContainerView.clipsToBounds = true
iconImageContainerView.backgroundColor = Asset.secondary50.color
iconImageContainerView.layer.cornerRadius = 16
}
}

@IBOutlet weak var iconImageView: UIImageView!
@IBOutlet weak var publishedAtLabel: UILabel!
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var contentTextView: UITextView!

func configure(_ announcement: Announcement) {
iconImageView.image = announcement.type.iconImage?.withRenderingMode(.alwaysOriginal).tint(with: Asset.secondary300.color)
publishedAtLabel.text = Constant.dateFormatter.string(from: Date(timeIntervalSince1970: announcement.publishedAt))
titleLabel.text = announcement.title
contentTextView.attributedText = { content in
guard
let data = content.data(using: .unicode),
let attributedString = try? NSMutableAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil)
else {
return nil
}

let range = NSRange(location: 0, length: attributedString.length)
attributedString.addAttribute(.font, value: UIFont.preferredFont(forTextStyle: .body), range: range)
return attributedString
}(announcement.content)
}
}
Loading

0 comments on commit 6959e43

Please sign in to comment.