From d147bdac5f8aa1ebc89e00b8b29056856e0ddfff Mon Sep 17 00:00:00 2001 From: touyou Date: Wed, 9 Apr 2025 22:55:35 +0900 Subject: [PATCH 01/13] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20rename=20LiveTransla?= =?UTF-8?q?tionView=20and=20create=20LiveTranslation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MyLibrary/Sources/AppFeature/AppView.swift | 2 +- .../LiveTranslation.swift | 91 ++++++++++++++ .../LiveTranslationView.swift | 4 +- .../OldLiveTranslationView.swift | 113 ++++++++++++++++++ 4 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift create mode 100644 MyLibrary/Sources/LiveTranslationFeature/OldLiveTranslationView.swift diff --git a/MyLibrary/Sources/AppFeature/AppView.swift b/MyLibrary/Sources/AppFeature/AppView.swift index 591ff2e..63dd798 100644 --- a/MyLibrary/Sources/AppFeature/AppView.swift +++ b/MyLibrary/Sources/AppFeature/AppView.swift @@ -60,7 +60,7 @@ public struct AppView: View { .tabItem { Label(String(localized: "Schedule", bundle: .module), systemImage: "calendar") } - LiveTranslationView() + OldLiveTranslationView() .tabItem { Label(String(localized: "Translation", bundle: .module), systemImage: "text.bubble") } diff --git a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift new file mode 100644 index 0000000..bdad3e4 --- /dev/null +++ b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift @@ -0,0 +1,91 @@ +import ComposableArchitecture +import SwiftUI +import Foundation +import LiveTranslationSDK_iOS + +@Reducer +public struct LiveTranslation { + @ObservableState + public struct State: Equatable { + var roomNumber: String + var chatList: [TranslationEntity.CompositeChatItem] = [] + var langSet: LanguageEntity.Response.LangSet? = .none + var langList: [LanguageEntity.Response.LanguageItem] = [] + var roomInfo: ChatRoomEntity.Make.Response? = .none + var selectedLangCode: String = + Locale.autoupdatingCurrent.language.languageCode?.identifier ?? "en" + + var isUpdatingChat: Bool = false + var isUpdatingTR: Bool = false + var updateChatWaitingQueue: [RealTimeEntity.Chat.Response] = [] + var updateTrWaitingQueue: [RealTimeEntity.Translation.Response] = [] + var latestListType: RealTimeEntity.ListType? = .none + + public init(roomNumber: String) { + self.roomNumber = roomNumber + } + } + + public enum Action: BindableAction, ViewAction { + case binding(BindingAction) + case connectChatStream + case changeLangCode(String) + case view(View) + + public enum View { + case onAppear + } + } + + public init() {} + + public var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .view(.onAppear): + return .run { send in + await withTaskGroup(of: Void.self) { group in + group.addTask { +// TODO: loadLangSet + } + group.addTask { +// TODO: loadLangList + } + group.addTask { +// TODO: loadChatRoomInfo + } + } + } + case .connectChatStream: + return .none + case .changeLangCode(let newLangCode): + return .none + case .binding: + return .none + } + } + } +} + +@ViewAction(for: LiveTranslation.self) +public struct LiveTranslationView: View { + + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + NavigationStack { + VStack { + ScrollViewReader { proxy in + ScrollView { + + } + } + } + } + } +} diff --git a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslationView.swift b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslationView.swift index b742dbb..fd1c1f9 100644 --- a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslationView.swift +++ b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslationView.swift @@ -1,7 +1,7 @@ import LiveTranslationSDK_iOS import SwiftUI -public struct LiveTranslationView: View { +public struct OldLiveTranslationView: View { let viewModel: ViewModel @State var isSelectedLanguageSheet: Bool = false @State var isShowingLastChat: Bool = false @@ -109,5 +109,5 @@ public struct LiveTranslationView: View { } #Preview { - LiveTranslationView(roomNumber: "490294") + OldLiveTranslationView(roomNumber: "490294") } diff --git a/MyLibrary/Sources/LiveTranslationFeature/OldLiveTranslationView.swift b/MyLibrary/Sources/LiveTranslationFeature/OldLiveTranslationView.swift new file mode 100644 index 0000000..fd1c1f9 --- /dev/null +++ b/MyLibrary/Sources/LiveTranslationFeature/OldLiveTranslationView.swift @@ -0,0 +1,113 @@ +import LiveTranslationSDK_iOS +import SwiftUI + +public struct OldLiveTranslationView: View { + let viewModel: ViewModel + @State var isSelectedLanguageSheet: Bool = false + @State var isShowingLastChat: Bool = false + + private let scrollContentBottomID: String = "atBottom" + + public init( + roomNumber: String = ProcessInfo.processInfo.environment["LIVE_TRANSLATION_KEY"] + ?? (Bundle.main.infoDictionary?["Live translation room number"] as? String) ?? "" + ) { + print(roomNumber) + self.viewModel = ViewModel(roomNumber: roomNumber) + } + + public var body: some View { + NavigationStack { + VStack { + ScrollViewReader { reader in + ScrollView { + if self.viewModel.roomNumber.isEmpty { + ContentUnavailableView("Room is unavailable", systemImage: "text.page.slash.fill") + Spacer() + } else if viewModel.chatList.isEmpty { + ContentUnavailableView("Not started yet", systemImage: "text.page.slash.fill") + Spacer() + } else { + LazyVStack { + ForEach(viewModel.chatList) { item in + Text(item.trItem?.content ?? item.item.text) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + .padding() + .onAppear { + guard item == viewModel.chatList.last else { return } + isShowingLastChat = true + } + .onDisappear { + guard item == viewModel.chatList.last else { return } + isShowingLastChat = false + } + } + } + } + + HStack { + Spacer() + Text("Powered by", bundle: .module) + .font(.caption) + .foregroundStyle(Color(.secondaryLabel)) + Image(.flitto) + .resizable() + .offset(x: -10) + .aspectRatio(contentMode: .fit) + .frame(maxHeight: 30) + Spacer() + } + .id(scrollContentBottomID) + .padding(.bottom, 16) + } + .onChange(of: viewModel.chatList.last) { old, new in + guard old != .none else { + reader.scrollTo(scrollContentBottomID, anchor: .bottom) + return + } + + guard isShowingLastChat else { return } + + guard new != .none else { return } + withAnimation(.interactiveSpring) { + reader.scrollTo(scrollContentBottomID, anchor: .center) + } + } + } + } + .task { + viewModel.send(.onAppearedPage) + viewModel.send(.connectChatStream) + } + .navigationTitle(Text("Live translation", bundle: .module)) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + isSelectedLanguageSheet.toggle() + } label: { + let selectedLanguage = + viewModel.langSet?.langCodingKey(viewModel.selectedLangCode) ?? "" + Text(selectedLanguage) + Image(systemName: "globe") + } + .sheet(isPresented: $isSelectedLanguageSheet) { + SelectLanguageSheet( + languageList: viewModel.langList, + langSet: viewModel.langSet, + selectedLanguageAction: { langCode in + viewModel.send(.changeLangCode(langCode)) + isSelectedLanguageSheet = false + } + ) + .presentationDetents([.medium, .large]) + } + } + } + } + } +} + +#Preview { + OldLiveTranslationView(roomNumber: "490294") +} From c93c97229387c983d499b099bb9d4ecccb7c66c0 Mon Sep 17 00:00:00 2001 From: touyou Date: Thu, 10 Apr 2025 00:27:46 +0900 Subject: [PATCH 02/13] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20almost=20all=20imple?= =?UTF-8?q?mentation=20without=20handleResponseChat=20and=20handleResponse?= =?UTF-8?q?Translation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LiveTranslation.swift | 255 +++++++++++++++++- .../LiveTranslationServiceClient.swift | 49 ++++ .../LiveTranslationFeature/ViewModel.swift | 2 +- 3 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 MyLibrary/Sources/LiveTranslationFeature/LiveTranslationServiceClient.swift diff --git a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift index bdad3e4..41b3839 100644 --- a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift +++ b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift @@ -13,8 +13,8 @@ public struct LiveTranslation { var langList: [LanguageEntity.Response.LanguageItem] = [] var roomInfo: ChatRoomEntity.Make.Response? = .none var selectedLangCode: String = - Locale.autoupdatingCurrent.language.languageCode?.identifier ?? "en" - + Locale.autoupdatingCurrent.language.languageCode?.identifier ?? "en" + var isUpdatingChat: Bool = false var isUpdatingTR: Bool = false var updateChatWaitingQueue: [RealTimeEntity.Chat.Response] = [] @@ -32,11 +32,20 @@ public struct LiveTranslation { case changeLangCode(String) case view(View) + case setLangSet(LanguageEntity.Response.LangSet) + case setLangList([LanguageEntity.Response.LanguageItem]) + case setRoomInfo(ChatRoomEntity.Make.Response) + + case handleResponseChat(RealTimeEntity.Chat.Response) + case handleResponseTranslation(RealTimeEntity.Translation.Response) + public enum View { case onAppear } } + @Dependency(\.liveTranslationServiceClient) var liveTranslationServiceClient + public init() {} public var body: some ReducerOf { @@ -44,22 +53,72 @@ public struct LiveTranslation { Reduce { state, action in switch action { case .view(.onAppear): - return .run { send in + return .run { [state] send in await withTaskGroup(of: Void.self) { group in group.addTask { -// TODO: loadLangSet + await loadLangSet(send: send) } group.addTask { -// TODO: loadLangList + do { + let langList = try await liveTranslationServiceClient.langList() + await send( + .setLangList(langList) + ) + } catch { + print(error) + } } group.addTask { -// TODO: loadChatRoomInfo + do { + let roomInfo = try await liveTranslationServiceClient.chatRoomInfo(state.roomNumber) + await send( + .setRoomInfo(roomInfo) + ) + } catch { + print(error) + } } } } case .connectChatStream: - return .none + return .run { [state] send in + do { + let stream = liveTranslationServiceClient.chatConnection(state.roomNumber) + for try await action in stream { + switch action { + case .connect: break + case .disconnect: break + case .peerClosed: + await send(.connectChatStream) + case .responseChat(let chatItem): + await send(.handleResponseChat(chatItem)) + case .responseBatchTranslation(let trItem): + await send(.handleResponseTranslation(trItem)) + default: break + } + } + } catch { + print(error) + } + } case .changeLangCode(let newLangCode): + state.selectedLangCode = newLangCode + return .run { [state] send in + await loadLangSet(langCode: newLangCode, send: send) + await loadTranslation(chatList: state.chatList, newLangCode) + } + case .setLangSet(let langSet): + state.langSet = langSet + return .none + case .setLangList(let langList): + state.langList = langList + return .none + case .setRoomInfo(let roomInfo): + state.roomInfo = roomInfo + return .none + case .handleResponseChat(let chatItem): + return .none + case .handleResponseTranslation(let trItem): return .none case .binding: return .none @@ -68,6 +127,41 @@ public struct LiveTranslation { } } +extension LiveTranslation { + private func loadLangSet(langCode: String? = nil, send: Send) async { + do { + let langSet = try await liveTranslationServiceClient.langSet(langCode) + await send( + .setLangSet(langSet) + ) + } catch { + print(error) + } + } + + private func loadTranslation( + chatList: [TranslationEntity.CompositeChatItem], _ dstLangCode: String + ) async { + await withTaskGroup(of: Void.self) { group in + let chunkedArray = chatList.chunked(into: 20) + for array in chunkedArray { + group.addTask { + let mutatedArray = array.map { + RealTimeEntity.Translation.Request.ContentData( + chatRoomID: $0.item.chatRoomID, + chatID: $0.id, + srcLangCode: $0.item.srcLangCode, + dstLangCode: dstLangCode, + timestamp: $0.item.timestamp, + text: $0.item.textForTR) + } + await liveTranslationServiceClient.requestBatchTranslation(mutatedArray) + } + } + } + } +} + @ViewAction(for: LiveTranslation.self) public struct LiveTranslationView: View { @@ -89,3 +183,150 @@ public struct LiveTranslationView: View { } } } + +extension [TranslationEntity.CompositeChatItem] { + fileprivate func merge(item: RealTimeEntity.Chat.Response, dstLangCode: String) async + -> [TranslationEntity.CompositeChatItem] + { + await withCheckedContinuation { continuation in + switch item.contentData.listType { + case .append: + var mutableSelf = self + for newItem in item.contentData.chatList { + if let lastIdx = mutableSelf.lastIndex(where: { $0.id == newItem.id }) { + mutableSelf.remove(at: lastIdx) + } + + guard !(newItem.textForTR.isEmpty || newItem.text.isEmpty) else { continue } + mutableSelf.append( + .init(item: newItem, trItem: .none, ttsData: .none, dstLangCode: dstLangCode)) + } + + return continuation.resume(returning: mutableSelf.suffix(100)) + + case .realtime: + var mutableSelf = self + for newItem in item.contentData.chatList { + if let lastIdx = mutableSelf.lastIndex(where: { $0.id == newItem.id }) { + mutableSelf.remove(at: lastIdx) + } + + mutableSelf.append( + .init(item: newItem, trItem: .none, ttsData: .none, dstLangCode: dstLangCode)) + } + return continuation.resume(returning: mutableSelf) + + case .renew: + let newArr: [TranslationEntity.CompositeChatItem] = item.contentData.chatList.reduce([]) { + current, next in + guard !next.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return current + } + let first = self.first(where: { $0.item.id == next.id }) + let new: TranslationEntity.CompositeChatItem = .init( + item: next, trItem: first?.trItem, ttsData: first?.ttsData, dstLangCode: dstLangCode) + + return current + [new] + } + + return continuation.resume(returning: newArr.suffix(100)) + + case .update: + let newArr = item.contentData.chatList.reduce(self) { current, next in + // If the update target is included in the current chat list (when modifying a chat with non-empty value) + if let idx = current.firstIndex(where: { $0.item.chatID == next.chatID }) { + var variableCurrent = current + + // If modified to empty value, delete the chat from the chat list + if next.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + variableCurrent.remove(at: idx) + return variableCurrent + } else { + variableCurrent[idx] = .init( + item: next, + trItem: variableCurrent[idx].trItem, + ttsData: .none, + dstLangCode: dstLangCode) + return variableCurrent + } + // If the update target is not included in the current chat list (when modifying an empty chat) + } else if let willAppendIndex = current.firstIndex(where: { + $0.item.timestamp > next.timestamp + }) { + guard !next.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return current + } + var variableCurrent = current + variableCurrent.insert( + .init(item: next, trItem: .none, ttsData: .none, dstLangCode: dstLangCode), + at: willAppendIndex) + return variableCurrent + } else { + return current + } + } + + return continuation.resume(returning: newArr.suffix(100)) + + default: + return continuation.resume(returning: self) + } + } + } + + fileprivate func updateTranslation(item: RealTimeEntity.Translation.Response) async + -> [TranslationEntity.CompositeChatItem] + { + await withCheckedContinuation { continuation in + guard + let firstIndex = self.firstIndex(where: { + $0.id == item.contentData.chatList.first?.chatID + } + ) + else { + return continuation.resume(returning: self) + } + + let range = firstIndex..<(firstIndex + item.contentData.chatList.count) + var mutatedArray: [TranslationEntity.CompositeChatItem] = [] + + for index in range { + guard let trItem = item.contentData.chatList[safe: mutatedArray.count] else { break } + guard let newItem = self[safe: index]?.setTranslation(trItem: trItem) else { break } + + mutatedArray.append(newItem) + } + + var mutateSelf = self + mutateSelf.replaceSubrange(range, with: mutatedArray) + + return continuation.resume(returning: mutateSelf) + } + } +} + +extension TranslationEntity.CompositeChatItem { + fileprivate func setTranslation(trItem: TranslationEntity.TR.Response) -> Self { + .init( + item: item, + trItem: trItem, + ttsData: .none, + dstLangCode: trItem.dstLangCode) + } +} + +extension Collection { + fileprivate subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} + +extension Array { + fileprivate func chunked(into size: Int) -> [[Element]] { + guard size > .zero else { return [self] } + return stride(from: 0, to: count, by: size).map { startIndex in + let endIndex = index(startIndex, offsetBy: size, limitedBy: count) ?? endIndex + return Array(self[startIndex.. LanguageEntity.Response.LangSet + public var langList: @Sendable () async throws -> [LanguageEntity.Response.LanguageItem] + public var chatRoomInfo: @Sendable (String) async throws -> ChatRoomEntity.Make.Response + public var chatConnection: @Sendable (String) -> AsyncThrowingStream = { _ in .never } + public var requestBatchTranslation: @Sendable ([RealTimeEntity.Translation.Request.ContentData]) async -> Void +} + +extension LiveTranslationServiceClient: DependencyKey { + static func live() -> Self { + let service = LiveTranslationService() + return Self( + langSet: { langCode in + try await service.getLangSet(.init(langCode: langCode ?? LanguageCodeFunctor.deviceCode)) + }, + langList: { + try await service.getLangList() + }, + chatRoomInfo: { roomNumber in + try await service.getChatRoomInfo(.init(interactionKey: roomNumber)) + }, + chatConnection: { roomNumber in + service.chatConnection(.init(interactionKey: roomNumber)) + }, + requestBatchTranslation: { array in + await service.requestBatchTranslation(.init(data: array)) + } + ) + } + + public static var liveValue: Self = .live() +} diff --git a/MyLibrary/Sources/LiveTranslationFeature/ViewModel.swift b/MyLibrary/Sources/LiveTranslationFeature/ViewModel.swift index bc26f22..4757b09 100644 --- a/MyLibrary/Sources/LiveTranslationFeature/ViewModel.swift +++ b/MyLibrary/Sources/LiveTranslationFeature/ViewModel.swift @@ -336,7 +336,7 @@ extension Collection { } extension Array { - func chunked(into size: Int) -> [[Element]] { + fileprivate func chunked(into size: Int) -> [[Element]] { guard size > .zero else { return [self] } return stride(from: 0, to: count, by: size).map { startIndex in let endIndex = index(startIndex, offsetBy: size, limitedBy: count) ?? endIndex From c4de342cdd8151975a979077432503060834421c Mon Sep 17 00:00:00 2001 From: touyou Date: Thu, 10 Apr 2025 11:36:12 +0900 Subject: [PATCH 03/13] =?UTF-8?q?=F0=9F=8E=89=20complete=20state=20and=20a?= =?UTF-8?q?ction=20reducer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LiveTranslation.swift | 180 +++++++++++++----- .../LiveTranslationView.swift | 113 ----------- 2 files changed, 135 insertions(+), 158 deletions(-) delete mode 100644 MyLibrary/Sources/LiveTranslationFeature/LiveTranslationView.swift diff --git a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift index 41b3839..32045c8 100644 --- a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift +++ b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift @@ -7,20 +7,36 @@ import LiveTranslationSDK_iOS public struct LiveTranslation { @ObservableState public struct State: Equatable { + /// Live Translation Room Number var roomNumber: String + /// Current visible translation items var chatList: [TranslationEntity.CompositeChatItem] = [] + /// Current language set var langSet: LanguageEntity.Response.LangSet? = .none + /// Available language list var langList: [LanguageEntity.Response.LanguageItem] = [] + /// Live Translation Room Info var roomInfo: ChatRoomEntity.Make.Response? = .none + /// Current language code which user selected var selectedLangCode: String = Locale.autoupdatingCurrent.language.languageCode?.identifier ?? "en" + /// While updating chat var isUpdatingChat: Bool = false + /// While updating translation response var isUpdatingTR: Bool = false + /// Chat updating request queue var updateChatWaitingQueue: [RealTimeEntity.Chat.Response] = [] + /// Translation response request queue var updateTrWaitingQueue: [RealTimeEntity.Translation.Response] = [] + /// Latest item's list type var latestListType: RealTimeEntity.ListType? = .none + /// Streaming is connected + var isConnected: Bool = false + /// The task of connecting stream + var chatStreamTask: Task? = nil + public init(roomNumber: String) { self.roomNumber = roomNumber } @@ -29,13 +45,10 @@ public struct LiveTranslation { public enum Action: BindableAction, ViewAction { case binding(BindingAction) case connectChatStream + case disconnectChatStream case changeLangCode(String) case view(View) - case setLangSet(LanguageEntity.Response.LangSet) - case setLangList([LanguageEntity.Response.LanguageItem]) - case setRoomInfo(ChatRoomEntity.Make.Response) - case handleResponseChat(RealTimeEntity.Chat.Response) case handleResponseTranslation(RealTimeEntity.Translation.Response) @@ -62,21 +75,28 @@ public struct LiveTranslation { do { let langList = try await liveTranslationServiceClient.langList() await send( - .setLangList(langList) + .set(\.langList, langList) ) } catch { print(error) } } group.addTask { - do { - let roomInfo = try await liveTranslationServiceClient.chatRoomInfo(state.roomNumber) - await send( - .setRoomInfo(roomInfo) + await send( + .set( + \.chatStreamTask, + Task { + do { + let roomInfo = try await liveTranslationServiceClient.chatRoomInfo(state.roomNumber) + await send( + .set(\.roomInfo, roomInfo) + ) + } catch { + print(error) + } + } ) - } catch { - print(error) - } + ) } } } @@ -86,8 +106,12 @@ public struct LiveTranslation { let stream = liveTranslationServiceClient.chatConnection(state.roomNumber) for try await action in stream { switch action { - case .connect: break - case .disconnect: break + case .connect: + await send(.set(\.isConnected, true)) + break + case .disconnect: + await send(.set(\.isConnected, false)) + break case .peerClosed: await send(.connectChatStream) case .responseChat(let chatItem): @@ -101,25 +125,23 @@ public struct LiveTranslation { print(error) } } + case .disconnectChatStream: + state.chatStreamTask?.cancel() + return .none case .changeLangCode(let newLangCode): state.selectedLangCode = newLangCode return .run { [state] send in await loadLangSet(langCode: newLangCode, send: send) await loadTranslation(chatList: state.chatList, newLangCode) } - case .setLangSet(let langSet): - state.langSet = langSet - return .none - case .setLangList(let langList): - state.langList = langList - return .none - case .setRoomInfo(let roomInfo): - state.roomInfo = roomInfo - return .none case .handleResponseChat(let chatItem): - return .none + return .run { [state] send in + await handleResponseChat(chatItem, state: state, send: send) + } case .handleResponseTranslation(let trItem): - return .none + return .run { [state] send in + await handleResponseTranslation(trItem, state: state, send: send) + } case .binding: return .none } @@ -132,7 +154,7 @@ extension LiveTranslation { do { let langSet = try await liveTranslationServiceClient.langSet(langCode) await send( - .setLangSet(langSet) + .set(\.langSet, langSet) ) } catch { print(error) @@ -160,6 +182,74 @@ extension LiveTranslation { } } } + + /// Handle chat item response + private func handleResponseChat(_ chatItem: RealTimeEntity.Chat.Response, state: State, send: Send) async { + guard !state.isUpdatingChat else { + await send( + .set(\.updateChatWaitingQueue, state.updateChatWaitingQueue + [chatItem]) + ) + return + } + // NOTE: Updating chat list + await send(.set(\.isUpdatingChat, true)) + await send(.set(\.latestListType, chatItem.contentData.listType)) + let newChatList = await state.chatList.merge(item: chatItem, dstLangCode: state.selectedLangCode) + await send(.set(\.chatList, newChatList)) + await send(.set(\.isUpdatingChat, false)) + + switch chatItem.contentData.listType { + case .update: + let updateTargetList = chatItem.contentData.chatList.reduce( + [TranslationEntity.CompositeChatItem]() + ) { current, next in + guard let firstIndex = newChatList.firstIndex(where: { $0.id == next.id }) else { + return current + } + return current + [newChatList[firstIndex]] + } + await loadTranslation(chatList: updateTargetList, state.selectedLangCode) + + case .append: + guard let lastItem = newChatList.last else { return } + await loadTranslation(chatList: [lastItem], state.selectedLangCode) + + case .realtime: break + + default: + await loadTranslation(chatList: state.chatList, state.selectedLangCode) + } + + await checkUpdateChatWaitingQueue(state: state, send: send) + } + + /// Check chat item wating queue + private func checkUpdateChatWaitingQueue(state: State, send: Send) async { + guard let task = state.updateChatWaitingQueue.first else { return } + await send(.set(\.updateChatWaitingQueue, state.updateChatWaitingQueue.dropFirst().map { $0 })) + await handleResponseChat(task, state: state, send: send) + } + + private func handleResponseTranslation(_ trItem: RealTimeEntity.Translation.Response, state: State, send: Send) async { + guard !state.isUpdatingTR else { + await send(.set(\.updateTrWaitingQueue, state.updateTrWaitingQueue + [trItem])) + return + } + + await send(.set(\.isUpdatingTR, true)) + await send(.set(\.latestListType, trItem.contentData.listType)) + let newChatList = await state.chatList.updateTranslation(item: trItem) + await send(.set(\.chatList, newChatList)) + await send(.set(\.isUpdatingTR, false)) + + await checkUpdateTRWaitingQueue(state: state, send: send) + } + + private func checkUpdateTRWaitingQueue(state: State, send: Send) async { + guard let task = state.updateTrWaitingQueue.first else { return } + await send(.set(\.updateTrWaitingQueue, state.updateTrWaitingQueue.dropFirst().map { $0 })) + await handleResponseTranslation(task, state: state, send: send) + } } @ViewAction(for: LiveTranslation.self) @@ -186,7 +276,7 @@ public struct LiveTranslationView: View { extension [TranslationEntity.CompositeChatItem] { fileprivate func merge(item: RealTimeEntity.Chat.Response, dstLangCode: String) async - -> [TranslationEntity.CompositeChatItem] + -> [TranslationEntity.CompositeChatItem] { await withCheckedContinuation { continuation in switch item.contentData.listType { @@ -196,26 +286,26 @@ extension [TranslationEntity.CompositeChatItem] { if let lastIdx = mutableSelf.lastIndex(where: { $0.id == newItem.id }) { mutableSelf.remove(at: lastIdx) } - + guard !(newItem.textForTR.isEmpty || newItem.text.isEmpty) else { continue } mutableSelf.append( .init(item: newItem, trItem: .none, ttsData: .none, dstLangCode: dstLangCode)) } - + return continuation.resume(returning: mutableSelf.suffix(100)) - + case .realtime: var mutableSelf = self for newItem in item.contentData.chatList { if let lastIdx = mutableSelf.lastIndex(where: { $0.id == newItem.id }) { mutableSelf.remove(at: lastIdx) } - + mutableSelf.append( .init(item: newItem, trItem: .none, ttsData: .none, dstLangCode: dstLangCode)) } return continuation.resume(returning: mutableSelf) - + case .renew: let newArr: [TranslationEntity.CompositeChatItem] = item.contentData.chatList.reduce([]) { current, next in @@ -225,18 +315,18 @@ extension [TranslationEntity.CompositeChatItem] { let first = self.first(where: { $0.item.id == next.id }) let new: TranslationEntity.CompositeChatItem = .init( item: next, trItem: first?.trItem, ttsData: first?.ttsData, dstLangCode: dstLangCode) - + return current + [new] } - + return continuation.resume(returning: newArr.suffix(100)) - + case .update: let newArr = item.contentData.chatList.reduce(self) { current, next in // If the update target is included in the current chat list (when modifying a chat with non-empty value) if let idx = current.firstIndex(where: { $0.item.chatID == next.chatID }) { var variableCurrent = current - + // If modified to empty value, delete the chat from the chat list if next.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { variableCurrent.remove(at: idx) @@ -265,17 +355,17 @@ extension [TranslationEntity.CompositeChatItem] { return current } } - + return continuation.resume(returning: newArr.suffix(100)) - + default: return continuation.resume(returning: self) } } } - + fileprivate func updateTranslation(item: RealTimeEntity.Translation.Response) async - -> [TranslationEntity.CompositeChatItem] + -> [TranslationEntity.CompositeChatItem] { await withCheckedContinuation { continuation in guard @@ -286,20 +376,20 @@ extension [TranslationEntity.CompositeChatItem] { else { return continuation.resume(returning: self) } - + let range = firstIndex..<(firstIndex + item.contentData.chatList.count) var mutatedArray: [TranslationEntity.CompositeChatItem] = [] - + for index in range { guard let trItem = item.contentData.chatList[safe: mutatedArray.count] else { break } guard let newItem = self[safe: index]?.setTranslation(trItem: trItem) else { break } - + mutatedArray.append(newItem) } - + var mutateSelf = self mutateSelf.replaceSubrange(range, with: mutatedArray) - + return continuation.resume(returning: mutateSelf) } } diff --git a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslationView.swift b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslationView.swift deleted file mode 100644 index fd1c1f9..0000000 --- a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslationView.swift +++ /dev/null @@ -1,113 +0,0 @@ -import LiveTranslationSDK_iOS -import SwiftUI - -public struct OldLiveTranslationView: View { - let viewModel: ViewModel - @State var isSelectedLanguageSheet: Bool = false - @State var isShowingLastChat: Bool = false - - private let scrollContentBottomID: String = "atBottom" - - public init( - roomNumber: String = ProcessInfo.processInfo.environment["LIVE_TRANSLATION_KEY"] - ?? (Bundle.main.infoDictionary?["Live translation room number"] as? String) ?? "" - ) { - print(roomNumber) - self.viewModel = ViewModel(roomNumber: roomNumber) - } - - public var body: some View { - NavigationStack { - VStack { - ScrollViewReader { reader in - ScrollView { - if self.viewModel.roomNumber.isEmpty { - ContentUnavailableView("Room is unavailable", systemImage: "text.page.slash.fill") - Spacer() - } else if viewModel.chatList.isEmpty { - ContentUnavailableView("Not started yet", systemImage: "text.page.slash.fill") - Spacer() - } else { - LazyVStack { - ForEach(viewModel.chatList) { item in - Text(item.trItem?.content ?? item.item.text) - .frame(maxWidth: .infinity, alignment: .leading) - .multilineTextAlignment(.leading) - .padding() - .onAppear { - guard item == viewModel.chatList.last else { return } - isShowingLastChat = true - } - .onDisappear { - guard item == viewModel.chatList.last else { return } - isShowingLastChat = false - } - } - } - } - - HStack { - Spacer() - Text("Powered by", bundle: .module) - .font(.caption) - .foregroundStyle(Color(.secondaryLabel)) - Image(.flitto) - .resizable() - .offset(x: -10) - .aspectRatio(contentMode: .fit) - .frame(maxHeight: 30) - Spacer() - } - .id(scrollContentBottomID) - .padding(.bottom, 16) - } - .onChange(of: viewModel.chatList.last) { old, new in - guard old != .none else { - reader.scrollTo(scrollContentBottomID, anchor: .bottom) - return - } - - guard isShowingLastChat else { return } - - guard new != .none else { return } - withAnimation(.interactiveSpring) { - reader.scrollTo(scrollContentBottomID, anchor: .center) - } - } - } - } - .task { - viewModel.send(.onAppearedPage) - viewModel.send(.connectChatStream) - } - .navigationTitle(Text("Live translation", bundle: .module)) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - isSelectedLanguageSheet.toggle() - } label: { - let selectedLanguage = - viewModel.langSet?.langCodingKey(viewModel.selectedLangCode) ?? "" - Text(selectedLanguage) - Image(systemName: "globe") - } - .sheet(isPresented: $isSelectedLanguageSheet) { - SelectLanguageSheet( - languageList: viewModel.langList, - langSet: viewModel.langSet, - selectedLanguageAction: { langCode in - viewModel.send(.changeLangCode(langCode)) - isSelectedLanguageSheet = false - } - ) - .presentationDetents([.medium, .large]) - } - } - } - } - } -} - -#Preview { - OldLiveTranslationView(roomNumber: "490294") -} From 9b4bcc07cba91f624d29ed99b155eab382ee6edd Mon Sep 17 00:00:00 2001 From: touyou Date: Thu, 10 Apr 2025 13:51:14 +0900 Subject: [PATCH 04/13] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20try=20to=20replace?= =?UTF-8?q?=20TCA=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MyLibrary/Sources/AppFeature/AppView.swift | 22 +++- .../LiveTranslation.swift | 121 ++++++++++++++++++ 2 files changed, 136 insertions(+), 7 deletions(-) diff --git a/MyLibrary/Sources/AppFeature/AppView.swift b/MyLibrary/Sources/AppFeature/AppView.swift index 63dd798..58d0421 100644 --- a/MyLibrary/Sources/AppFeature/AppView.swift +++ b/MyLibrary/Sources/AppFeature/AppView.swift @@ -13,28 +13,36 @@ public struct AppReducer { @ObservableState public struct State: Equatable { var schedule = Schedule.State() + var liveTranslation = LiveTranslation.State( + roomNumber: ProcessInfo.processInfo.environment["LIVE_TRANSLATION_KEY"] + ?? (Bundle.main.infoDictionary?["Live translation room number"] as? String) ?? "" + ) var guidance = Guidance.State() var sponsors = SponsorsList.State() var trySwift = TrySwift.State() - + public init() { try? Tips.configure([.displayFrequency(.immediate)]) } } - + public enum Action { case schedule(Schedule.Action) + case liveTranslation(LiveTranslation.Action) case guidance(Guidance.Action) case sponsors(SponsorsList.Action) case trySwift(TrySwift.Action) } - + public init() {} - + public var body: some ReducerOf { Scope(state: \.schedule, action: \.schedule) { Schedule() } + Scope(state: \.liveTranslation, action: \.liveTranslation) { + LiveTranslation() + } Scope(state: \.guidance, action: \.guidance) { Guidance() } @@ -49,18 +57,18 @@ public struct AppReducer { public struct AppView: View { var store: StoreOf - + public init(store: StoreOf) { self.store = store } - + public var body: some View { TabView { ScheduleView(store: store.scope(state: \.schedule, action: \.schedule)) .tabItem { Label(String(localized: "Schedule", bundle: .module), systemImage: "calendar") } - OldLiveTranslationView() + LiveTranslationView(store: store.scope(state: \.liveTranslation, action: \.liveTranslation)) .tabItem { Label(String(localized: "Translation", bundle: .module), systemImage: "text.bubble") } diff --git a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift index 32045c8..8a202de 100644 --- a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift +++ b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift @@ -37,6 +37,11 @@ public struct LiveTranslation { /// The task of connecting stream var chatStreamTask: Task? = nil + /// selected language sheet + var isSelectedLanguageSheet: Bool = false + /// showing last chat + var isShowingLastChat: Bool = false + public init(roomNumber: String) { self.roomNumber = roomNumber } @@ -54,6 +59,10 @@ public struct LiveTranslation { public enum View { case onAppear + case connectStream + case selectLangCode(String) + case setSelectedLanguageSheet(Bool) + case setShowingLastChat(Bool) } } @@ -100,6 +109,20 @@ public struct LiveTranslation { } } } + case .view(.connectStream): + return .run { send in + await send(.connectChatStream) + } + case .view(.selectLangCode(let langCode)): + return .run { send in + await send(.changeLangCode(langCode)) + } + case .view(.setSelectedLanguageSheet(let flag)): + state.isSelectedLanguageSheet = flag + return .none + case .view(.setShowingLastChat(let flag)): + state.isShowingLastChat = flag + return .none case .connectChatStream: return .run { [state] send in do { @@ -230,6 +253,7 @@ extension LiveTranslation { await handleResponseChat(task, state: state, send: send) } + /// Handle translation item response private func handleResponseTranslation(_ trItem: RealTimeEntity.Translation.Response, state: State, send: Send) async { guard !state.isUpdatingTR else { await send(.set(\.updateTrWaitingQueue, state.updateTrWaitingQueue + [trItem])) @@ -245,6 +269,7 @@ extension LiveTranslation { await checkUpdateTRWaitingQueue(state: state, send: send) } + /// Check translation item waiting queue private func checkUpdateTRWaitingQueue(state: State, send: Send) async { guard let task = state.updateTrWaitingQueue.first else { return } await send(.set(\.updateTrWaitingQueue, state.updateTrWaitingQueue.dropFirst().map { $0 })) @@ -257,6 +282,8 @@ public struct LiveTranslationView: View { @Bindable public var store: StoreOf + private let scrollContentBottomID: String = "atBottom" + public init(store: StoreOf) { self.store = store } @@ -266,12 +293,100 @@ public struct LiveTranslationView: View { VStack { ScrollViewReader { proxy in ScrollView { + if store.roomNumber.isEmpty { + ContentUnavailableView("Room is unavailable", systemImage: "text.page.slash.fill") + Spacer() + } else if store.chatList.isEmpty { + ContentUnavailableView("Not started yet", systemImage: "text.page.slash.fill") + Spacer() + } else { + translationContents + } + flittoLogo + .id(scrollContentBottomID) + .padding(.bottom, 16) + } + .onChange(of: store.chatList.last) { old, new in + guard old != .none else { + proxy.scrollTo(scrollContentBottomID, anchor: .bottom) + return + } + + guard store.isShowingLastChat else { return } + + withAnimation(.interactiveSpring) { + proxy.scrollTo(scrollContentBottomID, anchor: .center) + } + } + } + } + .task { + send(.onAppear) + send(.connectStream) + } + .navigationTitle(Text("Live translation", bundle: .module)) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + send(.setSelectedLanguageSheet(!store.isSelectedLanguageSheet)) + } label: { + let selectedLanguage = + store.langSet?.langCodingKey(store.selectedLangCode) ?? "" + Text(selectedLanguage) + Image(systemName: "globe") + } + .sheet(isPresented: $store.isSelectedLanguageSheet) { + SelectLanguageSheet( + languageList: store.langList, + langSet: store.langSet, + selectedLanguageAction: { langCode in + send(.selectLangCode(langCode)) + send(.setSelectedLanguageSheet(false)) + } + ) + .presentationDetents([.medium, .large]) } } } } } + + @ViewBuilder + var translationContents: some View { + LazyVStack { + ForEach(store.chatList) { item in + Text(item.trItem?.content ?? item.item.text) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + .padding() + .onAppear { + guard item == store.chatList.last else { return } + send(.setShowingLastChat(true)) + } + .onDisappear { + guard item == store.chatList.last else { return } + send(.setShowingLastChat(false)) + } + } + } + } + + @ViewBuilder + var flittoLogo: some View { + HStack { + Spacer() + Text("Powered by", bundle: .module) + .font(.caption) + .foregroundStyle(Color(.secondaryLabel)) + Image(.flitto) + .resizable() + .offset(x: -10) + .aspectRatio(contentMode: .fit) + .frame(maxHeight: 30) + Spacer() + } + } } extension [TranslationEntity.CompositeChatItem] { @@ -420,3 +535,9 @@ extension Array { } } } + +#Preview { + LiveTranslationView(store: .init(initialState: .init(roomNumber: "490294")) { + LiveTranslation() + }) +} From 3bef97c4c8b90730bfa84e2d1be07582f6e87faa Mon Sep 17 00:00:00 2001 From: touyou Date: Thu, 10 Apr 2025 14:02:02 +0900 Subject: [PATCH 05/13] =?UTF-8?q?=F0=9F=9A=A7=20remove=20old=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LiveTranslation.swift | 71 ++-- .../OldLiveTranslationView.swift | 113 ------ .../LiveTranslationFeature/ViewModel.swift | 346 ------------------ 3 files changed, 36 insertions(+), 494 deletions(-) delete mode 100644 MyLibrary/Sources/LiveTranslationFeature/OldLiveTranslationView.swift delete mode 100644 MyLibrary/Sources/LiveTranslationFeature/ViewModel.swift diff --git a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift index 8a202de..b5760ce 100644 --- a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift +++ b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift @@ -91,23 +91,17 @@ public struct LiveTranslation { } } group.addTask { - await send( - .set( - \.chatStreamTask, - Task { - do { - let roomInfo = try await liveTranslationServiceClient.chatRoomInfo(state.roomNumber) - await send( - .set(\.roomInfo, roomInfo) - ) - } catch { - print(error) - } - } + do { + let roomInfo = try await liveTranslationServiceClient.chatRoomInfo(state.roomNumber) + await send( + .set(\.roomInfo, roomInfo) ) - ) + } catch { + print(error) + } } } + await send(.connectChatStream) } case .view(.connectStream): return .run { send in @@ -125,28 +119,36 @@ public struct LiveTranslation { return .none case .connectChatStream: return .run { [state] send in - do { - let stream = liveTranslationServiceClient.chatConnection(state.roomNumber) - for try await action in stream { - switch action { - case .connect: - await send(.set(\.isConnected, true)) - break - case .disconnect: - await send(.set(\.isConnected, false)) - break - case .peerClosed: - await send(.connectChatStream) - case .responseChat(let chatItem): - await send(.handleResponseChat(chatItem)) - case .responseBatchTranslation(let trItem): - await send(.handleResponseTranslation(trItem)) - default: break + let task = Task { + do { + let stream = liveTranslationServiceClient.chatConnection(state.roomNumber) + for try await action in stream { + switch action { + case .connect: + await send(.set(\.isConnected, true)) + break + case .disconnect: + await send(.set(\.isConnected, false)) + break + case .peerClosed: + await send(.connectChatStream) + case .responseChat(let chatItem): + await send(.handleResponseChat(chatItem)) + case .responseBatchTranslation(let trItem): + await send(.handleResponseTranslation(trItem)) + default: break + } } + } catch { + print(error) } - } catch { - print(error) } + await send( + .set( + \.chatStreamTask, + task + ) + ) } case .disconnectChatStream: state.chatStreamTask?.cancel() @@ -323,7 +325,6 @@ public struct LiveTranslationView: View { } .task { send(.onAppear) - send(.connectStream) } .navigationTitle(Text("Live translation", bundle: .module)) .toolbar { @@ -332,7 +333,7 @@ public struct LiveTranslationView: View { send(.setSelectedLanguageSheet(!store.isSelectedLanguageSheet)) } label: { let selectedLanguage = - store.langSet?.langCodingKey(store.selectedLangCode) ?? "" + store.langSet?.langCodingKey(store.selectedLangCode) ?? "" Text(selectedLanguage) Image(systemName: "globe") } diff --git a/MyLibrary/Sources/LiveTranslationFeature/OldLiveTranslationView.swift b/MyLibrary/Sources/LiveTranslationFeature/OldLiveTranslationView.swift deleted file mode 100644 index fd1c1f9..0000000 --- a/MyLibrary/Sources/LiveTranslationFeature/OldLiveTranslationView.swift +++ /dev/null @@ -1,113 +0,0 @@ -import LiveTranslationSDK_iOS -import SwiftUI - -public struct OldLiveTranslationView: View { - let viewModel: ViewModel - @State var isSelectedLanguageSheet: Bool = false - @State var isShowingLastChat: Bool = false - - private let scrollContentBottomID: String = "atBottom" - - public init( - roomNumber: String = ProcessInfo.processInfo.environment["LIVE_TRANSLATION_KEY"] - ?? (Bundle.main.infoDictionary?["Live translation room number"] as? String) ?? "" - ) { - print(roomNumber) - self.viewModel = ViewModel(roomNumber: roomNumber) - } - - public var body: some View { - NavigationStack { - VStack { - ScrollViewReader { reader in - ScrollView { - if self.viewModel.roomNumber.isEmpty { - ContentUnavailableView("Room is unavailable", systemImage: "text.page.slash.fill") - Spacer() - } else if viewModel.chatList.isEmpty { - ContentUnavailableView("Not started yet", systemImage: "text.page.slash.fill") - Spacer() - } else { - LazyVStack { - ForEach(viewModel.chatList) { item in - Text(item.trItem?.content ?? item.item.text) - .frame(maxWidth: .infinity, alignment: .leading) - .multilineTextAlignment(.leading) - .padding() - .onAppear { - guard item == viewModel.chatList.last else { return } - isShowingLastChat = true - } - .onDisappear { - guard item == viewModel.chatList.last else { return } - isShowingLastChat = false - } - } - } - } - - HStack { - Spacer() - Text("Powered by", bundle: .module) - .font(.caption) - .foregroundStyle(Color(.secondaryLabel)) - Image(.flitto) - .resizable() - .offset(x: -10) - .aspectRatio(contentMode: .fit) - .frame(maxHeight: 30) - Spacer() - } - .id(scrollContentBottomID) - .padding(.bottom, 16) - } - .onChange(of: viewModel.chatList.last) { old, new in - guard old != .none else { - reader.scrollTo(scrollContentBottomID, anchor: .bottom) - return - } - - guard isShowingLastChat else { return } - - guard new != .none else { return } - withAnimation(.interactiveSpring) { - reader.scrollTo(scrollContentBottomID, anchor: .center) - } - } - } - } - .task { - viewModel.send(.onAppearedPage) - viewModel.send(.connectChatStream) - } - .navigationTitle(Text("Live translation", bundle: .module)) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - isSelectedLanguageSheet.toggle() - } label: { - let selectedLanguage = - viewModel.langSet?.langCodingKey(viewModel.selectedLangCode) ?? "" - Text(selectedLanguage) - Image(systemName: "globe") - } - .sheet(isPresented: $isSelectedLanguageSheet) { - SelectLanguageSheet( - languageList: viewModel.langList, - langSet: viewModel.langSet, - selectedLanguageAction: { langCode in - viewModel.send(.changeLangCode(langCode)) - isSelectedLanguageSheet = false - } - ) - .presentationDetents([.medium, .large]) - } - } - } - } - } -} - -#Preview { - OldLiveTranslationView(roomNumber: "490294") -} diff --git a/MyLibrary/Sources/LiveTranslationFeature/ViewModel.swift b/MyLibrary/Sources/LiveTranslationFeature/ViewModel.swift deleted file mode 100644 index 4757b09..0000000 --- a/MyLibrary/Sources/LiveTranslationFeature/ViewModel.swift +++ /dev/null @@ -1,346 +0,0 @@ -import Foundation -import LiveTranslationSDK_iOS - -@Observable -@MainActor -public final class ViewModel { - public init(roomNumber: String) { - self.roomNumber = roomNumber - } - - var roomNumber: String - var chatList: [TranslationEntity.CompositeChatItem] = [] - var langSet: LanguageEntity.Response.LangSet? = .none - var langList: [LanguageEntity.Response.LanguageItem] = [] - var roomInfo: ChatRoomEntity.Make.Response? = .none - var selectedLangCode: String = - Locale.autoupdatingCurrent.language.languageCode?.identifier ?? "en" - - let service: LiveTranslationService = .init() - - var isUpdatingChat: Bool = false - var isUpdatingTR: Bool = false - var updateChatWaitingQueue: [RealTimeEntity.Chat.Response] = [] - var updateTrWaitingQueue: [RealTimeEntity.Translation.Response] = [] - var latestListType: RealTimeEntity.ListType? = .none -} - -extension ViewModel { - public func send(_ inputAction: InputAction) { - switch inputAction { - case .onAppearedPage: - Task { - await withTaskGroup(of: Void.self) { [weak self] group in - group.addTask { await self?.loadLangSet() } - group.addTask { await self?.loadChatRoomInfo(self?.roomNumber) } - group.addTask { await self?.loadLangList() } - } - } - case .connectChatStream: - Task { - await connectChatStream(roomNumber) - } - case .changeLangCode(let newLangCode): - selectedLangCode = newLangCode - Task { - await loadLangSet(langCode: newLangCode) - await loadTranslation(chatList: chatList, newLangCode) - } - } - } -} - -extension ViewModel { - private func loadLangSet(langCode: String = LanguageCodeFunctor.deviceCode) async { - do { - let langSet = try await service.getLangSet(.init(langCode: langCode)) - self.langSet = langSet - } catch { - print(error.displayMessage) - } - } - - private func loadLangList() async { - do { - let langList = try await service.getLangList() - self.langList = langList - } catch { - print(error.displayMessage) - } - } - - private func loadChatRoomInfo(_ roomNumber: String?) async { - do { - guard let roomNumber else { return assert(true, "roomNumber is required") } - let roomInfo = try await service.getChatRoomInfo(.init(interactionKey: roomNumber)) - self.roomInfo = roomInfo - } catch { - print(error.displayMessage) - } - } - - private func connectChatStream(_ roomNumber: String) async { - do { - let stream = service.chatConnection(.init(interactionKey: roomNumber)) - for try await action in stream { - switch action { - case .connect: break - case .disconnect: break - case .peerClosed: - send(.connectChatStream) - case .responseChat(let chatItem): - await handleResponseChat(chatItem) - case .responseBatchTranslation(let trItem): - await handleResponseTranslation(trItem) - default: break - } - } - } catch { - print(error.serialized().displayMessage) - } - } - - package func loadTranslation( - chatList: [TranslationEntity.CompositeChatItem], _ dstLangCode: String - ) async { - await withTaskGroup(of: Void.self) { [weak self] group in - let chunkedArray = chatList.chunked(into: 20) - for array in chunkedArray { - group.addTask { - let mutatedArray = array.map { - RealTimeEntity.Translation.Request.ContentData( - chatRoomID: $0.item.chatRoomID, - chatID: $0.id, - srcLangCode: $0.item.srcLangCode, - dstLangCode: dstLangCode, - timestamp: $0.item.timestamp, - text: $0.item.textForTR) - } - await self?.service.requestBatchTranslation(.init(data: mutatedArray)) - } - } - } - } -} - -extension ViewModel { - fileprivate func handleResponseChat(_ chatItem: RealTimeEntity.Chat.Response) async { - guard !isUpdatingChat else { - updateChatWaitingQueue.append(chatItem) - return - } - - self.isUpdatingChat = true - self.latestListType = chatItem.contentData.listType - let newChatList = await self.chatList.merge(item: chatItem, dstLangCode: selectedLangCode) - - self.chatList = newChatList - self.isUpdatingChat = false - - switch chatItem.contentData.listType { - case .update: - let updateTargetList = chatItem.contentData.chatList.reduce( - [TranslationEntity.CompositeChatItem]() - ) { current, next in - guard let firstIndex = newChatList.firstIndex(where: { $0.id == next.id }) else { - return current - } - return current + [newChatList[firstIndex]] - } - await loadTranslation(chatList: updateTargetList, selectedLangCode) - - case .append: - guard let lastItem = newChatList.last else { return } - await loadTranslation(chatList: [lastItem], selectedLangCode) - - case .realtime: break - default: await loadTranslation(chatList: chatList, selectedLangCode) - } - - await checkUpdateChatWaitingQueue() - } - - private func checkUpdateChatWaitingQueue() async { - guard let task = updateChatWaitingQueue.first else { return } - updateChatWaitingQueue.removeFirst() - await handleResponseChat(task) - } - - private func handleResponseTranslation(_ trItem: RealTimeEntity.Translation.Response) async { - guard !isUpdatingTR else { - updateTrWaitingQueue.append(trItem) - return - } - - self.isUpdatingTR = true - self.latestListType = trItem.contentData.listType - - let newChatList = await self.chatList.updateTranslation(item: trItem) - - self.chatList = newChatList - self.isUpdatingTR = false - - await checkUpdateTRWaitingQueue() - } - - private func checkUpdateTRWaitingQueue() async { - guard let task = updateTrWaitingQueue.first else { return } - updateTrWaitingQueue.removeFirst() - await handleResponseTranslation(task) - } -} - -extension [TranslationEntity.CompositeChatItem] { - fileprivate func merge(item: RealTimeEntity.Chat.Response, dstLangCode: String) async - -> [TranslationEntity.CompositeChatItem] - { - await withCheckedContinuation { continuation in - switch item.contentData.listType { - case .append: - var mutableSelf = self - for newItem in item.contentData.chatList { - if let lastIdx = mutableSelf.lastIndex(where: { $0.id == newItem.id }) { - mutableSelf.remove(at: lastIdx) - } - - guard !(newItem.textForTR.isEmpty || newItem.text.isEmpty) else { continue } - mutableSelf.append( - .init(item: newItem, trItem: .none, ttsData: .none, dstLangCode: dstLangCode)) - } - - return continuation.resume(returning: mutableSelf.suffix(100)) - - case .realtime: - var mutableSelf = self - for newItem in item.contentData.chatList { - if let lastIdx = mutableSelf.lastIndex(where: { $0.id == newItem.id }) { - mutableSelf.remove(at: lastIdx) - } - - mutableSelf.append( - .init(item: newItem, trItem: .none, ttsData: .none, dstLangCode: dstLangCode)) - } - return continuation.resume(returning: mutableSelf) - - case .renew: - let newArr: [TranslationEntity.CompositeChatItem] = item.contentData.chatList.reduce([]) { - current, next in - guard !next.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return current - } - let first = self.first(where: { $0.item.id == next.id }) - let new: TranslationEntity.CompositeChatItem = .init( - item: next, trItem: first?.trItem, ttsData: first?.ttsData, dstLangCode: dstLangCode) - - return current + [new] - } - - return continuation.resume(returning: newArr.suffix(100)) - - case .update: - let newArr = item.contentData.chatList.reduce(self) { current, next in - // If the update target is included in the current chat list (when modifying a chat with non-empty value) - if let idx = current.firstIndex(where: { $0.item.chatID == next.chatID }) { - var variableCurrent = current - - // If modified to empty value, delete the chat from the chat list - if next.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - variableCurrent.remove(at: idx) - return variableCurrent - } else { - variableCurrent[idx] = .init( - item: next, - trItem: variableCurrent[idx].trItem, - ttsData: .none, - dstLangCode: dstLangCode) - return variableCurrent - } - // If the update target is not included in the current chat list (when modifying an empty chat) - } else if let willAppendIndex = current.firstIndex(where: { - $0.item.timestamp > next.timestamp - }) { - guard !next.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return current - } - var variableCurrent = current - variableCurrent.insert( - .init(item: next, trItem: .none, ttsData: .none, dstLangCode: dstLangCode), - at: willAppendIndex) - return variableCurrent - } else { - return current - } - } - - return continuation.resume(returning: newArr.suffix(100)) - - default: - return continuation.resume(returning: self) - } - } - } - - fileprivate func updateTranslation(item: RealTimeEntity.Translation.Response) async - -> [TranslationEntity.CompositeChatItem] - { - await withCheckedContinuation { continuation in - guard - let firstIndex = self.firstIndex(where: { - $0.id == item.contentData.chatList.first?.chatID - } - ) - else { - return continuation.resume(returning: self) - } - - let range = firstIndex..<(firstIndex + item.contentData.chatList.count) - var mutatedArray: [TranslationEntity.CompositeChatItem] = [] - - for index in range { - guard let trItem = item.contentData.chatList[safe: mutatedArray.count] else { break } - guard let newItem = self[safe: index]?.setTranslation(trItem: trItem) else { break } - - mutatedArray.append(newItem) - } - - var mutateSelf = self - mutateSelf.replaceSubrange(range, with: mutatedArray) - - return continuation.resume(returning: mutateSelf) - } - } -} - -extension ViewModel { - public enum InputAction { - case onAppearedPage - case connectChatStream - case changeLangCode(String) - } -} - -extension TranslationEntity.CompositeChatItem { - fileprivate func setTranslation(trItem: TranslationEntity.TR.Response) -> Self { - .init( - item: item, - trItem: trItem, - ttsData: .none, - dstLangCode: trItem.dstLangCode) - } -} - -extension Collection { - fileprivate subscript(safe index: Index) -> Element? { - indices.contains(index) ? self[index] : nil - } -} - -extension Array { - fileprivate func chunked(into size: Int) -> [[Element]] { - guard size > .zero else { return [self] } - return stride(from: 0, to: count, by: size).map { startIndex in - let endIndex = index(startIndex, offsetBy: size, limitedBy: count) ?? endIndex - return Array(self[startIndex.. Date: Thu, 10 Apr 2025 21:06:27 +0900 Subject: [PATCH 06/13] =?UTF-8?q?=F0=9F=90=9B=20fix=20translation=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LiveTranslation.swift | 148 +++++++++++------- 1 file changed, 90 insertions(+), 58 deletions(-) diff --git a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift index b5760ce..4ae1112 100644 --- a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift +++ b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift @@ -55,11 +55,14 @@ public struct LiveTranslation { case view(View) case handleResponseChat(RealTimeEntity.Chat.Response) + case checkUpdateChatWaitingQueue case handleResponseTranslation(RealTimeEntity.Translation.Response) + case checkUpdateTRWaitingQueue public enum View { case onAppear case connectStream + case disconnectStream case selectLangCode(String) case setSelectedLanguageSheet(Bool) case setShowingLastChat(Bool) @@ -81,32 +84,21 @@ public struct LiveTranslation { await loadLangSet(send: send) } group.addTask { - do { - let langList = try await liveTranslationServiceClient.langList() - await send( - .set(\.langList, langList) - ) - } catch { - print(error) - } + await loadChatRoomInfo(state: state, send: send) } group.addTask { - do { - let roomInfo = try await liveTranslationServiceClient.chatRoomInfo(state.roomNumber) - await send( - .set(\.roomInfo, roomInfo) - ) - } catch { - print(error) - } + await loadLangList(send: send) } } - await send(.connectChatStream) } case .view(.connectStream): return .run { send in await send(.connectChatStream) } + case .view(.disconnectStream): + return .run { send in + await send(.disconnectChatStream) + } case .view(.selectLangCode(let langCode)): return .run { send in await send(.changeLangCode(langCode)) @@ -119,40 +111,10 @@ public struct LiveTranslation { return .none case .connectChatStream: return .run { [state] send in - let task = Task { - do { - let stream = liveTranslationServiceClient.chatConnection(state.roomNumber) - for try await action in stream { - switch action { - case .connect: - await send(.set(\.isConnected, true)) - break - case .disconnect: - await send(.set(\.isConnected, false)) - break - case .peerClosed: - await send(.connectChatStream) - case .responseChat(let chatItem): - await send(.handleResponseChat(chatItem)) - case .responseBatchTranslation(let trItem): - await send(.handleResponseTranslation(trItem)) - default: break - } - } - } catch { - print(error) - } - } - await send( - .set( - \.chatStreamTask, - task - ) - ) - } + await connectChatRoom(state: state, send: send) + }.cancellable(id: "connectChatRoom") case .disconnectChatStream: - state.chatStreamTask?.cancel() - return .none + return .cancel(id: "connectChatRoom") case .changeLangCode(let newLangCode): state.selectedLangCode = newLangCode return .run { [state] send in @@ -163,10 +125,18 @@ public struct LiveTranslation { return .run { [state] send in await handleResponseChat(chatItem, state: state, send: send) } + case .checkUpdateChatWaitingQueue: + return .run { [state] send in + await checkUpdateChatWaitingQueue(state: state, send: send) + } case .handleResponseTranslation(let trItem): return .run { [state] send in await handleResponseTranslation(trItem, state: state, send: send) } + case .checkUpdateTRWaitingQueue: + return .run { [state] send in + await checkUpdateTRWaitingQueue(state: state, send: send) + } case .binding: return .none } @@ -175,6 +145,33 @@ public struct LiveTranslation { } extension LiveTranslation { + private func connectChatRoom(state: State, send: Send) async { + if state.isConnected { return } + + do { + let stream = liveTranslationServiceClient.chatConnection(state.roomNumber) + for try await action in stream { + switch action { + case .connect: + await send(.set(\.isConnected, true)) + break + case .disconnect: + await send(.set(\.isConnected, false)) + break + case .peerClosed: + await send(.connectChatStream) + case .responseChat(let chatItem): + await send(.handleResponseChat(chatItem)) + case .responseBatchTranslation(let trItem): + await send(.handleResponseTranslation(trItem)) + default: break + } + } + } catch { + print("\(#function): \(error.serialized().displayMessage)") + } + } + private func loadLangSet(langCode: String? = nil, send: Send) async { do { let langSet = try await liveTranslationServiceClient.langSet(langCode) @@ -182,13 +179,36 @@ extension LiveTranslation { .set(\.langSet, langSet) ) } catch { - print(error) + print("\(#function): \(error.serialized().displayMessage)") + } + } + + private func loadLangList(send: Send) async { + do { + let langList = try await liveTranslationServiceClient.langList() + await send( + .set(\.langList, langList) + ) + } catch { + print("\(#function): \(error.serialized().displayMessage)") + } + } + + private func loadChatRoomInfo(state: State, send: Send) async { + do { + let roomInfo = try await liveTranslationServiceClient.chatRoomInfo(state.roomNumber) + await send( + .set(\.roomInfo, roomInfo) + ) + } catch { + print("\(#function): \(error.serialized().displayMessage)") } } private func loadTranslation( chatList: [TranslationEntity.CompositeChatItem], _ dstLangCode: String ) async { + await withTaskGroup(of: Void.self) { group in let chunkedArray = chatList.chunked(into: 20) for array in chunkedArray { @@ -200,7 +220,8 @@ extension LiveTranslation { srcLangCode: $0.item.srcLangCode, dstLangCode: dstLangCode, timestamp: $0.item.timestamp, - text: $0.item.textForTR) + text: $0.item.textForTR + ) } await liveTranslationServiceClient.requestBatchTranslation(mutatedArray) } @@ -242,17 +263,16 @@ extension LiveTranslation { case .realtime: break default: - await loadTranslation(chatList: state.chatList, state.selectedLangCode) + await loadTranslation(chatList: newChatList, state.selectedLangCode) } - - await checkUpdateChatWaitingQueue(state: state, send: send) + await send(.checkUpdateChatWaitingQueue) } /// Check chat item wating queue private func checkUpdateChatWaitingQueue(state: State, send: Send) async { guard let task = state.updateChatWaitingQueue.first else { return } await send(.set(\.updateChatWaitingQueue, state.updateChatWaitingQueue.dropFirst().map { $0 })) - await handleResponseChat(task, state: state, send: send) + await send(.handleResponseChat(task)) } /// Handle translation item response @@ -268,14 +288,14 @@ extension LiveTranslation { await send(.set(\.chatList, newChatList)) await send(.set(\.isUpdatingTR, false)) - await checkUpdateTRWaitingQueue(state: state, send: send) + await send(.checkUpdateTRWaitingQueue) } /// Check translation item waiting queue private func checkUpdateTRWaitingQueue(state: State, send: Send) async { guard let task = state.updateTrWaitingQueue.first else { return } await send(.set(\.updateTrWaitingQueue, state.updateTrWaitingQueue.dropFirst().map { $0 })) - await handleResponseTranslation(task, state: state, send: send) + await send(.handleResponseTranslation(task)) } } @@ -283,6 +303,7 @@ extension LiveTranslation { public struct LiveTranslationView: View { @Bindable public var store: StoreOf + @Environment(\.scenePhase) var scenePhase private let scrollContentBottomID: String = "atBottom" @@ -321,10 +342,21 @@ public struct LiveTranslationView: View { proxy.scrollTo(scrollContentBottomID, anchor: .center) } } + .onChange(of: scenePhase) { + switch scenePhase { + case .inactive: break + case .active: + send(.connectStream) + case .background: + send(.disconnectStream) + @unknown default: break + } + } } } .task { send(.onAppear) + send(.connectStream) } .navigationTitle(Text("Live translation", bundle: .module)) .toolbar { From c8e182541ede863e8d51b10c89a9a7f531af3c88 Mon Sep 17 00:00:00 2001 From: touyou Date: Thu, 10 Apr 2025 21:14:19 +0900 Subject: [PATCH 07/13] =?UTF-8?q?=E2=9C=A8=20add=20reload=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LiveTranslationFeature/LiveTranslation.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift index 4ae1112..e8f7cec 100644 --- a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift +++ b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift @@ -360,7 +360,16 @@ public struct LiveTranslationView: View { } .navigationTitle(Text("Live translation", bundle: .module)) .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + if !store.isConnected { + ToolbarItem(placement: .topBarLeading) { + Button { + send(.connectStream) + } label: { + Image(systemName: "arrow.trianglehead.2.clockwise") + } + } + } + ToolbarItem(placement: .topBarTrailing) { Button { send(.setSelectedLanguageSheet(!store.isSelectedLanguageSheet)) } label: { From 29e81b8c4c62e7f952d8ee6b263c49fdba70f73f Mon Sep 17 00:00:00 2001 From: touyou Date: Thu, 10 Apr 2025 21:22:04 +0900 Subject: [PATCH 08/13] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MyLibrary/Sources/AppFeature/AppView.swift | 14 +- .../LiveTranslation.swift | 132 +++++++++--------- .../LiveTranslationServiceClient.swift | 9 +- 3 files changed, 82 insertions(+), 73 deletions(-) diff --git a/MyLibrary/Sources/AppFeature/AppView.swift b/MyLibrary/Sources/AppFeature/AppView.swift index 58d0421..fdd9a34 100644 --- a/MyLibrary/Sources/AppFeature/AppView.swift +++ b/MyLibrary/Sources/AppFeature/AppView.swift @@ -15,17 +15,17 @@ public struct AppReducer { var schedule = Schedule.State() var liveTranslation = LiveTranslation.State( roomNumber: ProcessInfo.processInfo.environment["LIVE_TRANSLATION_KEY"] - ?? (Bundle.main.infoDictionary?["Live translation room number"] as? String) ?? "" + ?? (Bundle.main.infoDictionary?["Live translation room number"] as? String) ?? "" ) var guidance = Guidance.State() var sponsors = SponsorsList.State() var trySwift = TrySwift.State() - + public init() { try? Tips.configure([.displayFrequency(.immediate)]) } } - + public enum Action { case schedule(Schedule.Action) case liveTranslation(LiveTranslation.Action) @@ -33,9 +33,9 @@ public struct AppReducer { case sponsors(SponsorsList.Action) case trySwift(TrySwift.Action) } - + public init() {} - + public var body: some ReducerOf { Scope(state: \.schedule, action: \.schedule) { Schedule() @@ -57,11 +57,11 @@ public struct AppReducer { public struct AppView: View { var store: StoreOf - + public init(store: StoreOf) { self.store = store } - + public var body: some View { TabView { ScheduleView(store: store.scope(state: \.schedule, action: \.schedule)) diff --git a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift index e8f7cec..153514e 100644 --- a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift +++ b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift @@ -1,7 +1,7 @@ import ComposableArchitecture -import SwiftUI import Foundation import LiveTranslationSDK_iOS +import SwiftUI @Reducer public struct LiveTranslation { @@ -19,8 +19,8 @@ public struct LiveTranslation { var roomInfo: ChatRoomEntity.Make.Response? = .none /// Current language code which user selected var selectedLangCode: String = - Locale.autoupdatingCurrent.language.languageCode?.identifier ?? "en" - + Locale.autoupdatingCurrent.language.languageCode?.identifier ?? "en" + /// While updating chat var isUpdatingChat: Bool = false /// While updating translation response @@ -31,34 +31,34 @@ public struct LiveTranslation { var updateTrWaitingQueue: [RealTimeEntity.Translation.Response] = [] /// Latest item's list type var latestListType: RealTimeEntity.ListType? = .none - + /// Streaming is connected var isConnected: Bool = false /// The task of connecting stream var chatStreamTask: Task? = nil - + /// selected language sheet var isSelectedLanguageSheet: Bool = false /// showing last chat var isShowingLastChat: Bool = false - + public init(roomNumber: String) { self.roomNumber = roomNumber } } - + public enum Action: BindableAction, ViewAction { case binding(BindingAction) case connectChatStream case disconnectChatStream case changeLangCode(String) case view(View) - + case handleResponseChat(RealTimeEntity.Chat.Response) case checkUpdateChatWaitingQueue case handleResponseTranslation(RealTimeEntity.Translation.Response) case checkUpdateTRWaitingQueue - + public enum View { case onAppear case connectStream @@ -68,11 +68,11 @@ public struct LiveTranslation { case setShowingLastChat(Bool) } } - + @Dependency(\.liveTranslationServiceClient) var liveTranslationServiceClient - + public init() {} - + public var body: some ReducerOf { BindingReducer() Reduce { state, action in @@ -147,7 +147,7 @@ public struct LiveTranslation { extension LiveTranslation { private func connectChatRoom(state: State, send: Send) async { if state.isConnected { return } - + do { let stream = liveTranslationServiceClient.chatConnection(state.roomNumber) for try await action in stream { @@ -171,7 +171,7 @@ extension LiveTranslation { print("\(#function): \(error.serialized().displayMessage)") } } - + private func loadLangSet(langCode: String? = nil, send: Send) async { do { let langSet = try await liveTranslationServiceClient.langSet(langCode) @@ -182,7 +182,7 @@ extension LiveTranslation { print("\(#function): \(error.serialized().displayMessage)") } } - + private func loadLangList(send: Send) async { do { let langList = try await liveTranslationServiceClient.langList() @@ -193,7 +193,7 @@ extension LiveTranslation { print("\(#function): \(error.serialized().displayMessage)") } } - + private func loadChatRoomInfo(state: State, send: Send) async { do { let roomInfo = try await liveTranslationServiceClient.chatRoomInfo(state.roomNumber) @@ -204,11 +204,11 @@ extension LiveTranslation { print("\(#function): \(error.serialized().displayMessage)") } } - + private func loadTranslation( chatList: [TranslationEntity.CompositeChatItem], _ dstLangCode: String ) async { - + await withTaskGroup(of: Void.self) { group in let chunkedArray = chatList.chunked(into: 20) for array in chunkedArray { @@ -228,9 +228,11 @@ extension LiveTranslation { } } } - + /// Handle chat item response - private func handleResponseChat(_ chatItem: RealTimeEntity.Chat.Response, state: State, send: Send) async { + private func handleResponseChat( + _ chatItem: RealTimeEntity.Chat.Response, state: State, send: Send + ) async { guard !state.isUpdatingChat else { await send( .set(\.updateChatWaitingQueue, state.updateChatWaitingQueue + [chatItem]) @@ -240,10 +242,11 @@ extension LiveTranslation { // NOTE: Updating chat list await send(.set(\.isUpdatingChat, true)) await send(.set(\.latestListType, chatItem.contentData.listType)) - let newChatList = await state.chatList.merge(item: chatItem, dstLangCode: state.selectedLangCode) + let newChatList = await state.chatList.merge( + item: chatItem, dstLangCode: state.selectedLangCode) await send(.set(\.chatList, newChatList)) await send(.set(\.isUpdatingChat, false)) - + switch chatItem.contentData.listType { case .update: let updateTargetList = chatItem.contentData.chatList.reduce( @@ -255,42 +258,44 @@ extension LiveTranslation { return current + [newChatList[firstIndex]] } await loadTranslation(chatList: updateTargetList, state.selectedLangCode) - + case .append: guard let lastItem = newChatList.last else { return } await loadTranslation(chatList: [lastItem], state.selectedLangCode) - + case .realtime: break - + default: await loadTranslation(chatList: newChatList, state.selectedLangCode) } await send(.checkUpdateChatWaitingQueue) } - + /// Check chat item wating queue private func checkUpdateChatWaitingQueue(state: State, send: Send) async { guard let task = state.updateChatWaitingQueue.first else { return } await send(.set(\.updateChatWaitingQueue, state.updateChatWaitingQueue.dropFirst().map { $0 })) await send(.handleResponseChat(task)) } - + /// Handle translation item response - private func handleResponseTranslation(_ trItem: RealTimeEntity.Translation.Response, state: State, send: Send) async { + private func handleResponseTranslation( + _ trItem: RealTimeEntity.Translation.Response, state: State, send: Send + ) async { guard !state.isUpdatingTR else { await send(.set(\.updateTrWaitingQueue, state.updateTrWaitingQueue + [trItem])) return } - + await send(.set(\.isUpdatingTR, true)) await send(.set(\.latestListType, trItem.contentData.listType)) let newChatList = await state.chatList.updateTranslation(item: trItem) await send(.set(\.chatList, newChatList)) await send(.set(\.isUpdatingTR, false)) - + await send(.checkUpdateTRWaitingQueue) } - + /// Check translation item waiting queue private func checkUpdateTRWaitingQueue(state: State, send: Send) async { guard let task = state.updateTrWaitingQueue.first else { return } @@ -301,16 +306,16 @@ extension LiveTranslation { @ViewAction(for: LiveTranslation.self) public struct LiveTranslationView: View { - + @Bindable public var store: StoreOf @Environment(\.scenePhase) var scenePhase - + private let scrollContentBottomID: String = "atBottom" - + public init(store: StoreOf) { self.store = store } - + public var body: some View { NavigationStack { VStack { @@ -325,7 +330,7 @@ public struct LiveTranslationView: View { } else { translationContents } - + flittoLogo .id(scrollContentBottomID) .padding(.bottom, 16) @@ -335,9 +340,9 @@ public struct LiveTranslationView: View { proxy.scrollTo(scrollContentBottomID, anchor: .bottom) return } - + guard store.isShowingLastChat else { return } - + withAnimation(.interactiveSpring) { proxy.scrollTo(scrollContentBottomID, anchor: .center) } @@ -374,7 +379,7 @@ public struct LiveTranslationView: View { send(.setSelectedLanguageSheet(!store.isSelectedLanguageSheet)) } label: { let selectedLanguage = - store.langSet?.langCodingKey(store.selectedLangCode) ?? "" + store.langSet?.langCodingKey(store.selectedLangCode) ?? "" Text(selectedLanguage) Image(systemName: "globe") } @@ -393,7 +398,7 @@ public struct LiveTranslationView: View { } } } - + @ViewBuilder var translationContents: some View { LazyVStack { @@ -413,7 +418,7 @@ public struct LiveTranslationView: View { } } } - + @ViewBuilder var flittoLogo: some View { HStack { @@ -433,7 +438,7 @@ public struct LiveTranslationView: View { extension [TranslationEntity.CompositeChatItem] { fileprivate func merge(item: RealTimeEntity.Chat.Response, dstLangCode: String) async - -> [TranslationEntity.CompositeChatItem] + -> [TranslationEntity.CompositeChatItem] { await withCheckedContinuation { continuation in switch item.contentData.listType { @@ -443,26 +448,26 @@ extension [TranslationEntity.CompositeChatItem] { if let lastIdx = mutableSelf.lastIndex(where: { $0.id == newItem.id }) { mutableSelf.remove(at: lastIdx) } - + guard !(newItem.textForTR.isEmpty || newItem.text.isEmpty) else { continue } mutableSelf.append( .init(item: newItem, trItem: .none, ttsData: .none, dstLangCode: dstLangCode)) } - + return continuation.resume(returning: mutableSelf.suffix(100)) - + case .realtime: var mutableSelf = self for newItem in item.contentData.chatList { if let lastIdx = mutableSelf.lastIndex(where: { $0.id == newItem.id }) { mutableSelf.remove(at: lastIdx) } - + mutableSelf.append( .init(item: newItem, trItem: .none, ttsData: .none, dstLangCode: dstLangCode)) } return continuation.resume(returning: mutableSelf) - + case .renew: let newArr: [TranslationEntity.CompositeChatItem] = item.contentData.chatList.reduce([]) { current, next in @@ -472,18 +477,18 @@ extension [TranslationEntity.CompositeChatItem] { let first = self.first(where: { $0.item.id == next.id }) let new: TranslationEntity.CompositeChatItem = .init( item: next, trItem: first?.trItem, ttsData: first?.ttsData, dstLangCode: dstLangCode) - + return current + [new] } - + return continuation.resume(returning: newArr.suffix(100)) - + case .update: let newArr = item.contentData.chatList.reduce(self) { current, next in // If the update target is included in the current chat list (when modifying a chat with non-empty value) if let idx = current.firstIndex(where: { $0.item.chatID == next.chatID }) { var variableCurrent = current - + // If modified to empty value, delete the chat from the chat list if next.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { variableCurrent.remove(at: idx) @@ -512,17 +517,17 @@ extension [TranslationEntity.CompositeChatItem] { return current } } - + return continuation.resume(returning: newArr.suffix(100)) - + default: return continuation.resume(returning: self) } } } - + fileprivate func updateTranslation(item: RealTimeEntity.Translation.Response) async - -> [TranslationEntity.CompositeChatItem] + -> [TranslationEntity.CompositeChatItem] { await withCheckedContinuation { continuation in guard @@ -533,20 +538,20 @@ extension [TranslationEntity.CompositeChatItem] { else { return continuation.resume(returning: self) } - + let range = firstIndex..<(firstIndex + item.contentData.chatList.count) var mutatedArray: [TranslationEntity.CompositeChatItem] = [] - + for index in range { guard let trItem = item.contentData.chatList[safe: mutatedArray.count] else { break } guard let newItem = self[safe: index]?.setTranslation(trItem: trItem) else { break } - + mutatedArray.append(newItem) } - + var mutateSelf = self mutateSelf.replaceSubrange(range, with: mutatedArray) - + return continuation.resume(returning: mutateSelf) } } @@ -579,7 +584,8 @@ extension Array { } #Preview { - LiveTranslationView(store: .init(initialState: .init(roomNumber: "490294")) { - LiveTranslation() - }) + LiveTranslationView( + store: .init(initialState: .init(roomNumber: "490294")) { + LiveTranslation() + }) } diff --git a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslationServiceClient.swift b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslationServiceClient.swift index 56a8842..2e8370e 100644 --- a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslationServiceClient.swift +++ b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslationServiceClient.swift @@ -19,8 +19,11 @@ public struct LiveTranslationServiceClient { public var langSet: @Sendable (String?) async throws -> LanguageEntity.Response.LangSet public var langList: @Sendable () async throws -> [LanguageEntity.Response.LanguageItem] public var chatRoomInfo: @Sendable (String) async throws -> ChatRoomEntity.Make.Response - public var chatConnection: @Sendable (String) -> AsyncThrowingStream = { _ in .never } - public var requestBatchTranslation: @Sendable ([RealTimeEntity.Translation.Request.ContentData]) async -> Void + public var chatConnection: + @Sendable (String) -> AsyncThrowingStream = { _ in .never + } + public var requestBatchTranslation: + @Sendable ([RealTimeEntity.Translation.Request.ContentData]) async -> Void } extension LiveTranslationServiceClient: DependencyKey { @@ -44,6 +47,6 @@ extension LiveTranslationServiceClient: DependencyKey { } ) } - + public static var liveValue: Self = .live() } From a278d0d2825594a79a3e60dd288452bcf662f168 Mon Sep 17 00:00:00 2001 From: touyou Date: Fri, 11 Apr 2025 18:40:14 +0900 Subject: [PATCH 09/13] add #91 changes --- MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift index 153514e..23b39a8 100644 --- a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift +++ b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift @@ -334,6 +334,8 @@ public struct LiveTranslationView: View { flittoLogo .id(scrollContentBottomID) .padding(.bottom, 16) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(verbatim: "Powered by Flitto")) } .onChange(of: store.chatList.last) { old, new in guard old != .none else { @@ -431,6 +433,7 @@ public struct LiveTranslationView: View { .offset(x: -10) .aspectRatio(contentMode: .fit) .frame(maxHeight: 30) + .accessibilityIgnoresInvertColors() Spacer() } } From 88ea1128734d79ac9fa7242ce69a5a3ebabf5904 Mon Sep 17 00:00:00 2001 From: touyou Date: Fri, 11 Apr 2025 18:50:58 +0900 Subject: [PATCH 10/13] resolved package --- MyLibrary/Package.resolved | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MyLibrary/Package.resolved b/MyLibrary/Package.resolved index e98cef9..72d5243 100644 --- a/MyLibrary/Package.resolved +++ b/MyLibrary/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/flitto/rtt_sdk", "state" : { - "revision" : "e0870639abf3a1858d507d6b19e62ef5746c17fa", - "version" : "0.1.2" + "branch" : "0.1.5", + "revision" : "f1da670032cb52081285752b7a8c479118038393" } }, { From b32d9b470d83a5c43404f8100e3470bef2a7da77 Mon Sep 17 00:00:00 2001 From: touyou Date: Mon, 14 Apr 2025 22:53:01 +0900 Subject: [PATCH 11/13] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20rewrite=20send=20act?= =?UTF-8?q?ion=20to=20call=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LiveTranslationFeature/LiveTranslation.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift index 23b39a8..546ef2f 100644 --- a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift +++ b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift @@ -70,6 +70,8 @@ public struct LiveTranslation { } @Dependency(\.liveTranslationServiceClient) var liveTranslationServiceClient + + private let connectChatRoomTaskId: String = "connectChatRoomTask" public init() {} @@ -92,13 +94,11 @@ public struct LiveTranslation { } } case .view(.connectStream): - return .run { send in - await send(.connectChatStream) - } + return .run { [state] send in + await connectChatRoom(state: state, send: send) + }.cancellable(id: connectChatRoomTaskId) case .view(.disconnectStream): - return .run { send in - await send(.disconnectChatStream) - } + return .cancel(id: connectChatRoomTaskId) case .view(.selectLangCode(let langCode)): return .run { send in await send(.changeLangCode(langCode)) @@ -112,9 +112,9 @@ public struct LiveTranslation { case .connectChatStream: return .run { [state] send in await connectChatRoom(state: state, send: send) - }.cancellable(id: "connectChatRoom") + }.cancellable(id: connectChatRoomTaskId) case .disconnectChatStream: - return .cancel(id: "connectChatRoom") + return .cancel(id: connectChatRoomTaskId) case .changeLangCode(let newLangCode): state.selectedLangCode = newLangCode return .run { [state] send in From 7d53241b9d8e05e821552e6b4e9d283403f92e25 Mon Sep 17 00:00:00 2001 From: touyou Date: Tue, 15 Apr 2025 22:05:53 +0900 Subject: [PATCH 12/13] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20use=20liveValue=20di?= =?UTF-8?q?rectly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LiveTranslationServiceClient.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslationServiceClient.swift b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslationServiceClient.swift index 2e8370e..575ed7d 100644 --- a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslationServiceClient.swift +++ b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslationServiceClient.swift @@ -27,7 +27,7 @@ public struct LiveTranslationServiceClient { } extension LiveTranslationServiceClient: DependencyKey { - static func live() -> Self { + public static var liveValue: Self = { let service = LiveTranslationService() return Self( langSet: { langCode in @@ -46,7 +46,5 @@ extension LiveTranslationServiceClient: DependencyKey { await service.requestBatchTranslation(.init(data: array)) } ) - } - - public static var liveValue: Self = .live() + }() } From cf9000ef5cce68eda67c7395d8baec882d7a73cb Mon Sep 17 00:00:00 2001 From: touyou Date: Tue, 15 Apr 2025 22:35:37 +0900 Subject: [PATCH 13/13] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20implement=20BuildCon?= =?UTF-8?q?fig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MyLibrary/Package.swift | 7 ++++++ MyLibrary/Sources/AppFeature/AppView.swift | 5 +--- .../Sources/BuildConfig/BuildConfig.swift | 24 +++++++++++++++++++ .../LiveTranslation.swift | 11 +++++---- 4 files changed, 38 insertions(+), 9 deletions(-) create mode 100644 MyLibrary/Sources/BuildConfig/BuildConfig.swift diff --git a/MyLibrary/Package.swift b/MyLibrary/Package.swift index 1b1e690..2d7891c 100644 --- a/MyLibrary/Package.swift +++ b/MyLibrary/Package.swift @@ -42,6 +42,12 @@ let package = Package( "trySwiftFeature", ] ), + .target( + name: "BuildConfig", + dependencies: [ + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), .target( name: "DataClient", dependencies: [ @@ -69,6 +75,7 @@ let package = Package( .target( name: "LiveTranslationFeature", dependencies: [ + "BuildConfig", .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "rtt-sdk", package: "rtt_sdk"), ] diff --git a/MyLibrary/Sources/AppFeature/AppView.swift b/MyLibrary/Sources/AppFeature/AppView.swift index fdd9a34..b2e8c6c 100644 --- a/MyLibrary/Sources/AppFeature/AppView.swift +++ b/MyLibrary/Sources/AppFeature/AppView.swift @@ -13,10 +13,7 @@ public struct AppReducer { @ObservableState public struct State: Equatable { var schedule = Schedule.State() - var liveTranslation = LiveTranslation.State( - roomNumber: ProcessInfo.processInfo.environment["LIVE_TRANSLATION_KEY"] - ?? (Bundle.main.infoDictionary?["Live translation room number"] as? String) ?? "" - ) + var liveTranslation = LiveTranslation.State() var guidance = Guidance.State() var sponsors = SponsorsList.State() var trySwift = TrySwift.State() diff --git a/MyLibrary/Sources/BuildConfig/BuildConfig.swift b/MyLibrary/Sources/BuildConfig/BuildConfig.swift new file mode 100644 index 0000000..670fb61 --- /dev/null +++ b/MyLibrary/Sources/BuildConfig/BuildConfig.swift @@ -0,0 +1,24 @@ +import Dependencies +import Foundation +import DependenciesMacros + +@DependencyClient +public struct BuildConfig { + public var liveTranslationRoomNumber: () -> String = { "" } +} + +extension DependencyValues { + public var buildConfig: BuildConfig { + get { self[BuildConfig.self] } + set { self[BuildConfig.self] = newValue } + } +} + +extension BuildConfig: DependencyKey { + public static let liveValue: Self = Self( + liveTranslationRoomNumber: { + ProcessInfo.processInfo.environment["LIVE_TRANSLATION_KEY"] + ?? (Bundle.main.infoDictionary?["Live translation room number"] as? String) ?? "" + } + ) +} diff --git a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift index 546ef2f..6333ae9 100644 --- a/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift +++ b/MyLibrary/Sources/LiveTranslationFeature/LiveTranslation.swift @@ -2,13 +2,14 @@ import ComposableArchitecture import Foundation import LiveTranslationSDK_iOS import SwiftUI +import BuildConfig @Reducer public struct LiveTranslation { @ObservableState public struct State: Equatable { /// Live Translation Room Number - var roomNumber: String + var roomNumber: String = "" /// Current visible translation items var chatList: [TranslationEntity.CompositeChatItem] = [] /// Current language set @@ -42,9 +43,7 @@ public struct LiveTranslation { /// showing last chat var isShowingLastChat: Bool = false - public init(roomNumber: String) { - self.roomNumber = roomNumber - } + public init() {} } public enum Action: BindableAction, ViewAction { @@ -70,6 +69,7 @@ public struct LiveTranslation { } @Dependency(\.liveTranslationServiceClient) var liveTranslationServiceClient + @Dependency(\.buildConfig) var buildConfig private let connectChatRoomTaskId: String = "connectChatRoomTask" @@ -80,6 +80,7 @@ public struct LiveTranslation { Reduce { state, action in switch action { case .view(.onAppear): + state.roomNumber = buildConfig.liveTranslationRoomNumber() return .run { [state] send in await withTaskGroup(of: Void.self) { group in group.addTask { @@ -588,7 +589,7 @@ extension Array { #Preview { LiveTranslationView( - store: .init(initialState: .init(roomNumber: "490294")) { + store: .init(initialState: .init()) { LiveTranslation() }) }