Skip to content

Commit

Permalink
Update translation feature (#107)
Browse files Browse the repository at this point in the history
  • Loading branch information
erolburak authored Sep 23, 2024
1 parent 73d115d commit 04dc96f
Show file tree
Hide file tree
Showing 12 changed files with 256 additions and 130 deletions.
8 changes: 6 additions & 2 deletions BobbysNews/Factory/ViewModelFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import BobbysNewsDomain
import SwiftUI

final class ViewModelFactory {
// MARK: - Private Properties
Expand All @@ -28,7 +29,10 @@ final class ViewModelFactory {
readTopHeadlinesUseCase: useCaseFactory.readTopHeadlinesUseCase)
}

func detailViewModel(article: Article) -> DetailViewModel {
DetailViewModel(article: article)
func detailViewModel(article: Article,
articleImage: Image?) -> DetailViewModel
{
DetailViewModel(article: article,
articleImage: articleImage)
}
}
197 changes: 106 additions & 91 deletions BobbysNews/Presentation/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ struct ContentView: View {
// MARK: - Private Properties

@AppStorage("country") private var country = ""
@Namespace private var animation

// MARK: - Properties

Expand All @@ -25,17 +24,7 @@ struct ContentView: View {
NavigationStack {
ScrollView {
ForEach($viewModel.articles) { $article in
NavigationLink {
DetailView(viewModel: ViewModelFactory.shared.detailViewModel(article: article))
.navigationTransition(.zoom(sourceID: article.id,
in: animation))
} label: {
ListItem(article: $article,
translationSessionConfiguration: viewModel.translationSessionConfiguration)
}
.matchedTransitionSource(id: article.id,
in: animation)
.accessibilityIdentifier(article.id == viewModel.articles.first?.id ? "NavigationLink" : "")
ListItem(article: $article)
}
}
.navigationTitle("TopHeadlines")
Expand Down Expand Up @@ -77,13 +66,14 @@ struct ContentView: View {
}
}
}
.menuActionDismissBehavior(.disabled)
}

Section {
Toggle("Translation",
Toggle("Translate",
systemImage: "translate",
isOn: $viewModel.translationBool)
.accessibilityIdentifier("TranslationToggle")
isOn: $viewModel.translate)
.accessibilityIdentifier("TranslateToggle")
}

Section {
Expand Down Expand Up @@ -149,8 +139,8 @@ struct ContentView: View {
}
} else {
switch viewModel.stateTopHeadlines {
case .isLoading:
Text("TopHeadlinesLoading")
case .isLoading, .isTranslating:
Text(viewModel.stateTopHeadlines == .isLoading ? "TopHeadlinesLoading" : "TopHeadlinesTranslating")
.fontWeight(.black)
case .loaded:
EmptyView()
Expand All @@ -172,6 +162,22 @@ struct ContentView: View {
.foregroundStyle(.secondary)
.accessibilityIdentifier("RefreshButton")
}
case .emptyTranslate:
ContentUnavailableView {
Label("EmptyTranslateTopHeadlines",
systemImage: "translate")
} description: {
Text("EmptyTranslateTopHeadlinesMessage")
} actions: {
Button("Disable") {
viewModel.translate = false
}
.textCase(.uppercase)
.font(.system(.subheadline,
weight: .black))
.foregroundStyle(.secondary)
.accessibilityIdentifier("DisableButton")
}
}
}
}
Expand Down Expand Up @@ -200,6 +206,12 @@ struct ContentView: View {
.sensoryFeedback(trigger: viewModel.sensoryFeedbackBool) { _, _ in
viewModel.sensoryFeedback
}
.onChange(of: viewModel.translate) { _, newValue in
viewModel.translate(translate: newValue)
}
.translationTask(viewModel.translationSessionConfiguration) { translateSession in
await viewModel.translate(translateSession: translateSession)
}
}
}

Expand All @@ -210,106 +222,109 @@ struct ContentView: View {
private struct ListItem: View {
// MARK: - Private Properties

@Namespace private var animation
@State private var articleImage: Image?
@State private var showTranslationPresentation = false
@State private var translationPresentationText = ""

// MARK: - Properties

@Binding var article: Article
let translationSessionConfiguration: TranslationSession.Configuration?

// MARK: - Layouts

var body: some View {
HStack {
VStack(alignment: .leading) {
Text(article.source?.name ?? String(localized: "EmptyArticleSource"))
.font(.system(.subheadline,
weight: .black))
.lineLimit(1)
NavigationLink {
DetailView(viewModel: ViewModelFactory.shared.detailViewModel(article: article,
articleImage: articleImage))
.navigationTransition(.zoom(sourceID: article.id,
in: animation))
} label: {
HStack {
VStack(alignment: .leading) {
Text(article.source?.name ?? String(localized: "EmptyArticleSource"))
.font(.system(.subheadline,
weight: .black))
.lineLimit(1)

Text(article.publishedAt?.toRelative ?? String(localized: "EmptyArticlePublishedAt"))
.font(.system(size: 8,
weight: .semibold))
Text(article.publishedAt?.toRelative ?? String(localized: "EmptyArticlePublishedAt"))
.font(.system(size: 8,
weight: .semibold))

Spacer()
Spacer()

Text((translationSessionConfiguration == nil ? article.title : article.titleTranslation) ?? String(localized: "EmptyArticleTitle"))
.font(.system(.subheadline,
weight: .semibold))
.lineLimit(2)
}
.multilineTextAlignment(.leading)
Text((article.titleTranslated ?? article.title) ?? String(localized: "EmptyArticleTitle"))
.font(.system(.subheadline,
weight: .semibold))
.lineLimit(2)
}
.multilineTextAlignment(.leading)

Spacer()
Spacer()

Group {
if let urlToImage = article.urlToImage {
AsyncImage(url: urlToImage,
transaction: .init(animation: .easeIn(duration: 0.75)))
{ asyncImagePhase in
if let image = asyncImagePhase.image {
image
.resizable()
.scaledToFill()
.frame(width: 80,
height: 80,
alignment: .center)
.clipped()
} else {
ProgressView()
Group {
if let urlToImage = article.urlToImage {
AsyncImage(url: urlToImage,
transaction: Transaction(animation: .easeIn(duration: 0.75)))
{ asyncImagePhase in
if let image = asyncImagePhase.image {
image
.resizable()
.scaledToFill()
.frame(width: 80,
height: 80,
alignment: .center)
.clipped()
.onAppear {
articleImage = image
}
} else {
ProgressView()
}
}
} else {
Image(systemName: "photo.circle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 24)
.foregroundStyle(.gray)
.symbolEffect(.bounce,
options: .nonRepeating)
}
} else {
Image(systemName: "photo.circle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 24)
.foregroundStyle(.gray)
.symbolEffect(.bounce,
options: .nonRepeating)
}
.frame(width: 80,
height: 80)
.background(.bar)
.clipShape(.rect(cornerRadius: 12))
}
.frame(width: 80,
height: 80)
.background(.bar)
.clipShape(.rect(cornerRadius: 12))
}
.padding(.horizontal)
.padding(.vertical, 20)
.contentShape(.rect)
.contextMenu {
if let url = article.url {
ShareLink("Share",
item: url)
.accessibilityIdentifier("ShareLink")
}
if let title = article.title {
Button("Translate",
systemImage: "translate")
{
translationPresentationText = title
showTranslationPresentation = true
}
.accessibilityIdentifier("TranslateButton")
}
}
.translationPresentation(isPresented: $showTranslationPresentation,
text: translationPresentationText)
.translationTask(translationSessionConfiguration) { session in
Task {
if let content = article.content {
article.contentTranslation = try await session.translate(content).targetText
.padding(.horizontal)
.padding(.vertical, 20)
.contentShape(.rect)
.contextMenu {
if let url = article.url {
ShareLink("Share",
item: url)
.accessibilityIdentifier("ShareLink")
}
if let title = article.title {
article.titleTranslation = try await session.translate(title).targetText
Button("Translate",
systemImage: "translate")
{
translationPresentationText = title
showTranslationPresentation = true
}
.accessibilityIdentifier("TranslateButton")
}
}
.translationPresentation(isPresented: $showTranslationPresentation,
text: translationPresentationText)
}
.matchedTransitionSource(id: article.id,
in: animation)
.accessibilityIdentifier("NavigationLink")
}
}

#Preview("ListItem") {
ListItem(article: .constant(PreviewMock.article),
translationSessionConfiguration: nil)
ListItem(article: .constant(PreviewMock.article))
}
70 changes: 57 additions & 13 deletions BobbysNews/Presentation/ContentViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ final class ContentViewModel {

enum StateTopHeadlines {
/// General States
case isLoading, loaded
case isLoading, loaded, isTranslating
/// Empty States
case emptyFetch, emptyRead
case emptyFetch, emptyRead, emptyTranslate
}

struct SettingsTip: Tip {
Expand Down Expand Up @@ -83,17 +83,7 @@ final class ContentViewModel {
var showConfirmationDialog = false
var stateSources: StateSources = .isLoading
var stateTopHeadlines: StateTopHeadlines = .isLoading
var translationBool = false {
willSet {
if newValue, translationSessionConfiguration == nil {
translationSessionConfiguration = TranslationSession.Configuration()
} else {
translationSessionConfiguration = nil
readTopHeadlines()
}
}
}

var translate = false
var translationSessionConfiguration: TranslationSession.Configuration?

// MARK: - Lifecycles
Expand Down Expand Up @@ -161,6 +151,7 @@ final class ContentViewModel {
selectedCountry = ""
stateSources = .emptyRead
stateTopHeadlines = .emptyRead
translate = false
sensoryFeedbackTrigger(feedback: .success)
} catch {
showAlert(error: .reset)
Expand All @@ -171,6 +162,59 @@ final class ContentViewModel {
SettingsTip.show = true
}

func translate(translate: Bool) {
if translate, translationSessionConfiguration == nil {
translationSessionConfiguration = TranslationSession.Configuration()
} else if translate {
translationSessionConfiguration?.invalidate()
} else {
readTopHeadlines()
}
}

@MainActor
func translate(translateSession: TranslationSession) async {
stateTopHeadlines = .isTranslating
var contentRequests: [TranslationSession.Request]? = []
var titleRequests: [TranslationSession.Request]? = []
for (index, article) in articles.enumerated() {
if let content = article.content {
contentRequests?.append(TranslationSession.Request(sourceText: content,
clientIdentifier: "\(index)"))
}
if let title = article.title {
titleRequests?.append(TranslationSession.Request(sourceText: title,
clientIdentifier: "\(index)"))
}
}
do {
if let contentRequests,
!contentRequests.isEmpty
{
for try await response in translateSession.translate(batch: contentRequests) {
guard let index = Int(response.clientIdentifier ?? "") else {
continue
}
articles[index].contentTranslated = response.targetText
}
}
if let titleRequests,
!titleRequests.isEmpty
{
for try await response in translateSession.translate(batch: titleRequests) {
guard let index = Int(response.clientIdentifier ?? "") else {
continue
}
articles[index].titleTranslated = response.targetText
}
}
updateStateTopHeadlines(state: contentRequests?.isEmpty == true && titleRequests?.isEmpty == true ? .emptyTranslate : .loaded)
} catch {
updateStateTopHeadlines(error: error,
state: .emptyTranslate)
}
}

private func configureTipKit() {
try? Tips.configure([.displayFrequency(.immediate),
.datastoreLocation(.groupContainer(identifier: "com.burakerol.BobbysNews"))])
Expand Down
Loading

0 comments on commit 04dc96f

Please sign in to comment.