From aa7dd3d9844cd6c3af919bb7d01b321f78813fc4 Mon Sep 17 00:00:00 2001 From: leesam Date: Thu, 2 Apr 2026 23:50:36 +0900 Subject: [PATCH 1/2] feat: prepare 2.2.1 stabilization --- .../Strings/Base.lproj/Localizable.strings | 4 + .../Strings/de.lproj/Localizable.strings | 4 + .../Strings/es.lproj/Localizable.strings | 4 + .../Strings/fr.lproj/Localizable.strings | 4 + .../Strings/id.lproj/Localizable.strings | 4 + .../Strings/it.lproj/Localizable.strings | 4 + .../Strings/ja.lproj/Localizable.strings | 4 + .../Strings/ko.lproj/Localizable.strings | 4 + .../Strings/ru.lproj/Localizable.strings | 4 + .../Strings/th.lproj/Localizable.strings | 4 + .../Strings/vi.lproj/Localizable.strings | 4 + .../Strings/zh-Hans.lproj/Localizable.strings | 4 + .../zh-Hant-TW.lproj/Localizable.strings | 4 + Projects/App/Sources/App.swift | 32 ++- .../GoogleAds/BannerAdSwiftUIView.swift | 15 +- .../Sources/Managers/SwiftUIAdManager.swift | 42 ++- .../App/Sources/Screens/HistoryScreen.swift | 32 +++ .../Sources/Screens/TranslationScreen.swift | 29 +- .../SpeechRecognitionViewModel.swift | 7 +- .../ViewModels/TranslationViewModel.swift | 69 ++++- .../App/Sources/Views/WatchAdButton.swift | 7 +- Projects/App/Tests/TalktransTests.swift | 210 +++++++++++++- docs/plans/2026-04-01-2.2.1-stabilization.md | 263 ++++++++++++++++++ ...4-01-appstore-screenshot-penpot-screens.md | 128 +++++++++ docs/qa/2.2.1-manual-regression-checklist.md | 61 ++++ fastlane/metadata/de-DE/description.txt | 6 + fastlane/metadata/de-DE/keywords.txt | 1 + fastlane/metadata/de-DE/subtitle.txt | 1 + fastlane/metadata/en-US/description.txt | 6 + fastlane/metadata/en-US/keywords.txt | 1 + fastlane/metadata/en-US/subtitle.txt | 1 + fastlane/metadata/es-ES/description.txt | 6 + fastlane/metadata/es-ES/keywords.txt | 1 + fastlane/metadata/es-ES/subtitle.txt | 1 + fastlane/metadata/fr-FR/description.txt | 6 + fastlane/metadata/fr-FR/keywords.txt | 1 + fastlane/metadata/fr-FR/subtitle.txt | 1 + fastlane/metadata/id/description.txt | 1 + fastlane/metadata/id/keywords.txt | 1 + fastlane/metadata/id/subtitle.txt | 1 + fastlane/metadata/it/description.txt | 1 + fastlane/metadata/it/keywords.txt | 1 + fastlane/metadata/it/subtitle.txt | 1 + fastlane/metadata/ja/description.txt | 6 + fastlane/metadata/ja/keywords.txt | 1 + fastlane/metadata/ja/subtitle.txt | 1 + fastlane/metadata/ko/description.txt | 6 + fastlane/metadata/ko/keywords.txt | 1 + fastlane/metadata/ko/subtitle.txt | 1 + fastlane/metadata/ru/description.txt | 1 + fastlane/metadata/ru/keywords.txt | 1 + fastlane/metadata/ru/subtitle.txt | 1 + fastlane/metadata/th/description.txt | 1 + fastlane/metadata/th/keywords.txt | 1 + fastlane/metadata/th/subtitle.txt | 1 + fastlane/metadata/vi/description.txt | 1 + fastlane/metadata/vi/keywords.txt | 1 + fastlane/metadata/vi/subtitle.txt | 1 + fastlane/metadata/zh-Hans/description.txt | 6 + fastlane/metadata/zh-Hans/keywords.txt | 1 + fastlane/metadata/zh-Hans/subtitle.txt | 1 + fastlane/metadata/zh-Hant/description.txt | 6 + fastlane/metadata/zh-Hant/keywords.txt | 1 + fastlane/metadata/zh-Hant/subtitle.txt | 1 + 64 files changed, 982 insertions(+), 44 deletions(-) create mode 100644 docs/plans/2026-04-01-2.2.1-stabilization.md create mode 100644 docs/plans/2026-04-01-appstore-screenshot-penpot-screens.md create mode 100644 docs/qa/2.2.1-manual-regression-checklist.md create mode 100644 fastlane/metadata/de-DE/description.txt create mode 100644 fastlane/metadata/de-DE/keywords.txt create mode 100644 fastlane/metadata/de-DE/subtitle.txt create mode 100644 fastlane/metadata/en-US/description.txt create mode 100644 fastlane/metadata/en-US/keywords.txt create mode 100644 fastlane/metadata/en-US/subtitle.txt create mode 100644 fastlane/metadata/es-ES/description.txt create mode 100644 fastlane/metadata/es-ES/keywords.txt create mode 100644 fastlane/metadata/es-ES/subtitle.txt create mode 100644 fastlane/metadata/fr-FR/description.txt create mode 100644 fastlane/metadata/fr-FR/keywords.txt create mode 100644 fastlane/metadata/fr-FR/subtitle.txt create mode 100644 fastlane/metadata/id/description.txt create mode 100644 fastlane/metadata/id/keywords.txt create mode 100644 fastlane/metadata/id/subtitle.txt create mode 100644 fastlane/metadata/it/description.txt create mode 100644 fastlane/metadata/it/keywords.txt create mode 100644 fastlane/metadata/it/subtitle.txt create mode 100644 fastlane/metadata/ja/description.txt create mode 100644 fastlane/metadata/ja/keywords.txt create mode 100644 fastlane/metadata/ja/subtitle.txt create mode 100644 fastlane/metadata/ko/description.txt create mode 100644 fastlane/metadata/ko/keywords.txt create mode 100644 fastlane/metadata/ko/subtitle.txt create mode 100644 fastlane/metadata/ru/description.txt create mode 100644 fastlane/metadata/ru/keywords.txt create mode 100644 fastlane/metadata/ru/subtitle.txt create mode 100644 fastlane/metadata/th/description.txt create mode 100644 fastlane/metadata/th/keywords.txt create mode 100644 fastlane/metadata/th/subtitle.txt create mode 100644 fastlane/metadata/vi/description.txt create mode 100644 fastlane/metadata/vi/keywords.txt create mode 100644 fastlane/metadata/vi/subtitle.txt create mode 100644 fastlane/metadata/zh-Hans/description.txt create mode 100644 fastlane/metadata/zh-Hans/keywords.txt create mode 100644 fastlane/metadata/zh-Hans/subtitle.txt create mode 100644 fastlane/metadata/zh-Hant/description.txt create mode 100644 fastlane/metadata/zh-Hant/keywords.txt create mode 100644 fastlane/metadata/zh-Hant/subtitle.txt diff --git a/Projects/App/Resources/Strings/Base.lproj/Localizable.strings b/Projects/App/Resources/Strings/Base.lproj/Localizable.strings index fac5d95..b2b45f0 100644 --- a/Projects/App/Resources/Strings/Base.lproj/Localizable.strings +++ b/Projects/App/Resources/Strings/Base.lproj/Localizable.strings @@ -59,3 +59,7 @@ Translate="Translate"; "No favorites yet"="No favorites yet"; "Detail"="Detail"; "Filter"="Filter"; +"Send Feedback"="Send Feedback"; +"Unable to Open Link"="Unable to Open Link"; +"Copy Link"="Copy Link"; +"Please copy and open the feedback link manually."="Please copy and open the feedback link manually."; diff --git a/Projects/App/Resources/Strings/de.lproj/Localizable.strings b/Projects/App/Resources/Strings/de.lproj/Localizable.strings index 7f908c4..fc1e758 100644 --- a/Projects/App/Resources/Strings/de.lproj/Localizable.strings +++ b/Projects/App/Resources/Strings/de.lproj/Localizable.strings @@ -59,3 +59,7 @@ Translate="Übersetzen"; "No favorites yet"="Keine Favoriten"; "Detail"="Detail"; "Filter"="Filter"; +"Send Feedback"="Feedback senden"; +"Unable to Open Link"="Link kann nicht geöffnet werden"; +"Copy Link"="Link kopieren"; +"Please copy and open the feedback link manually."="Bitte kopieren Sie den Feedback-Link und öffnen Sie ihn manuell."; diff --git a/Projects/App/Resources/Strings/es.lproj/Localizable.strings b/Projects/App/Resources/Strings/es.lproj/Localizable.strings index 033d8c9..11af544 100644 --- a/Projects/App/Resources/Strings/es.lproj/Localizable.strings +++ b/Projects/App/Resources/Strings/es.lproj/Localizable.strings @@ -59,3 +59,7 @@ Translate="Traducir"; "No favorites yet"="Sin favoritos"; "Detail"="Detalle"; "Filter"="Filtro"; +"Send Feedback"="Enviar comentarios"; +"Unable to Open Link"="No se puede abrir el enlace"; +"Copy Link"="Copiar enlace"; +"Please copy and open the feedback link manually."="Copie y abra manualmente el enlace de comentarios."; diff --git a/Projects/App/Resources/Strings/fr.lproj/Localizable.strings b/Projects/App/Resources/Strings/fr.lproj/Localizable.strings index 0155bfb..8553e63 100644 --- a/Projects/App/Resources/Strings/fr.lproj/Localizable.strings +++ b/Projects/App/Resources/Strings/fr.lproj/Localizable.strings @@ -59,3 +59,7 @@ Translate="Traduire"; "No favorites yet"="Aucun favori"; "Detail"="Détail"; "Filter"="Filtre"; +"Send Feedback"="Envoyer des commentaires"; +"Unable to Open Link"="Impossible d’ouvrir le lien"; +"Copy Link"="Copier le lien"; +"Please copy and open the feedback link manually."="Veuillez copier et ouvrir manuellement le lien de commentaires."; diff --git a/Projects/App/Resources/Strings/id.lproj/Localizable.strings b/Projects/App/Resources/Strings/id.lproj/Localizable.strings index 0863954..eea4d6f 100644 --- a/Projects/App/Resources/Strings/id.lproj/Localizable.strings +++ b/Projects/App/Resources/Strings/id.lproj/Localizable.strings @@ -59,3 +59,7 @@ Translate="Menterjemahkan"; "No favorites yet"="Belum ada favorit"; "Detail"="Detail"; "Filter"="Filter"; +"Send Feedback"="Kirim masukan"; +"Unable to Open Link"="Tidak dapat membuka tautan"; +"Copy Link"="Salin tautan"; +"Please copy and open the feedback link manually."="Silakan salin dan buka tautan masukan secara manual."; diff --git a/Projects/App/Resources/Strings/it.lproj/Localizable.strings b/Projects/App/Resources/Strings/it.lproj/Localizable.strings index 3ed1a96..7c3b23d 100644 --- a/Projects/App/Resources/Strings/it.lproj/Localizable.strings +++ b/Projects/App/Resources/Strings/it.lproj/Localizable.strings @@ -59,3 +59,7 @@ Translate="Tradurre"; "No favorites yet"="Nessun preferito"; "Detail"="Dettaglio"; "Filter"="Filtro"; +"Send Feedback"="Invia feedback"; +"Unable to Open Link"="Impossibile aprire il link"; +"Copy Link"="Copia link"; +"Please copy and open the feedback link manually."="Copia e apri manualmente il link di feedback."; diff --git a/Projects/App/Resources/Strings/ja.lproj/Localizable.strings b/Projects/App/Resources/Strings/ja.lproj/Localizable.strings index e383a61..27627de 100644 --- a/Projects/App/Resources/Strings/ja.lproj/Localizable.strings +++ b/Projects/App/Resources/Strings/ja.lproj/Localizable.strings @@ -59,3 +59,7 @@ Translate="翻訳"; "No favorites yet"="お気に入りがありません"; "Detail"="詳細"; "Filter"="フィルター"; +"Send Feedback"="フィードバックを送信"; +"Unable to Open Link"="リンクを開けません"; +"Copy Link"="リンクをコピー"; +"Please copy and open the feedback link manually."="フィードバックリンクをコピーして手動で開いてください。"; diff --git a/Projects/App/Resources/Strings/ko.lproj/Localizable.strings b/Projects/App/Resources/Strings/ko.lproj/Localizable.strings index 1d7582b..79c99e2 100644 --- a/Projects/App/Resources/Strings/ko.lproj/Localizable.strings +++ b/Projects/App/Resources/Strings/ko.lproj/Localizable.strings @@ -60,3 +60,7 @@ Translate="번역"; "No favorites yet"="즐겨찾기가 없습니다"; "Detail"="상세"; "Filter"="필터"; +"Send Feedback"="피드백 보내기"; +"Unable to Open Link"="링크를 열 수 없습니다"; +"Copy Link"="링크 복사"; +"Please copy and open the feedback link manually."="피드백 링크를 복사해 직접 열어주세요."; diff --git a/Projects/App/Resources/Strings/ru.lproj/Localizable.strings b/Projects/App/Resources/Strings/ru.lproj/Localizable.strings index 36b43fc..79723f7 100644 --- a/Projects/App/Resources/Strings/ru.lproj/Localizable.strings +++ b/Projects/App/Resources/Strings/ru.lproj/Localizable.strings @@ -59,3 +59,7 @@ Translate="Переведите"; "No favorites yet"="Нет избранного"; "Detail"="Подробности"; "Filter"="Фильтр"; +"Send Feedback"="Отправить отзыв"; +"Unable to Open Link"="Не удалось открыть ссылку"; +"Copy Link"="Копировать ссылку"; +"Please copy and open the feedback link manually."="Скопируйте ссылку обратной связи и откройте ее вручную."; diff --git a/Projects/App/Resources/Strings/th.lproj/Localizable.strings b/Projects/App/Resources/Strings/th.lproj/Localizable.strings index 44f3e4c..ecaae29 100644 --- a/Projects/App/Resources/Strings/th.lproj/Localizable.strings +++ b/Projects/App/Resources/Strings/th.lproj/Localizable.strings @@ -59,3 +59,7 @@ Translate="แปลความ"; "No favorites yet"="ยังไม่มีรายการโปรด"; "Detail"="รายละเอียด"; "Filter"="กรอง"; +"Send Feedback"="ส่งข้อเสนอแนะ"; +"Unable to Open Link"="ไม่สามารถเปิดลิงก์ได้"; +"Copy Link"="คัดลอกลิงก์"; +"Please copy and open the feedback link manually."="โปรดคัดลอกและเปิดลิงก์ข้อเสนอแนะด้วยตนเอง"; diff --git a/Projects/App/Resources/Strings/vi.lproj/Localizable.strings b/Projects/App/Resources/Strings/vi.lproj/Localizable.strings index 4eeea00..07c8e4c 100644 --- a/Projects/App/Resources/Strings/vi.lproj/Localizable.strings +++ b/Projects/App/Resources/Strings/vi.lproj/Localizable.strings @@ -59,4 +59,8 @@ Translate="Dịch"; "No favorites yet"="Chưa có mục yêu thích"; "Detail"="Chi tiết"; "Filter"="Lọc"; +"Send Feedback"="Gửi phản hồi"; +"Unable to Open Link"="Không thể mở liên kết"; +"Copy Link"="Sao chép liên kết"; +"Please copy and open the feedback link manually."="Vui lòng sao chép và mở liên kết phản hồi theo cách thủ công."; diff --git a/Projects/App/Resources/Strings/zh-Hans.lproj/Localizable.strings b/Projects/App/Resources/Strings/zh-Hans.lproj/Localizable.strings index 73b0c71..354b44d 100644 --- a/Projects/App/Resources/Strings/zh-Hans.lproj/Localizable.strings +++ b/Projects/App/Resources/Strings/zh-Hans.lproj/Localizable.strings @@ -59,3 +59,7 @@ Translate="翻译"; "No favorites yet"="暂无收藏"; "Detail"="详情"; "Filter"="筛选"; +"Send Feedback"="发送反馈"; +"Unable to Open Link"="无法打开链接"; +"Copy Link"="复制链接"; +"Please copy and open the feedback link manually."="请复制并手动打开反馈链接。"; diff --git a/Projects/App/Resources/Strings/zh-Hant-TW.lproj/Localizable.strings b/Projects/App/Resources/Strings/zh-Hant-TW.lproj/Localizable.strings index 59b0577..d6e607a 100644 --- a/Projects/App/Resources/Strings/zh-Hant-TW.lproj/Localizable.strings +++ b/Projects/App/Resources/Strings/zh-Hant-TW.lproj/Localizable.strings @@ -59,3 +59,7 @@ Translate="翻譯"; "No favorites yet"="尚無收藏"; "Detail"="詳情"; "Filter"="篩選"; +"Send Feedback"="傳送回饋"; +"Unable to Open Link"="無法開啟連結"; +"Copy Link"="複製連結"; +"Please copy and open the feedback link manually."="請複製並手動開啟回饋連結。"; diff --git a/Projects/App/Sources/App.swift b/Projects/App/Sources/App.swift index 0ba6da3..979847c 100644 --- a/Projects/App/Sources/App.swift +++ b/Projects/App/Sources/App.swift @@ -78,22 +78,26 @@ struct SendadvApp: App { } } - private func handleAppDidBecomeActive() { - print("scene become active") - Task { - defer { - LSDefaults.increaseLaunchCount() - } + private func handleAppDidBecomeActive() { + print("scene become active") + Task { @MainActor in + defer { + LSDefaults.increaseLaunchCount() + } - guard !SwiftUIAdManager.isDisabled else { - checkPendingReview() - return - } + guard !SwiftUIAdManager.isDisabled else { + checkPendingReview() + return + } - await adManager.show(unit: .launch) - checkPendingReview() - } - } + adManager.refreshAdFreeStatus() + + if !adManager.isAdFree { + await adManager.show(unit: .launch) + } + checkPendingReview() + } + } private func checkPendingReview() { guard LSDefaults.pendingReviewRequest else { return } diff --git a/Projects/App/Sources/Extensions/GoogleAds/BannerAdSwiftUIView.swift b/Projects/App/Sources/Extensions/GoogleAds/BannerAdSwiftUIView.swift index 6f83cfe..16f21d2 100644 --- a/Projects/App/Sources/Extensions/GoogleAds/BannerAdSwiftUIView.swift +++ b/Projects/App/Sources/Extensions/GoogleAds/BannerAdSwiftUIView.swift @@ -14,7 +14,7 @@ struct BannerAdSwiftUIView: View { var body: some View { Group { - if SwiftUIAdManager.isDisabled { + if SwiftUIAdManager.isDisabled || adManager.isAdFree { EmptyView() } else if let bannerView = coordinator.bannerView { BannerAdRepresentable(bannerView: bannerView) @@ -24,8 +24,16 @@ struct BannerAdSwiftUIView: View { } .onChange(of: adManager.isReady, initial: true) { _, isReady in guard isReady else { return } + guard !adManager.isAdFree else { return } coordinator.load(withAdManager: adManager) } + .onChange(of: adManager.isAdFree, initial: true) { _, isAdFree in + if isAdFree { + coordinator.reset() + } else if adManager.isReady { + coordinator.load(withAdManager: adManager) + } + } } } @@ -44,6 +52,11 @@ final class BannerAdCoordinator { banner.load(request) } } + + func reset() { + bannerView = nil + hasLoaded = false + } } private struct BannerAdRepresentable: UIViewRepresentable { diff --git a/Projects/App/Sources/Managers/SwiftUIAdManager.swift b/Projects/App/Sources/Managers/SwiftUIAdManager.swift index f401f0b..43d00f9 100644 --- a/Projects/App/Sources/Managers/SwiftUIAdManager.swift +++ b/Projects/App/Sources/Managers/SwiftUIAdManager.swift @@ -1,3 +1,4 @@ +import Foundation import Firebase import GADManager import GoogleMobileAds @@ -34,7 +35,14 @@ class SwiftUIAdManager: NSObject, ObservableObject { // 싱글톤 패턴으로 전역 접근 지원 static var shared: SwiftUIAdManager? - @Published var isReady: Bool = false + @Published var isReady: Bool = false + @Published private(set) var isAdFree: Bool = LSDefaults.isAdFree + + private var adFreeStatusTask: Task? + + deinit { + adFreeStatusTask?.cancel() + } func setup() { guard !SwiftUIAdManager.isDisabled else { return } @@ -49,6 +57,10 @@ class SwiftUIAdManager: NSObject, ObservableObject { // 싱글톤 인스턴스 설정 SwiftUIAdManager.shared = self self.isReady = true + Task { @MainActor in + self.refreshAdFreeStatus() + self.startAdFreeStatusTimer() + } } func prepare(interstitialUnit unit: GADUnitName, interval: TimeInterval) { @@ -74,7 +86,7 @@ class SwiftUIAdManager: NSObject, ObservableObject { @discardableResult func show(unit: GADUnitName) async -> Bool { guard !SwiftUIAdManager.isDisabled else { return false } - guard !LSDefaults.isAdFree else { return false } + guard !isAdFree else { return false } return await withCheckedContinuation { continuation in guard let gadManager else { @@ -94,7 +106,9 @@ class SwiftUIAdManager: NSObject, ObservableObject { guard let gadManager else { completion(false); return } gadManager.show(unit: .rewarded, needToWait: true, isTesting: isTesting(unit: .rewarded)) { _, _, rewarded in - completion(rewarded) + Task { @MainActor in + completion(rewarded) + } } } @@ -105,10 +119,30 @@ class SwiftUIAdManager: NSObject, ObservableObject { func createBannerAdView(withAdSize size: AdSize, forUnit unit: GADUnitName) -> BannerView? { guard !SwiftUIAdManager.isDisabled else { return nil } - guard !LSDefaults.isAdFree else { return nil } + guard !isAdFree else { return nil } return gadManager?.prepare(bannerUnit: unit, isTesting: self.isTesting(unit: unit), size: size) } + @MainActor + func refreshAdFreeStatus() { + let latestState = LSDefaults.isAdFree + guard latestState != isAdFree else { return } + isAdFree = latestState + } + + @MainActor + private func startAdFreeStatusTimer() { + adFreeStatusTask?.cancel() + + adFreeStatusTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(30)) + guard !Task.isCancelled else { return } + self?.refreshAdFreeStatus() + } + } + } + // MARK: - Testing Flags func isTesting(unit: GADUnitName) -> Bool { return testUnits.contains(unit) diff --git a/Projects/App/Sources/Screens/HistoryScreen.swift b/Projects/App/Sources/Screens/HistoryScreen.swift index 68fc2d4..1a9ff46 100644 --- a/Projects/App/Sources/Screens/HistoryScreen.swift +++ b/Projects/App/Sources/Screens/HistoryScreen.swift @@ -8,6 +8,7 @@ import SwiftUI import SwiftData +import UIKit // MARK: - HistoryScreen @@ -15,11 +16,15 @@ struct HistoryScreen: View { /// Called when user taps Re-translate on a detail sheet. let onRetranslate: (String, String, String, String) -> Void + static let feedbackIssueURLString = "https://github.com/2sem/talktrans/issues/new/choose" + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL @Environment(\.modelContext) private var modelContext @State private var filterMode: HistoryFilter = .all @State private var selectedEntry: TranslationEntry? + @State private var showFeedbackFallbackAlert = false var body: some View { NavigationStack { @@ -52,12 +57,26 @@ struct HistoryScreen: View { .navigationTitle("History".localized()) .navigationBarTitleDisplayMode(.large) .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(action: openFeedbackIssue) { + Image(systemName: "bubble.left.and.text.bubble.right") + } + .accessibilityLabel("Send Feedback".localized()) + } ToolbarItem(placement: .topBarTrailing) { Button("Done".localized()) { dismiss() } } } + .alert("Unable to Open Link".localized(), isPresented: $showFeedbackFallbackAlert) { + Button("Copy Link".localized()) { + UIPasteboard.general.string = Self.feedbackIssueURLString + } + Button("OK".localized(), role: .cancel) { } + } message: { + Text("Please copy and open the feedback link manually.".localized()) + } .sheet(item: $selectedEntry) { entry in HistoryDetailSheet(entry: entry) { sourceText, translatedText, sourceLang, targetLang in dismiss() @@ -69,6 +88,19 @@ struct HistoryScreen: View { } } + private func openFeedbackIssue() { + guard let issueURL = URL(string: Self.feedbackIssueURLString) else { + showFeedbackFallbackAlert = true + return + } + + openURL(issueURL) { accepted in + if !accepted { + showFeedbackFallbackAlert = true + } + } + } + private func toggleFavorite(_ entry: TranslationEntry) { entry.isFavorited.toggle() } diff --git a/Projects/App/Sources/Screens/TranslationScreen.swift b/Projects/App/Sources/Screens/TranslationScreen.swift index 8bf75e6..03e98b9 100644 --- a/Projects/App/Sources/Screens/TranslationScreen.swift +++ b/Projects/App/Sources/Screens/TranslationScreen.swift @@ -18,8 +18,8 @@ struct TranslationScreen: View { @Environment(\.modelContext) private var modelContext @State private var showSpeechRecognition = false @State private var showHistory = false - @State private var isAdFree: Bool = LSDefaults.isAdFree @State private var showAdFreeToast = false + @State private var lastHandledTranslationID: UUID? @FocusState private var isInputFocused: Bool init() { @@ -29,6 +29,8 @@ struct TranslationScreen: View { } var body: some View { + let sessionBindingRequestID = viewModel.sessionBindingRequestID + ZStack { // Gradient Background LinearGradient( @@ -61,8 +63,10 @@ struct TranslationScreen: View { ) .padding(16) - BannerAdSwiftUIView() - .frame(height: 50) + if !SwiftUIAdManager.isDisabled, !adManager.isAdFree { + BannerAdSwiftUIView() + .frame(height: 50) + } }.transition(.scale) } else { // Normal Mode - Show all UI elements @@ -87,9 +91,10 @@ struct TranslationScreen: View { .padding(.top, 20) } - // Advertisement Banner Placeholder - BannerAdSwiftUIView() - .frame(height: 50) + if !SwiftUIAdManager.isDisabled, !adManager.isAdFree { + BannerAdSwiftUIView() + .frame(height: 50) + } // Native Input Section TranslationInputView( @@ -159,7 +164,7 @@ struct TranslationScreen: View { .buttonStyle(.plain) // Watch Ad Button (hidden when ad-free) - WatchAdButton(isAdFree: $isAdFree) { + WatchAdButton { showAdFreeToast = true Task { try? await Task.sleep(for: .seconds(2)) @@ -186,14 +191,16 @@ struct TranslationScreen: View { .transition(.scale) } } - .onChange(of: viewModel.translatedText) { _, newValue in - guard !newValue.isEmpty else { return } + .onChange(of: viewModel.lastCompletedTranslationID) { _, newValue in + guard let translationID = newValue else { return } + guard translationID != lastHandledTranslationID else { return } + lastHandledTranslationID = translationID reviewManager.show() saveTranslationEntry() } .animation(.easeInOut, value: viewModel.isFullScreen) - .translationTask(viewModel.translationConfiguration) { session in - await viewModel.setTranslationSession(session) + .translationTask(viewModel.translationConfiguration) { [sessionBindingRequestID] session in + await viewModel.setTranslationSession(session, for: sessionBindingRequestID) } .sheet(isPresented: $showSpeechRecognition) { SpeechRecognitionScreen( diff --git a/Projects/App/Sources/ViewModels/SpeechRecognitionViewModel.swift b/Projects/App/Sources/ViewModels/SpeechRecognitionViewModel.swift index 7f681cd..eea4a19 100644 --- a/Projects/App/Sources/ViewModels/SpeechRecognitionViewModel.swift +++ b/Projects/App/Sources/ViewModels/SpeechRecognitionViewModel.swift @@ -20,6 +20,11 @@ class SpeechRecognitionViewModel: ObservableObject { private var audioEngine = AVAudioEngine() private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? private var recognitionTask: SFSpeechRecognitionTask? + + func resetSessionState() { + recognizedText = "" + errorMessage = nil + } func startRecognition(locale: Locale, onResult: @escaping (String) -> Void) { guard !isRecognizing else { return } @@ -77,6 +82,7 @@ class SpeechRecognitionViewModel: ObservableObject { audioEngine.prepare() try audioEngine.start() + resetSessionState() isRecognizing = true errorMessage = nil @@ -131,4 +137,3 @@ class SpeechRecognitionViewModel: ObservableObject { isRecognizing = false } } - diff --git a/Projects/App/Sources/ViewModels/TranslationViewModel.swift b/Projects/App/Sources/ViewModels/TranslationViewModel.swift index 06a5e4d..ff55abf 100644 --- a/Projects/App/Sources/ViewModels/TranslationViewModel.swift +++ b/Projects/App/Sources/ViewModels/TranslationViewModel.swift @@ -33,11 +33,16 @@ class TranslationViewModel: ObservableObject { @Published var translationConfiguration: TranslationSession.Configuration? @Published var isFullScreen: Bool = false @Published var deviceOrientation: UIDeviceOrientation = .portrait + @Published private(set) var lastCompletedTranslationID: UUID? private let translationManager = TranslationManager.shared private let maxTextLength = 100 private var orientationObserver: AnyCancellable? private var manualFullScreenToggle: Bool = false + private var translationExecutionGate = TranslationExecutionGate() + private var activeTranslationRequestID: UUID? + private(set) var sessionBindingRequestID: UUID? + var canTranslate: Bool { !nativeText.isEmpty && !isTranslating @@ -136,6 +141,9 @@ class TranslationViewModel: ObservableObject { isTranslating = true errorMessage = nil + let requestID = translationExecutionGate.beginRequest() + activeTranslationRequestID = requestID + sessionBindingRequestID = requestID // Recreate TranslationSession configuration when translate button is pressed. // Resetting to nil forces .translationTask to attach a fresh session, @@ -144,21 +152,47 @@ class TranslationViewModel: ObservableObject { createTranslationConfiguration(source: sourceLocale, target: targetLocale) } - func setTranslationSession(_ session: TranslationSession) async { - // Perform translation only with the session provided by .translationTask. + func setTranslationSession(_ session: TranslationSession, for requestID: UUID?) async { + // Perform translation only with the fresh session provided by .translationTask. + guard isValidSessionBindingRequestID(requestID) else { return } guard !nativeText.isEmpty else { return } - guard isTranslating else { return } // Only translate if translate() was called - + guard isTranslating else { return } + guard let requestID = requestID else { return } + guard translationExecutionGate.canExecute(requestID) else { return } + + let textToTranslate = nativeText + do { - let translated = try await translationManager.translate(text: nativeText, session: session) + let translated = try await translationManager.translate(text: textToTranslate, session: session) + guard activeTranslationRequestID == requestID else { return } translatedText = translated + lastCompletedTranslationID = requestID isTranslating = false LSDefaults.incrementTranslationCount() + translationExecutionGate.complete(requestID) + activeTranslationRequestID = nil + sessionBindingRequestID = nil } catch { + guard activeTranslationRequestID == requestID else { return } + if error is CancellationError { + isTranslating = false + translationExecutionGate.complete(requestID) + activeTranslationRequestID = nil + sessionBindingRequestID = nil + return + } errorMessage = error.localizedDescription isTranslating = false + translationExecutionGate.complete(requestID) + activeTranslationRequestID = nil + sessionBindingRequestID = nil } } + + func isValidSessionBindingRequestID(_ requestID: UUID?) -> Bool { + guard let requestID else { return false } + return requestID == activeTranslationRequestID && requestID == sessionBindingRequestID + } func swapLanguages() { @@ -228,3 +262,28 @@ extension UIDeviceOrientation { } } } + +struct TranslationExecutionGate { + private(set) var currentRequestID: UUID? + private var executingRequestID: UUID? + + mutating func beginRequest() -> UUID { + let requestID = UUID() + currentRequestID = requestID + executingRequestID = nil + return requestID + } + + mutating func canExecute(_ requestID: UUID) -> Bool { + guard currentRequestID == requestID else { return false } + guard executingRequestID != requestID else { return false } + executingRequestID = requestID + return true + } + + mutating func complete(_ requestID: UUID) { + guard currentRequestID == requestID else { return } + currentRequestID = nil + executingRequestID = nil + } +} diff --git a/Projects/App/Sources/Views/WatchAdButton.swift b/Projects/App/Sources/Views/WatchAdButton.swift index cc25a8c..78046ba 100644 --- a/Projects/App/Sources/Views/WatchAdButton.swift +++ b/Projects/App/Sources/Views/WatchAdButton.swift @@ -11,13 +11,12 @@ import SwiftUI struct WatchAdButton: View { @EnvironmentObject private var adManager: SwiftUIAdManager @EnvironmentObject private var analyticsManager: AnalyticsManager - @Binding var isAdFree: Bool var onAdFreeActivated: (() -> Void)? @State private var showConfirmation = false var body: some View { - if !isAdFree { + if !adManager.isAdFree { Button(action: { analyticsManager.logWatchAdTapped() showConfirmation = true @@ -43,9 +42,7 @@ struct WatchAdButton: View { adManager.showRewarded { rewarded in if rewarded { LSDefaults.activateAdFree() - withAnimation(.easeInOut(duration: 0.25)) { - isAdFree = true - } + adManager.refreshAdFreeStatus() onAdFreeActivated?() analyticsManager.logWatchAdCompleted() } else { diff --git a/Projects/App/Tests/TalktransTests.swift b/Projects/App/Tests/TalktransTests.swift index 8ed5a42..85721f6 100644 --- a/Projects/App/Tests/TalktransTests.swift +++ b/Projects/App/Tests/TalktransTests.swift @@ -1,10 +1,214 @@ import Testing +import Foundation @testable import App struct TalktransTests { - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - } + @Test func translationExecutionGate_preventsDuplicateExecutionPerRequest() { + var gate = TranslationExecutionGate() + let requestID = gate.beginRequest() + let firstExecution = gate.canExecute(requestID) + let secondExecution = gate.canExecute(requestID) + #expect(firstExecution) + #expect(!secondExecution) + } + + @Test func translationExecutionGate_ignoresStaleRequestAfterNewRequestBegins() { + var gate = TranslationExecutionGate() + let firstRequestID = gate.beginRequest() + _ = gate.canExecute(firstRequestID) + + let secondRequestID = gate.beginRequest() + let staleExecution = gate.canExecute(firstRequestID) + let newExecution = gate.canExecute(secondRequestID) + + #expect(!staleExecution) + #expect(newExecution) + } + + @Test @MainActor func translationViewModel_manualTranslatedTextMutation_doesNotEmitTranslationCompletionEvent() { + let viewModel = TranslationViewModel() + viewModel.translatedText = "History value" + + #expect(viewModel.lastCompletedTranslationID == nil) + } + + @Test @MainActor func speechRecognitionViewModel_resetSessionState_clearsRecognizedTextAndError() { + let viewModel = SpeechRecognitionViewModel() + viewModel.recognizedText = "stale transcript" + viewModel.errorMessage = "stale error" + + viewModel.resetSessionState() + + #expect(viewModel.recognizedText.isEmpty) + #expect(viewModel.errorMessage == nil) + } + + @Test @MainActor func speechRecognitionViewModel_startRecognitionWhileRecognizing_keepsCurrentTranscriptAndError() { + let viewModel = SpeechRecognitionViewModel() + viewModel.isRecognizing = true + viewModel.recognizedText = "in-progress transcript" + viewModel.errorMessage = "in-progress error" + + viewModel.startRecognition(locale: Locale(identifier: "en_US")) { _ in } + + #expect(viewModel.recognizedText == "in-progress transcript") + #expect(viewModel.errorMessage == "in-progress error") + } + + @Test @MainActor func speechRecognitionViewModel_startRecognitionFailure_doesNotClearExistingTranscript() { + let viewModel = SpeechRecognitionViewModel() + viewModel.recognizedText = "existing transcript" + + viewModel.startRecognition(locale: Locale(identifier: "zz_ZZ")) { _ in } + + #expect(viewModel.recognizedText == "existing transcript") + } + + @Test @MainActor func translationViewModel_translateWhileTranslating_doesNotResetErrorOrConfiguration() { + let viewModel = TranslationViewModel() + viewModel.nativeText = "Hello" + viewModel.isTranslating = true + viewModel.errorMessage = "existing error" + + viewModel.translate() + + #expect(viewModel.errorMessage == "existing error") + #expect(viewModel.translationConfiguration == nil) + } + + @Test @MainActor func translationViewModel_translate_createsSessionBindingRequestID() { + let viewModel = TranslationViewModel() + viewModel.nativeText = "Hello" + + viewModel.translate() + + #expect(viewModel.sessionBindingRequestID != nil) + } + + @Test @MainActor func translationViewModel_sessionBindingRequestIDValidation_rejectsStaleIDs() { + let viewModel = TranslationViewModel() + viewModel.nativeText = "Hello" + viewModel.translate() + let activeRequestID = viewModel.sessionBindingRequestID + + #expect(viewModel.isValidSessionBindingRequestID(activeRequestID)) + #expect(!viewModel.isValidSessionBindingRequestID(UUID())) + #expect(!viewModel.isValidSessionBindingRequestID(nil)) + } + + @Test func feedbackIssueURL_usesHistoryScreenProductionValue() { + let feedbackURL = URL(string: HistoryScreen.feedbackIssueURLString) + + #expect(feedbackURL?.scheme == "https") + #expect(feedbackURL?.host == "github.com") + #expect(feedbackURL?.path == "/2sem/talktrans/issues/new/choose") + #expect(feedbackURL?.query == nil) + } + + @Test func feedbackLocalizationValues_matchExpectedTranslations() throws { + let expected: [String: [String: String]] = [ + "Base": [ + "Send Feedback": "Send Feedback", + "Unable to Open Link": "Unable to Open Link", + "Copy Link": "Copy Link", + "Please copy and open the feedback link manually.": "Please copy and open the feedback link manually." + ], + "ko": [ + "Send Feedback": "피드백 보내기", + "Unable to Open Link": "링크를 열 수 없습니다", + "Copy Link": "링크 복사", + "Please copy and open the feedback link manually.": "피드백 링크를 복사해 직접 열어주세요." + ], + "de": [ + "Send Feedback": "Feedback senden", + "Unable to Open Link": "Link kann nicht geöffnet werden", + "Copy Link": "Link kopieren", + "Please copy and open the feedback link manually.": "Bitte kopieren Sie den Feedback-Link und öffnen Sie ihn manuell." + ], + "es": [ + "Send Feedback": "Enviar comentarios", + "Unable to Open Link": "No se puede abrir el enlace", + "Copy Link": "Copiar enlace", + "Please copy and open the feedback link manually.": "Copie y abra manualmente el enlace de comentarios." + ], + "fr": [ + "Send Feedback": "Envoyer des commentaires", + "Unable to Open Link": "Impossible d’ouvrir le lien", + "Copy Link": "Copier le lien", + "Please copy and open the feedback link manually.": "Veuillez copier et ouvrir manuellement le lien de commentaires." + ], + "id": [ + "Send Feedback": "Kirim masukan", + "Unable to Open Link": "Tidak dapat membuka tautan", + "Copy Link": "Salin tautan", + "Please copy and open the feedback link manually.": "Silakan salin dan buka tautan masukan secara manual." + ], + "it": [ + "Send Feedback": "Invia feedback", + "Unable to Open Link": "Impossibile aprire il link", + "Copy Link": "Copia link", + "Please copy and open the feedback link manually.": "Copia e apri manualmente il link di feedback." + ], + "ja": [ + "Send Feedback": "フィードバックを送信", + "Unable to Open Link": "リンクを開けません", + "Copy Link": "リンクをコピー", + "Please copy and open the feedback link manually.": "フィードバックリンクをコピーして手動で開いてください。" + ], + "ru": [ + "Send Feedback": "Отправить отзыв", + "Unable to Open Link": "Не удалось открыть ссылку", + "Copy Link": "Копировать ссылку", + "Please copy and open the feedback link manually.": "Скопируйте ссылку обратной связи и откройте ее вручную." + ], + "th": [ + "Send Feedback": "ส่งข้อเสนอแนะ", + "Unable to Open Link": "ไม่สามารถเปิดลิงก์ได้", + "Copy Link": "คัดลอกลิงก์", + "Please copy and open the feedback link manually.": "โปรดคัดลอกและเปิดลิงก์ข้อเสนอแนะด้วยตนเอง" + ], + "vi": [ + "Send Feedback": "Gửi phản hồi", + "Unable to Open Link": "Không thể mở liên kết", + "Copy Link": "Sao chép liên kết", + "Please copy and open the feedback link manually.": "Vui lòng sao chép và mở liên kết phản hồi theo cách thủ công." + ], + "zh-Hans": [ + "Send Feedback": "发送反馈", + "Unable to Open Link": "无法打开链接", + "Copy Link": "复制链接", + "Please copy and open the feedback link manually.": "请复制并手动打开反馈链接。" + ], + "zh-Hant-TW": [ + "Send Feedback": "傳送回饋", + "Unable to Open Link": "無法開啟連結", + "Copy Link": "複製連結", + "Please copy and open the feedback link manually.": "請複製並手動開啟回饋連結。" + ] + ] + + for (locale, localizedPairs) in expected { + let strings = try #require(localizedStrings(locale: locale)) + for (key, expectedValue) in localizedPairs { + #expect(strings[key] == expectedValue) + } + } + } + + private func localizedStrings(locale: String) -> [String: String]? { + let testsFileURL = URL(fileURLWithPath: #filePath) + let repoRootURL = testsFileURL + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let stringsURL = repoRootURL + .appendingPathComponent("Projects/App/Resources/Strings") + .appendingPathComponent("\(locale).lproj") + .appendingPathComponent("Localizable.strings") + + return NSDictionary(contentsOf: stringsURL) as? [String: String] + } } diff --git a/docs/plans/2026-04-01-2.2.1-stabilization.md b/docs/plans/2026-04-01-2.2.1-stabilization.md new file mode 100644 index 0000000..474c8b1 --- /dev/null +++ b/docs/plans/2026-04-01-2.2.1-stabilization.md @@ -0,0 +1,263 @@ +# 2.2.1 Stabilization Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Ship TalkTrans 2.2.1 as a low-risk stabilization release with a bug sweep, a lightweight in-app feedback entry point, a reusable manual QA checklist, and the App Store metadata quick wins already tracked in milestone `2.2.1`. + +**Architecture:** Keep app changes localized to the existing SwiftUI translation flow instead of introducing a new settings area or large navigation change. Reuse `TranslationScreen`, `TranslationInputView`, and existing app/service patterns so the release stays patch-safe. Document QA and ASO work as explicit deliverables alongside the code changes. + +**Tech Stack:** SwiftUI, SwiftData, Tuist (`mise x -- tuist ...`), GitHub Issues/Milestones, App Store Connect metadata. + +--- + +### Task 1: Triage 2.2.0 regressions into shippable patch scope + +**Files:** +- Review: `Projects/App/Sources/Screens/TranslationScreen.swift` +- Review: `Projects/App/Sources/Views/TranslationInputView.swift` +- Review: `Projects/App/Sources/Views/TranslationOutputView.swift` +- Review: `Projects/App/Sources/Screens/HistoryScreen.swift` +- Review: `Projects/App/Sources/Views/HistoryDetailSheet.swift` +- Review: `Projects/App/Sources/Screens/SpeechRecognitionScreen.swift` +- Review: `Projects/App/Sources/Managers/SwiftUIAdManager.swift` + +**Step 1: Build a regression checklist from issue #102** + +- Translation input/output +- History open/detail/re-translate/share +- Speech recognition start/stop/process +- Rewarded/ad-free flow +- App launch and resume basics + +**Step 2: Run the app manually and capture only patch-safe bugs** + +Run: `mise x -- tuist test` + +Expected: Current baseline test suite passes before any code changes. + +**Step 3: Split findings** + +- Patch-safe → stays in `2.2.1` +- Risky/new-feature-like → create follow-up issue for later milestone + +**Step 4: Update issue #102 with the approved bug list** + +- Add reproduction notes +- Mark blockers vs nice-to-have polish + + +### Task 2: Implement the confirmed 2.2.1 bug fixes + +**Files:** +- Modify only the smallest affected files identified in Task 1 +- Typical candidates: + - `Projects/App/Sources/Screens/TranslationScreen.swift` + - `Projects/App/Sources/Views/TranslationInputView.swift` + - `Projects/App/Sources/Views/TranslationOutputView.swift` + - `Projects/App/Sources/Screens/HistoryScreen.swift` + - `Projects/App/Sources/Views/HistoryDetailSheet.swift` + - `Projects/App/Sources/Screens/SpeechRecognitionScreen.swift` + +**Step 1: Write or expand tests for any bug with stable automated coverage potential** + +- Prefer ViewModel/logic coverage +- Avoid brittle UI snapshot-style tests for patch work + +**Step 2: Run the targeted failing test** + +Run: `mise x -- tuist test` + +Expected: Relevant failure reproduced if automated coverage was added. + +**Step 3: Implement the minimal fix** + +- No redesigns +- No unrelated refactors +- No new user flows unless required to close the bug + +**Step 4: Re-run tests** + +Run: `mise x -- tuist test` + +Expected: No new regressions in the baseline suite. + +**Step 5: Commit** + +```bash +git add +git commit -m "fix: stabilize 2.2.1 regression sweep" +``` + + +### Task 3: Add a lightweight in-app feedback entry point + +**Files:** +- Modify: `Projects/App/Sources/Screens/TranslationScreen.swift` +- Modify: `Projects/App/Sources/Views/TranslationInputView.swift` +- Create: `Projects/App/Sources/Views/FeedbackButton.swift` *(optional if extraction improves clarity)* +- Create: `Projects/App/Sources/Extensions/UIApplication+Feedback.swift` *(optional if URL/opening logic needs isolation)* + +**Step 1: Choose the lowest-risk surface** + +Recommended patch-safe option: +- Add a small feedback action near the existing history/control area in `TranslationInputView` +- Avoid introducing a full settings screen in `2.2.1` + +**Step 2: Write the smallest possible behavior test if logic is extracted** + +- Example: URL generation or availability fallback test +- Skip UI-only tests if they add more risk than value + +**Step 3: Implement the feedback path** + +Recommended behavior: +- Primary path: open a mailto/support URL or hosted feedback form +- Fallback: if unavailable, show a lightweight alert/toast with alternative contact guidance + +**Step 4: Verify on-device-safe behavior** + +- Feedback action is visible +- Translation flow is not blocked +- Failure case is graceful + +**Step 5: Re-run tests** + +Run: `mise x -- tuist test` + +Expected: Suite still passes. + +**Step 6: Commit** + +```bash +git add Projects/App/Sources/Screens/TranslationScreen.swift Projects/App/Sources/Views/TranslationInputView.swift Projects/App/Sources/Views/FeedbackButton.swift Projects/App/Sources/Extensions/UIApplication+Feedback.swift +git commit -m "feat: add lightweight in-app feedback entry point" +``` + + +### Task 4: Define and store the 2.2.1 manual QA checklist + +**Files:** +- Create: `docs/qa/2.2.1-manual-regression-checklist.md` + +**Step 1: Draft the checklist from issue #104** + +Include: +- App launch +- Translation happy path +- Unsupported/error state +- Speech recognition path +- History open/detail/re-translate/share +- Ad-free/watch-ad flow +- Feedback entry point + +**Step 2: Add pass/fail capture structure** + +Use a simple template: +- Area +- Scenario +- Expected result +- Result +- Notes + +**Step 3: Run the checklist once on a real/simulated release candidate build** + +Run: `mise x -- tuist test` + +Expected: Automated suite passes before manual sign-off starts. + +**Step 4: Commit** + +```bash +git add docs/qa/2.2.1-manual-regression-checklist.md +git commit -m "docs: add 2.2.1 manual QA checklist" +``` + + +### Task 5: Execute the App Store metadata quick wins + +**Files:** +- No repo code changes required +- Track completion in issue #76 + +**Step 1: Update metadata that is safe to ship now** + +Apply from issue #76: +- Subtitle +- Keywords +- Reorder currently available screenshots +- Localized descriptions that do not depend on unfinished UI + +**Step 2: Explicitly defer blocked screenshot slots** + +- Conversation Mode screenshot stays blocked on `2.3.0` / #71 +- History screenshot dependency is now unblocked by 2.2.0; verify actual asset quality before upload + +**Step 3: Record what changed in issue #76** + +- What was updated +- What remains deferred +- App Store Connect URLs/screens used + + +### Task 6: Final verification and release readiness for 2.2.1 + +**Files:** +- Review: `docs/qa/2.2.1-manual-regression-checklist.md` +- Review: all files changed in Tasks 2-3 + +**Step 1: Run the full project test suite** + +Run: `mise x -- tuist test` + +Expected: Success, 0 failures. + +**Step 2: Run the manual QA checklist** + +Expected: All release-blocking scenarios pass or have explicit follow-up issues. + +**Step 3: Review milestone scope before cutting the release branch** + +- `2.2.1` contains only #76, #102, #103, #104 +- Any stretch work is removed from the milestone + +**Step 4: Prepare release notes draft** + +Include: +- fixed regressions +- new feedback entry point +- store metadata/positioning updates if user-visible + +**Step 5: Commit remaining docs/status updates** + +```bash +git add +git commit -m "chore: prepare 2.2.1 release readiness" +``` + + +### Task 7: Branching and release handoff + +**Files:** +- No source file requirement + +**Step 1: Ensure feature/fix work is merged before release branch cut** + +Run: `git status --short --branch` + +Expected: Clean working tree before release operations. + +**Step 2: Cut release branch only when approved** + +Per repo rule: +- release branch name must be version only: `2.2.1` + +**Step 3: Final release commit on release branch** + +Per repo rule: +- only release bump commit on `2.2.1` +- commit message format: `bump: release 2.2.1` + +**Step 4: Create the release PR with patch notes** + +- Include all changes since 2.2.0 +- Include a vertical Mermaid flow diagram if user flow changed diff --git a/docs/plans/2026-04-01-appstore-screenshot-penpot-screens.md b/docs/plans/2026-04-01-appstore-screenshot-penpot-screens.md new file mode 100644 index 0000000..95cde11 --- /dev/null +++ b/docs/plans/2026-04-01-appstore-screenshot-penpot-screens.md @@ -0,0 +1,128 @@ +# App Store Screenshot Penpot Screens Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Create four screenshot-ready inner app screens in Penpot for the current `#76` App Store plan: landscape full translated text, voice input, language selection, and history. + +**Architecture:** Build the screens directly in the connected Penpot file as separate boards/artboards inside the current English page, keeping visual language consistent with TalkTrans and excluding device mockups or final App Store composite layouts. Reuse a shared screenshot style across all four boards so the set feels cohesive and easy to place into manual iPhone mock frames later. + +**Tech Stack:** Penpot MCP tools, existing `#76` screenshot plan, TalkTrans app visual styling. + +--- + +### Task 1: Create the landscape hero screen + +**Files:** +- Create in Penpot: `Frame 1` inner board named `Landscape App Screen` + +**Step 1: Remove any prior temporary landscape screen board** + +Expected: only one final `Landscape App Screen` remains. + +**Step 2: Create the landscape inner app board** + +- Size it for a landscape screenshot composition inside `Frame 1` +- Use TalkTrans-inspired gradient background +- Add generous rounded corners + +**Step 3: Add the translated hero text** + +- Use large centered Korean text +- Keep the screen uncluttered +- Suggested text: `서울역으로\n가 주세요` + +**Step 4: Verify readability** + +Expected: text is legible at thumbnail size and clearly communicates full-screen translation display. + + +### Task 2: Create the voice input screen + +**Files:** +- Create in Penpot: `Frame 2` inner board named `Voice Input Screen` + +**Step 1: Create the board using the same screenshot style system** + +- Match spacing, corner radius, and palette from Task 1 + +**Step 2: Add speech-recognition UI state** + +- Include active listening emphasis +- Include visible recognized phrase +- Keep enough structure to imply current shipped UI, not future Conversation Mode + +**Step 3: Add realistic speech content** + +- Suggested phrase: `How much is this?` + +**Step 4: Verify screenshot purpose** + +Expected: the board immediately communicates “speak naturally, translate instantly”. + + +### Task 3: Create the language selection screen + +**Files:** +- Create in Penpot: `Frame 3` inner board named `Language Selection Screen` + +**Step 1: Create the board using the same screenshot style system** + +**Step 2: Add a clean language picker layout** + +- Show multiple language rows with flags and names +- Ensure Korean, English, Japanese, and Chinese are clearly visible + +**Step 3: Keep content screenshot-focused** + +- Avoid excessive controls +- Emphasize breadth and clarity + +**Step 4: Verify screenshot purpose** + +Expected: the board communicates “13 languages, zero barriers”. + + +### Task 4: Create the history screen + +**Files:** +- Create in Penpot: `Frame 4` inner board named `History Screen` + +**Step 1: Create the board using the same screenshot style system** + +**Step 2: Add realistic history content** + +- Multiple saved rows +- Visible pairing of source/translated text +- Dates/times or grouping if it improves readability +- Optionally one favorite/starred item + +**Step 3: Keep layout clean for App Store use** + +- Prioritize recognizability over density + +**Step 4: Verify screenshot purpose** + +Expected: the board communicates “your translations, ready anytime”. + + +### Task 5: Final consistency review in Penpot + +**Files:** +- Review in Penpot: `Frame 1`, `Frame 2`, `Frame 3`, `Frame 4` + +**Step 1: Check visual consistency** + +- color harmony +- corner radius +- spacing rhythm +- typography hierarchy + +**Step 2: Check message hierarchy** + +- each board should communicate one idea only + +**Step 3: Confirm exclusions** + +- no iPhone mock +- no App Store caption layout +- no future Conversation Mode screen diff --git a/docs/qa/2.2.1-manual-regression-checklist.md b/docs/qa/2.2.1-manual-regression-checklist.md new file mode 100644 index 0000000..0199756 --- /dev/null +++ b/docs/qa/2.2.1-manual-regression-checklist.md @@ -0,0 +1,61 @@ +# TalkTrans 2.2.1 Manual Regression Checklist + +Use this lightweight checklist before shipping patch releases. Keep scope focused on stabilization-critical flows. + +## Pre-test Setup (5 minutes) + +1. Install the current release-candidate build. +2. Start from a clean app state (fresh install or clear app data). +3. Confirm network is ON. +4. Seed one history item: + - Source language: English + - Target language: Korean + - Input: `Hello` + - Tap Translate and confirm a result appears. +5. Prepare rewarded-ad state: + - Confirm app is **not** ad-free (ad-removal/watch-ad entry point is visible). + - Stay on the translation screen for ~10 seconds once to allow ad loading. + +## Run Info + +- Build/Branch: +- Tester: +- Device/OS: +- Date: + +## Result Legend + +- ✅ Pass +- ❌ Fail +- ⚠️ Blocked / Not run + +## Checklist + +| Gate | Area | Scenario | Expected result (observable pass/fail) | Result | Notes | +| --- | --- | --- | --- | --- | --- | +| Blocker | App launch | Fresh launch from home screen | Translation screen is visible within 3 seconds; no crash dialog; text input is tappable | | | +| Blocker | App launch | Background 10+ seconds then foreground | Returns to same screen; app accepts typing within 2 taps; no freeze/spinner lasting >3 seconds | | | +| Blocker | Translation happy path | Input `How are you?` and translate (network ON) | A non-empty translated result appears; source text remains visible; no error banner/dialog appears | | | +| Blocker | Translation happy path | Tap language swap, then translate `Good morning` | Source/target labels are swapped before request; output updates with a non-empty result after translate | | | +| Blocker | Unsupported / error state | Turn network OFF (Airplane mode or disable Wi-Fi/cellular), then translate `Network test` | Error UI appears within 5 seconds; app does not crash; translate control becomes tappable again after failure | | | +| Blocker | Speech recognition | Start speech recognition, say `hello talktrans`, stop | Input field contains recognized text (non-empty) after stop; app remains responsive | | | +| Blocker | Speech recognition | Start recognition, then cancel/close immediately | Recognition stops, recording indicator disappears, and translation screen remains interactive | | | +| Blocker | History flow | Open History screen (after pre-test seed step) | History list opens and contains at least 1 item (the seeded `Hello` translation) | | | +| Blocker | History flow | Open seeded history item detail | Detail view/sheet appears and shows non-empty source + translated text | | | +| Blocker | History flow | Tap re-translate from history detail | Returns/applies text to translation flow and produces a non-empty output when translated | | | +| Non-blocker | History flow | Tap share in history detail | iOS share sheet appears with text payload; sheet can be dismissed normally | | | +| Blocker | Ad-free / watch-ad | From non-ad-free state, tap ad-removal/watch-ad entry point | Rewarded flow entry opens (ad screen or loading UI) within 5 seconds; app does not freeze/crash | | | +| Blocker | Ad-free / watch-ad | Complete rewarded ad (or simulate completion path in RC env) | Ad flow closes; ad-free benefit/state is reflected in UI without restart or stuck loading | | | +| Non-blocker | Feedback entry point | Tap feedback action from translation controls area | Mail/form opens; if unavailable, fallback message with next action is visible and dismissible | | | + +## Sign-off + +- Overall result: ✅ Ready / ❌ Not ready +- Critical issues found: +- Follow-up issue links: + +## Reuse Notes for Future Patch Releases + +- Copy this file to the new patch version and adjust only version-specific scenarios. +- Keep this list short; add only flows that are patch-risk-sensitive. +- Track failures in GitHub issues with reproduction steps and device/OS info. diff --git a/fastlane/metadata/de-DE/description.txt b/fastlane/metadata/de-DE/description.txt new file mode 100644 index 0000000..14a9fe4 --- /dev/null +++ b/fastlane/metadata/de-DE/description.txt @@ -0,0 +1,6 @@ +TalkTrans hilft dir unterwegs bei Gesprächen von Angesicht zu Angesicht. Sprich oder tippe, übersetze sofort und zeige das Ergebnis in großer Schrift. + +• Sprach- und Textübersetzung +• Gesprächsmodus zum Vorzeigen (Vollbild) +• Übersetzungsverlauf automatisch gespeichert (nach Tagen) + Favoriten +• Bis zu 500 Zeichen pro Übersetzung diff --git a/fastlane/metadata/de-DE/keywords.txt b/fastlane/metadata/de-DE/keywords.txt new file mode 100644 index 0000000..2c07f0b --- /dev/null +++ b/fastlane/metadata/de-DE/keywords.txt @@ -0,0 +1 @@ +face to face,korean translator,travel,speech,papago,japanese,chinese,offline,language diff --git a/fastlane/metadata/de-DE/subtitle.txt b/fastlane/metadata/de-DE/subtitle.txt new file mode 100644 index 0000000..3f78fd7 --- /dev/null +++ b/fastlane/metadata/de-DE/subtitle.txt @@ -0,0 +1 @@ +Voice & Text Translator diff --git a/fastlane/metadata/en-US/description.txt b/fastlane/metadata/en-US/description.txt new file mode 100644 index 0000000..492e425 --- /dev/null +++ b/fastlane/metadata/en-US/description.txt @@ -0,0 +1,6 @@ +TalkTrans helps you communicate face to face while traveling. Speak or type to translate instantly, then show the result in large text for the other person. + +• Voice & text translation +• Face-to-face conversation mode (full-screen display) +• Translation history saved automatically (by day) + favorites +• Translate up to 500 characters at once diff --git a/fastlane/metadata/en-US/keywords.txt b/fastlane/metadata/en-US/keywords.txt new file mode 100644 index 0000000..2c07f0b --- /dev/null +++ b/fastlane/metadata/en-US/keywords.txt @@ -0,0 +1 @@ +face to face,korean translator,travel,speech,papago,japanese,chinese,offline,language diff --git a/fastlane/metadata/en-US/subtitle.txt b/fastlane/metadata/en-US/subtitle.txt new file mode 100644 index 0000000..3f78fd7 --- /dev/null +++ b/fastlane/metadata/en-US/subtitle.txt @@ -0,0 +1 @@ +Voice & Text Translator diff --git a/fastlane/metadata/es-ES/description.txt b/fastlane/metadata/es-ES/description.txt new file mode 100644 index 0000000..63ced8d --- /dev/null +++ b/fastlane/metadata/es-ES/description.txt @@ -0,0 +1,6 @@ +TalkTrans te ayuda a comunicarte cara a cara mientras viajas. Habla o escribe, traduce al instante y muestra el resultado en texto grande. + +• Traducción por voz y texto +• Modo conversación para mostrar (pantalla completa) +• Historial guardado automáticamente (por día) + favoritos +• Hasta 500 caracteres por traducción diff --git a/fastlane/metadata/es-ES/keywords.txt b/fastlane/metadata/es-ES/keywords.txt new file mode 100644 index 0000000..2c07f0b --- /dev/null +++ b/fastlane/metadata/es-ES/keywords.txt @@ -0,0 +1 @@ +face to face,korean translator,travel,speech,papago,japanese,chinese,offline,language diff --git a/fastlane/metadata/es-ES/subtitle.txt b/fastlane/metadata/es-ES/subtitle.txt new file mode 100644 index 0000000..3f78fd7 --- /dev/null +++ b/fastlane/metadata/es-ES/subtitle.txt @@ -0,0 +1 @@ +Voice & Text Translator diff --git a/fastlane/metadata/fr-FR/description.txt b/fastlane/metadata/fr-FR/description.txt new file mode 100644 index 0000000..7364d7a --- /dev/null +++ b/fastlane/metadata/fr-FR/description.txt @@ -0,0 +1,6 @@ +TalkTrans facilite les échanges en face à face en voyage. Parlez ou tapez, traduisez instantanément, puis montrez le résultat en grands caractères. + +• Traduction voix + texte +• Mode conversation à afficher (plein écran) +• Historique enregistré automatiquement (par jour) + favoris +• Jusqu’à 500 caractères par traduction diff --git a/fastlane/metadata/fr-FR/keywords.txt b/fastlane/metadata/fr-FR/keywords.txt new file mode 100644 index 0000000..2c07f0b --- /dev/null +++ b/fastlane/metadata/fr-FR/keywords.txt @@ -0,0 +1 @@ +face to face,korean translator,travel,speech,papago,japanese,chinese,offline,language diff --git a/fastlane/metadata/fr-FR/subtitle.txt b/fastlane/metadata/fr-FR/subtitle.txt new file mode 100644 index 0000000..3f78fd7 --- /dev/null +++ b/fastlane/metadata/fr-FR/subtitle.txt @@ -0,0 +1 @@ +Voice & Text Translator diff --git a/fastlane/metadata/id/description.txt b/fastlane/metadata/id/description.txt new file mode 100644 index 0000000..a98c7a9 --- /dev/null +++ b/fastlane/metadata/id/description.txt @@ -0,0 +1 @@ +Terjemahkan suara atau teks untuk percakapan cepat saat bepergian. Ucapkan kalimat Anda, lalu tunjukkan hasil terjemahan di layar penuh untuk komunikasi tatap muka. Semua terjemahan disimpan otomatis ke riwayat, jadi mudah dicari dan digunakan lagi kapan saja. diff --git a/fastlane/metadata/id/keywords.txt b/fastlane/metadata/id/keywords.txt new file mode 100644 index 0000000..2c07f0b --- /dev/null +++ b/fastlane/metadata/id/keywords.txt @@ -0,0 +1 @@ +face to face,korean translator,travel,speech,papago,japanese,chinese,offline,language diff --git a/fastlane/metadata/id/subtitle.txt b/fastlane/metadata/id/subtitle.txt new file mode 100644 index 0000000..6fe0362 --- /dev/null +++ b/fastlane/metadata/id/subtitle.txt @@ -0,0 +1 @@ +Penerjemah Suara & Teks diff --git a/fastlane/metadata/it/description.txt b/fastlane/metadata/it/description.txt new file mode 100644 index 0000000..3090469 --- /dev/null +++ b/fastlane/metadata/it/description.txt @@ -0,0 +1 @@ +Traduci con voce o testo per comunicare in viaggio. Parla o scrivi, poi mostra la traduzione a tutto schermo: ideale per conversazioni faccia a faccia. Ogni traduzione viene salvata automaticamente nella cronologia, così puoi ritrovarla e riutilizzarla in qualsiasi momento. diff --git a/fastlane/metadata/it/keywords.txt b/fastlane/metadata/it/keywords.txt new file mode 100644 index 0000000..2c07f0b --- /dev/null +++ b/fastlane/metadata/it/keywords.txt @@ -0,0 +1 @@ +face to face,korean translator,travel,speech,papago,japanese,chinese,offline,language diff --git a/fastlane/metadata/it/subtitle.txt b/fastlane/metadata/it/subtitle.txt new file mode 100644 index 0000000..58e6c05 --- /dev/null +++ b/fastlane/metadata/it/subtitle.txt @@ -0,0 +1 @@ +Traduttore voce e testo diff --git a/fastlane/metadata/ja/description.txt b/fastlane/metadata/ja/description.txt new file mode 100644 index 0000000..6009eeb --- /dev/null +++ b/fastlane/metadata/ja/description.txt @@ -0,0 +1,6 @@ +旅行中の「伝わらない」をTalkTransで解決。話す/入力するだけで即翻訳し、相手に見せやすい大きな文字で表示できます。 + +・音声&テキスト翻訳 +・見せる会話モード(全画面表示) +・翻訳履歴を自動保存(日別)+お気に入り +・1回あたり最大500文字まで翻訳 diff --git a/fastlane/metadata/ja/keywords.txt b/fastlane/metadata/ja/keywords.txt new file mode 100644 index 0000000..2c07f0b --- /dev/null +++ b/fastlane/metadata/ja/keywords.txt @@ -0,0 +1 @@ +face to face,korean translator,travel,speech,papago,japanese,chinese,offline,language diff --git a/fastlane/metadata/ja/subtitle.txt b/fastlane/metadata/ja/subtitle.txt new file mode 100644 index 0000000..3f78fd7 --- /dev/null +++ b/fastlane/metadata/ja/subtitle.txt @@ -0,0 +1 @@ +Voice & Text Translator diff --git a/fastlane/metadata/ko/description.txt b/fastlane/metadata/ko/description.txt new file mode 100644 index 0000000..051569c --- /dev/null +++ b/fastlane/metadata/ko/description.txt @@ -0,0 +1,6 @@ +여행 중 말이 막힐 때, TalkTrans로 바로 통역하세요. 말하거나 입력하면 즉시 번역되고, 상대방에게 보여주기 좋게 큰 글자로 표시할 수 있어요. + +• 음성/텍스트 번역 +• 보여주기 대화 모드(전체 화면) +• 번역 기록 자동 저장(날짜별) + 즐겨찾기 +• 한 번에 최대 500자 번역 diff --git a/fastlane/metadata/ko/keywords.txt b/fastlane/metadata/ko/keywords.txt new file mode 100644 index 0000000..2c07f0b --- /dev/null +++ b/fastlane/metadata/ko/keywords.txt @@ -0,0 +1 @@ +face to face,korean translator,travel,speech,papago,japanese,chinese,offline,language diff --git a/fastlane/metadata/ko/subtitle.txt b/fastlane/metadata/ko/subtitle.txt new file mode 100644 index 0000000..3f78fd7 --- /dev/null +++ b/fastlane/metadata/ko/subtitle.txt @@ -0,0 +1 @@ +Voice & Text Translator diff --git a/fastlane/metadata/ru/description.txt b/fastlane/metadata/ru/description.txt new file mode 100644 index 0000000..fe6d357 --- /dev/null +++ b/fastlane/metadata/ru/description.txt @@ -0,0 +1 @@ +Переводите голосом или текстом для общения в поездках. Скажите фразу или введите её — и покажите перевод на весь экран для разговора лицом к лицу. Все переводы автоматически сохраняются в истории, чтобы вы могли быстро найти и использовать их снова. diff --git a/fastlane/metadata/ru/keywords.txt b/fastlane/metadata/ru/keywords.txt new file mode 100644 index 0000000..2c07f0b --- /dev/null +++ b/fastlane/metadata/ru/keywords.txt @@ -0,0 +1 @@ +face to face,korean translator,travel,speech,papago,japanese,chinese,offline,language diff --git a/fastlane/metadata/ru/subtitle.txt b/fastlane/metadata/ru/subtitle.txt new file mode 100644 index 0000000..fa86777 --- /dev/null +++ b/fastlane/metadata/ru/subtitle.txt @@ -0,0 +1 @@ +Переводчик: голос и текст diff --git a/fastlane/metadata/th/description.txt b/fastlane/metadata/th/description.txt new file mode 100644 index 0000000..05f20e2 --- /dev/null +++ b/fastlane/metadata/th/description.txt @@ -0,0 +1 @@ +แปลได้ทั้งเสียงและข้อความ เพื่อคุยกันระหว่างเดินทาง พูดหรือพิมพ์ แล้วแสดงคำแปลแบบเต็มหน้าจอให้คู่สนทนาเห็น เหมาะสำหรับการสื่อสารแบบตัวต่อตัว ทุกการแปลจะถูกบันทึกอัตโนมัติในประวัติ ค้นหาและใช้ซ้ำได้ทุกเมื่อ diff --git a/fastlane/metadata/th/keywords.txt b/fastlane/metadata/th/keywords.txt new file mode 100644 index 0000000..2c07f0b --- /dev/null +++ b/fastlane/metadata/th/keywords.txt @@ -0,0 +1 @@ +face to face,korean translator,travel,speech,papago,japanese,chinese,offline,language diff --git a/fastlane/metadata/th/subtitle.txt b/fastlane/metadata/th/subtitle.txt new file mode 100644 index 0000000..7d915ec --- /dev/null +++ b/fastlane/metadata/th/subtitle.txt @@ -0,0 +1 @@ +แปลเสียงและข้อความ diff --git a/fastlane/metadata/vi/description.txt b/fastlane/metadata/vi/description.txt new file mode 100644 index 0000000..2e4b528 --- /dev/null +++ b/fastlane/metadata/vi/description.txt @@ -0,0 +1 @@ +Dịch bằng giọng nói hoặc văn bản để giao tiếp khi đi du lịch. Nói hoặc nhập câu, rồi hiển thị bản dịch toàn màn hình để trao đổi trực tiếp. Mọi bản dịch được lưu tự động vào lịch sử, giúp bạn dễ tìm lại và dùng lại bất cứ lúc nào. diff --git a/fastlane/metadata/vi/keywords.txt b/fastlane/metadata/vi/keywords.txt new file mode 100644 index 0000000..2c07f0b --- /dev/null +++ b/fastlane/metadata/vi/keywords.txt @@ -0,0 +1 @@ +face to face,korean translator,travel,speech,papago,japanese,chinese,offline,language diff --git a/fastlane/metadata/vi/subtitle.txt b/fastlane/metadata/vi/subtitle.txt new file mode 100644 index 0000000..327e43c --- /dev/null +++ b/fastlane/metadata/vi/subtitle.txt @@ -0,0 +1 @@ +Dịch giọng nói & văn bản diff --git a/fastlane/metadata/zh-Hans/description.txt b/fastlane/metadata/zh-Hans/description.txt new file mode 100644 index 0000000..e81680b --- /dev/null +++ b/fastlane/metadata/zh-Hans/description.txt @@ -0,0 +1,6 @@ +TalkTrans 让旅行中的面对面交流更顺畅。说出来或输入文字即可即时翻译,并用大字号全屏展示给对方。 + +• 语音与文字翻译 +• 面对面展示模式(全屏) +• 自动保存翻译记录(按日期)+ 收藏 +• 单次最多翻译 500 个字符 diff --git a/fastlane/metadata/zh-Hans/keywords.txt b/fastlane/metadata/zh-Hans/keywords.txt new file mode 100644 index 0000000..2c07f0b --- /dev/null +++ b/fastlane/metadata/zh-Hans/keywords.txt @@ -0,0 +1 @@ +face to face,korean translator,travel,speech,papago,japanese,chinese,offline,language diff --git a/fastlane/metadata/zh-Hans/subtitle.txt b/fastlane/metadata/zh-Hans/subtitle.txt new file mode 100644 index 0000000..3f78fd7 --- /dev/null +++ b/fastlane/metadata/zh-Hans/subtitle.txt @@ -0,0 +1 @@ +Voice & Text Translator diff --git a/fastlane/metadata/zh-Hant/description.txt b/fastlane/metadata/zh-Hant/description.txt new file mode 100644 index 0000000..5ad6781 --- /dev/null +++ b/fastlane/metadata/zh-Hant/description.txt @@ -0,0 +1,6 @@ +TalkTrans 讓旅行中的面對面溝通更順暢。說話或輸入文字即可即時翻譯,並以大字/全螢幕方便出示給對方。 + +• 語音與文字翻譯 +• 面對面出示模式(全螢幕) +• 自動儲存翻譯紀錄(按日期)+ 我的最愛 +• 單次最多翻譯 500 個字元 diff --git a/fastlane/metadata/zh-Hant/keywords.txt b/fastlane/metadata/zh-Hant/keywords.txt new file mode 100644 index 0000000..2c07f0b --- /dev/null +++ b/fastlane/metadata/zh-Hant/keywords.txt @@ -0,0 +1 @@ +face to face,korean translator,travel,speech,papago,japanese,chinese,offline,language diff --git a/fastlane/metadata/zh-Hant/subtitle.txt b/fastlane/metadata/zh-Hant/subtitle.txt new file mode 100644 index 0000000..3f78fd7 --- /dev/null +++ b/fastlane/metadata/zh-Hant/subtitle.txt @@ -0,0 +1 @@ +Voice & Text Translator From 4ee46766a7340fdf822fbe75e5c32dfdba950915 Mon Sep 17 00:00:00 2001 From: leesam Date: Sat, 4 Apr 2026 18:30:09 +0900 Subject: [PATCH 2/2] bump: release 2.2.1 --- Projects/App/Configs/app.debug.xcconfig | 2 +- Projects/App/Configs/app.release.xcconfig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Projects/App/Configs/app.debug.xcconfig b/Projects/App/Configs/app.debug.xcconfig index 7a0a860..b024d2e 100644 --- a/Projects/App/Configs/app.debug.xcconfig +++ b/Projects/App/Configs/app.debug.xcconfig @@ -5,7 +5,7 @@ CODE_SIGN_STYLE=Automatic CURRENT_PROJECT_VERSION=1 DEVELOPMENT_TEAM=M29A6H95KD INFOPLIST_FILE=talktrans/Info.plist -MARKETING_VERSION=2.2.0 +MARKETING_VERSION=2.2.1 PRODUCT_BUNDLE_IDENTIFIER=com.credif.talktrans PRODUCT_NAME=$(TARGET_NAME) PROVISIONING_PROFILE= diff --git a/Projects/App/Configs/app.release.xcconfig b/Projects/App/Configs/app.release.xcconfig index a2449ad..a31d18c 100644 --- a/Projects/App/Configs/app.release.xcconfig +++ b/Projects/App/Configs/app.release.xcconfig @@ -5,7 +5,7 @@ CODE_SIGN_STYLE=Automatic CURRENT_PROJECT_VERSION=1 DEVELOPMENT_TEAM=M29A6H95KD INFOPLIST_FILE=talktrans/Info.plist -MARKETING_VERSION=2.2.0 +MARKETING_VERSION=2.2.1 PRODUCT_BUNDLE_IDENTIFIER=com.credif.talktrans PRODUCT_NAME=$(TARGET_NAME) PROVISIONING_PROFILE=