Skip to content

Commit

Permalink
Merge pull request #543 from DroidKaigi/media-feature-impl
Browse files Browse the repository at this point in the history
[iOS] Media Feature Impl
  • Loading branch information
Ryoya Ito authored Jul 9, 2021
2 parents 21b51ed + 2938cfe commit 0c5088a
Show file tree
Hide file tree
Showing 11 changed files with 305 additions and 234 deletions.
1 change: 1 addition & 0 deletions ios/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ var package = Package(
dependencies: [
.target(name: "Component"),
.target(name: "Model"),
.target(name: "Repository"),
.target(name: "Styleguide"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
.product(name: "Introspect", package: "Introspect"),
Expand Down
4 changes: 2 additions & 2 deletions ios/Sources/AppFeature/AppFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ public let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine(
mediaReducer.pullback(
state: \.mediaState,
action: /AppAction.media,
environment: { _ in
.init()
environment: { environment in
.init(feedRepository: environment.feedRepository)
}
),
favoritesReducer.pullback(
Expand Down
64 changes: 33 additions & 31 deletions ios/Sources/Component/Card/MediumCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,46 +30,48 @@ public struct MediumCard: View {
}

public var body: some View {
VStack(alignment: .leading, spacing: 13) {
ImageView(
imageURL: imageURL,
placeholder: .noImage,
placeholderSize: .medium
)
.aspectRatio(225/114, contentMode: .fit)
.scaledToFill()
.layoutPriority(1)
GeometryReader { geometry in
VStack(alignment: .leading, spacing: 13) {
ImageView(
imageURL: imageURL,
placeholder: .noImage,
placeholderSize: .medium,
width: geometry.size.width,
height: geometry.size.width * 114/225
)

VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.subheadline)
.foregroundColor(AssetColor.Base.primary.color)
.lineLimit(2)
.frame(maxHeight: .infinity, alignment: .top)
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.subheadline)
.foregroundColor(AssetColor.Base.primary.color)
.lineLimit(2)
.frame(maxHeight: .infinity, alignment: .top)

Text(date.formatted)
.font(.caption)
.foregroundColor(AssetColor.Base.tertiary.color)
}
Text(date.formatted)
.font(.caption)
.foregroundColor(AssetColor.Base.tertiary.color)
}

HStack(spacing: 8) {
Tag(media: media)
HStack(spacing: 8) {
Tag(media: media)

Spacer()
Spacer()

Button(action: tapFavoriteAction, label: {
let image = isFavorited ? AssetImage.iconFavorite.image : AssetImage.iconFavoriteOff.image
image
.renderingMode(.template)
.foregroundColor(AssetColor.primary.color)
})
Button(action: tapFavoriteAction, label: {
let image = isFavorited ? AssetImage.iconFavorite.image : AssetImage.iconFavoriteOff.image
image
.renderingMode(.template)
.foregroundColor(AssetColor.primary.color)
})
}
}
}
.background(Color.clear)
.onTapGesture(perform: tapAction)
}
.padding(16)
.background(Color.clear)
.onTapGesture(perform: tapAction)
.frame(width: 257, height: 258)
}
}

Expand Down
132 changes: 40 additions & 92 deletions ios/Sources/MediaFeature/MediaFeature.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import Component
import ComposableArchitecture
import Model
import Repository

public enum MediaState: Equatable {

case needToInitialize
case initialized(MediaListState)

Expand All @@ -12,110 +12,50 @@ public enum MediaState: Equatable {
}
}

public struct MediaListState: Equatable {

enum Next: Equatable {
case searchText(String)
case isEditingDidChange(Bool)
case more(for: MediaType)
}

var blogs: [FeedContent]
var videos: [FeedContent]
var podcasts: [FeedContent]
var next: Next?
}

public enum MediaType {
case blog
case video
case podcast
}

public enum MediaAction: Equatable {
case loadItems
case itemsLoaded([FeedContent])
public enum MediaAction {
case refresh
case refreshResponse(Result<[FeedContent], KotlinError>)
case mediaList(MediaListAction)
}

public enum MediaListAction: Equatable {
case searchTextDidChange(to: String?)
case isEditingDidChange(to: Bool)
case showMore(for: MediaType)
case moreDismissed
case tap(FeedContent)
case tapFavorite(isFavorited: Bool, id: String)
}

public struct MediaEnvironment {
public init() {}
}
public let feedRepository: FeedRepositoryProtocol

let mediaListReducer = Reducer<MediaListState, MediaListAction, Void> { state, action, _ in
switch action {
case let .searchTextDidChange(to: searchText):
switch state.next {
case nil, .searchText, .isEditingDidChange:
state.next = searchText.map { .searchText($0) }
default:
break
}
return .none
case let .isEditingDidChange(isEditing):
switch state.next {
case nil, .searchText, .isEditingDidChange:
state.next = .isEditingDidChange(isEditing)
if !isEditing {
state.next = nil
}
default:
break
}
return .none
case let .showMore(mediaType):
if state.next == nil {
state.next = .more(for: mediaType)
}
return .none
case .moreDismissed:
if case .more = state.next {
state.next = nil
}
return .none
case .tap(let content):
return .none
case .tapFavorite(let isFavorited, let contentId):
return .none
public init(
feedRepository: FeedRepositoryProtocol
) {
self.feedRepository = feedRepository
}
}

public let mediaReducer = Reducer<MediaState, MediaAction, MediaEnvironment>.combine(
mediaListReducer.pullback(
state: /MediaState.initialized,
action: /MediaAction.mediaList,
environment: { _ in () }
environment: {
.init(feedRepository: $0.feedRepository)
}
),
.init { state, action, _ in
.init { state, action, environment in
switch action {
case .loadItems:
// TODO: Load items from the repository
return Effect(value: .mockItemsLoads)
.delay(for: 1, scheduler: DispatchQueue.main)
.eraseToEffect()
case let .itemsLoaded(contents):
var blogs: [FeedContent] = .init()
var videos: [FeedContent] = .init()
var podcasts: [FeedContent] = .init()
for content in contents {
switch content.item.wrappedValue {
case .refresh:
return environment.feedRepository.feedContents()
.catchToEffect()
.map(MediaAction.refreshResponse)
case let .refreshResponse(.success(feedContents)):
var blogs: [FeedContent] = []
var videos: [FeedContent] = []
var podcasts: [FeedContent] = []
for feedContent in feedContents {
switch feedContent.item.wrappedValue {
case is Blog:
blogs.append(content)
blogs.append(feedContent)
case is Video:
videos.append(content)
videos.append(feedContent)
case is Podcast:
podcasts.append(content)
podcasts.append(feedContent)
default:
assertionFailure("Unexpected FeedItem: (\(content.item.wrappedValue)")
assertionFailure("Unexpected FeedItem: (\(feedContent.item.wrappedValue)")
break
}
}
Expand All @@ -125,15 +65,23 @@ public let mediaReducer = Reducer<MediaState, MediaAction, MediaEnvironment>.com
listState.podcasts = podcasts
state = .initialized(listState)
} else {
state = .initialized(.init(blogs: blogs, videos: videos, podcasts: podcasts, next: nil))
state = .initialized(
.init(
feedContents: feedContents,
blogs: blogs,
videos: videos,
podcasts: podcasts,
next: nil
)
)
}
return .none
case let .refreshResponse(.failure(error)):
print(error.localizedDescription)
// TODO: Error handling
return .none
case .mediaList:
return .none
}
}
)

private extension MediaAction {
static let mockItemsLoads: Self = .itemsLoaded([])
}
112 changes: 112 additions & 0 deletions ios/Sources/MediaFeature/MediaListFeature.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import ComposableArchitecture
import Model
import Repository

public struct MediaListState: Equatable {

enum Next: Equatable {
case searchText(String)
case isEditingDidChange(Bool)
case more(for: MediaType)
}

// In order not to use any networks for searching feature,
// `feedContents` is storage to search from & `searchedFeedContents` is searched result from `feedContents`
var feedContents: [FeedContent]
var searchedFeedContents: [FeedContent]
var blogs: [FeedContent]
var videos: [FeedContent]
var podcasts: [FeedContent]
var next: Next?

init(
feedContents: [FeedContent],
searchedFeedContents: [FeedContent] = [],
blogs: [FeedContent],
videos: [FeedContent],
podcasts: [FeedContent],
next: Next?
) {
self.feedContents = feedContents
self.searchedFeedContents = searchedFeedContents
self.blogs = blogs
self.videos = videos
self.podcasts = podcasts
self.next = next
}
}

public enum MediaListAction {
case searchTextDidChange(to: String?)
case isEditingDidChange(to: Bool)
case showMore(for: MediaType)
case moreDismissed
case tap(FeedContent)
case tapFavorite(isFavorited: Bool, id: String)
case favoriteResponse(Result<String, KotlinError>)
}

public enum MediaType {
case blog
case video
case podcast
}

let mediaListReducer = Reducer<MediaListState, MediaListAction, MediaEnvironment> { state, action, environment in
switch action {
case let .searchTextDidChange(to: searchText):
switch state.next {
case nil, .searchText, .isEditingDidChange:
state.next = searchText.map { .searchText($0) }
if let searchText = searchText {
state.searchedFeedContents = state.feedContents.filter { content in
content.item.title.jaTitle.lowercased().contains(searchText.lowercased())
}
}
default:
break
}
return .none
case let .isEditingDidChange(isEditing):
switch state.next {
case nil, .searchText, .isEditingDidChange:
state.next = .isEditingDidChange(isEditing)
if !isEditing {
state.next = nil
}
default:
break
}
return .none
case let .showMore(mediaType):
if state.next == nil {
state.next = .more(for: mediaType)
}
return .none
case .moreDismissed:
if case .more = state.next {
state.next = nil
}
return .none
case .tap(let content):
// TODO: open content page
return .none
case .tapFavorite(let isFavorited, let id):
let publisher = isFavorited
? environment.feedRepository.removeFavorite(id: id)
: environment.feedRepository.addFavorite(id: id)
return publisher
.map { id }
.catchToEffect()
.map(MediaListAction.favoriteResponse)
case let .favoriteResponse(.success(id)):
if let index = state.feedContents.map(\.id).firstIndex(of: id) {
state.feedContents[index].isFavorited.toggle()
state.searchedFeedContents[index].isFavorited.toggle()
}
return .none
case let .favoriteResponse(.failure(error)):
print(error.localizedDescription)
return .none
}
}
Loading

0 comments on commit 0c5088a

Please sign in to comment.