403 lines
16 KiB
Swift
403 lines
16 KiB
Swift
//
|
||
// TranslateView.swift
|
||
// AIGrammar
|
||
//
|
||
// Created by oscar on 2024/3/27.
|
||
//
|
||
|
||
import SwiftUI
|
||
import AVFoundation
|
||
import ToastUI
|
||
import FirebaseAnalytics
|
||
|
||
struct SubmitTextEditor: UIViewRepresentable {
|
||
@Binding var text: String
|
||
var onCommit: () -> Void
|
||
|
||
func makeUIView(context: Context) -> UITextView {
|
||
let textView = UITextView()
|
||
textView.delegate = context.coordinator
|
||
textView.font = UIFont.preferredFont(forTextStyle: .body)
|
||
textView.isScrollEnabled = true
|
||
textView.returnKeyType = .send // 设置键盘的返回键类型为发送
|
||
textView.enablesReturnKeyAutomatically = true
|
||
|
||
textView.backgroundColor = UIColor.white.withAlphaComponent(0.5) // 设置背景色为半透明的白色
|
||
textView.layer.cornerRadius = 10 // 设置圆角
|
||
textView.layer.borderColor = UIColor.blue.cgColor // 设置边框颜色为蓝色
|
||
textView.layer.borderWidth = 1 // 设置边框宽度
|
||
return textView
|
||
}
|
||
|
||
func updateUIView(_ textView: UITextView, context: Context) {
|
||
textView.text = text
|
||
}
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator(self)
|
||
}
|
||
|
||
class Coordinator: NSObject, UITextViewDelegate {
|
||
var parent: SubmitTextEditor
|
||
|
||
init(_ parent: SubmitTextEditor) {
|
||
self.parent = parent
|
||
}
|
||
|
||
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
|
||
if text == "\n" { // 检测到换行符,即用户按下了发送键
|
||
parent.onCommit()
|
||
textView.resignFirstResponder() // 收起键盘
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func textViewDidChange(_ textView: UITextView) {
|
||
parent.text = textView.text
|
||
}
|
||
}
|
||
}
|
||
|
||
struct TranslateView: View {
|
||
@EnvironmentObject var globalEnv: GlobalEnvironment // 引入环境对象
|
||
|
||
@State private var inputText = ""
|
||
@State private var translations: [Translation] = [
|
||
Translation(input: "This is demo text", translation: "这是演示文本"),
|
||
]
|
||
@State private var showMenu = false // 控制下拉菜单的显示
|
||
@FocusState private var isInputFocused: Bool // 状态变量来控制TextEditor的聚焦
|
||
|
||
@State private var currentLang = "Chinese" // 用来显示当前语言
|
||
@State private var langCode = "chs" // 默认语言代码
|
||
|
||
@State private var isLoading = false // 控制加载指示器的显示
|
||
@State private var showingToast = false // 控制是否显示toast
|
||
@State private var toastText = ""
|
||
|
||
var body: some View {
|
||
NavigationView {
|
||
ZStack {
|
||
// 设置背景色
|
||
Color.pink.opacity(0.2).edgesIgnoringSafeArea(.all)
|
||
|
||
VStack {
|
||
ScrollViewReader { scrollView in
|
||
ScrollView {
|
||
VStack(spacing: 10) {
|
||
ForEach(translations) { translation in
|
||
TranslateCardView(translation: translation, onEdit: onEdit, onCopy: onCopy, onFeedback: onFeedBack)
|
||
.id(translation.id) // Assign an ID to each card view
|
||
}
|
||
}
|
||
.padding()
|
||
}
|
||
.onChange(of: translations) { _ in
|
||
if let lastId = translations.last?.id {
|
||
withAnimation {
|
||
scrollView.scrollTo(lastId, anchor: .bottom) // Scroll to the last translation
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
SubmitTextEditor(text: $inputText, onCommit: {
|
||
// 清除输入内容前后的空格
|
||
let trimmedText = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
||
// 检查是否为空
|
||
if trimmedText.isEmpty {
|
||
showingToast = true
|
||
toastText = "Please enter some text."
|
||
return // 提前返回,不执行网络请求
|
||
}
|
||
|
||
// 检查长度是否超过200个字符
|
||
if trimmedText.count > globalEnvironment.MaxLenTranslate {
|
||
showingToast = true
|
||
toastText = "Input too long. Please re-enter the text."
|
||
return // 提前返回,不执行网络请求
|
||
}
|
||
|
||
// 检查是否含有非法字符
|
||
/*
|
||
if trimmedText.range(of: "[^a-zA-Z0-9 .,;:!?'\"@-]", options: .regularExpression) != nil {
|
||
showingToast = true
|
||
toastText = "Please enter valid characters."
|
||
return // 提前返回,不执行网络请求
|
||
}*/
|
||
if currentLang == "Chinese" {
|
||
let checkRes = CommonFunc.shared.validateInputEng(input: trimmedText)
|
||
if !checkRes.isValid {
|
||
showingToast = true
|
||
toastText = checkRes.message
|
||
return
|
||
}
|
||
} else {
|
||
let checkRes = CommonFunc.shared.validateChineseInput(input: trimmedText)
|
||
if !checkRes.isValid {
|
||
showingToast = true
|
||
toastText = checkRes.message
|
||
return
|
||
}
|
||
}
|
||
|
||
loadData()
|
||
NetworkManager.shared.translate(inputText: trimmedText, lang: langCode) { result in
|
||
switch result {
|
||
case .success(let translation):
|
||
addTranslation(output: translation.translation)
|
||
logger.info("translate succ.", context: ["input": inputText, "output":translation.translation, "lang":langCode])
|
||
case .failure(let error):
|
||
switch error {
|
||
case .businessError(let ret, let message):
|
||
// 业务错误,比如无免费可用次数等,需要处理逻辑。
|
||
logger.error("Business error - Ret: \(ret), Message: \(message)")
|
||
DispatchQueue.main.async {
|
||
showingToast = true
|
||
switch ret{
|
||
case globalEnvironment.RetCodeFreeLimited:
|
||
toastText = globalEnvironment.FreeLimitedToast
|
||
case globalEnvironment.RetCodeDirtyInput:
|
||
toastText = globalEnvironment.DirtyInputErrToast
|
||
default:
|
||
toastText = globalEnvironment.OtherServerErrToast
|
||
}
|
||
}
|
||
case .other(let error):
|
||
logger.error("network error occurred: \(error.localizedDescription)")
|
||
DispatchQueue.main.async {
|
||
showingToast = true
|
||
toastText = globalEnvironment.NetWorkErrToast
|
||
}
|
||
}
|
||
}
|
||
loadComplete()
|
||
}
|
||
})
|
||
.frame(height: 100) // 指定高度
|
||
.padding()
|
||
.focused($isInputFocused)
|
||
}
|
||
.toast(isPresented: $showingToast, dismissAfter: globalEnvironment.toastPresentMsNormal) {
|
||
HStack {
|
||
Image(systemName: "exclamationmark.bubble")
|
||
.foregroundColor(.yellow)
|
||
Text(toastText)
|
||
.foregroundColor(.black)
|
||
}
|
||
.padding()
|
||
.background(Color.white)
|
||
.cornerRadius(8)
|
||
.shadow(radius: 10)
|
||
}
|
||
|
||
|
||
// 加载指示器
|
||
if isLoading {
|
||
LoadingView()
|
||
}
|
||
}
|
||
.onTapGesture {
|
||
// 点击任何非TextField区域时收起键盘
|
||
self.hideKeyboard()
|
||
}
|
||
.navigationBarTitle("AI Translate", displayMode: .inline)
|
||
.navigationBarItems(trailing: Menu(currentLang) {
|
||
Button("English -> Chinese") {
|
||
currentLang = "Chinese"
|
||
langCode = "chs"
|
||
}
|
||
Button("Chinese -> English") {
|
||
currentLang = "English"
|
||
langCode = "eng"
|
||
}
|
||
})
|
||
}
|
||
}
|
||
// 模拟加载数据
|
||
func loadData() {
|
||
isLoading = true
|
||
}
|
||
func loadComplete(){
|
||
isLoading = false
|
||
}
|
||
|
||
private func onEdit(text : String){
|
||
self.inputText = text
|
||
self.isInputFocused = true
|
||
}
|
||
private func onCopy(text : String){
|
||
UIPasteboard.general.string = text
|
||
}
|
||
private func onFeedBack( feedback: TranslateFeedback){
|
||
let fb:Bool = feedback.good ? true : false
|
||
NetworkManager.shared.sendFeedback(input: feedback.input, output: feedback.translation, isPositive: fb)
|
||
}
|
||
|
||
private func addTranslation(output: String) {
|
||
let newTranslation = Translation(input: inputText, translation: output)
|
||
translations.append(newTranslation)
|
||
if translations.count > 10 {
|
||
translations.removeFirst()
|
||
}
|
||
inputText = "" // 清空输入框
|
||
}
|
||
|
||
private func hideKeyboard() {
|
||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||
}
|
||
}
|
||
|
||
/*
|
||
struct Translation: Identifiable, Equatable {
|
||
let id = UUID()
|
||
var input: String
|
||
var translation: String
|
||
}
|
||
*/
|
||
|
||
|
||
struct TranslateFeedback {
|
||
var input: String
|
||
var translation: String
|
||
var good: Bool = false
|
||
var bad: Bool = false
|
||
}
|
||
|
||
|
||
struct TranslateCardView: View {
|
||
var translation: Translation
|
||
var onEdit: (String) -> Void
|
||
var onCopy: (String) -> Void
|
||
var onFeedback: (TranslateFeedback) -> Void
|
||
|
||
// 创建委托类的实例
|
||
@State private var isSynthesizing = false
|
||
|
||
var body: some View {
|
||
VStack {
|
||
// 上半部分
|
||
VStack(alignment: .leading) {
|
||
Text("Input")
|
||
.font(.system(size: UIFont.systemFontSize * 0.8))
|
||
.foregroundColor(Color.black.opacity(0.6))
|
||
.padding(.top, 0) // 留出顶部间距
|
||
.padding(.bottom,3) // 减少与原文之间的间距
|
||
Text(translation.input)
|
||
.padding(.bottom, 3)
|
||
HStack {
|
||
|
||
Button(action: {
|
||
synthesizeAndPlay(text: translation.input, lang: "Original")
|
||
}) {
|
||
Image(systemName: "speaker.wave.2")
|
||
.foregroundColor(.blue)
|
||
.padding(.trailing)
|
||
}
|
||
|
||
Spacer()
|
||
Button(action: {
|
||
onEdit(translation.input)
|
||
}) {
|
||
Image(systemName: "square.and.pencil")
|
||
.foregroundColor(.gray)
|
||
.frame(width: 15, height: 15) // 缩小图标大小
|
||
}
|
||
}
|
||
}
|
||
.padding([.horizontal, .top])
|
||
.padding(.bottom, 3) // 减少与原文之间的间距
|
||
|
||
Divider().dashed()
|
||
|
||
// 下半部分
|
||
VStack(alignment: .leading) {
|
||
Text(translation.translation)
|
||
.padding(.vertical, 1) // 减少与分割线的间距
|
||
.padding(.bottom, 3) // 减少与原文之间的间距
|
||
HStack {
|
||
|
||
Button(action: {
|
||
synthesizeAndPlay(text: translation.translation, lang: "Translated")
|
||
}) {
|
||
Image(systemName: "speaker.wave.2")
|
||
.foregroundColor(.blue)
|
||
}
|
||
|
||
Spacer()
|
||
HStack(spacing: 15) {// 减小图标之间的间距
|
||
Button(action: {
|
||
onCopy(translation.translation)
|
||
}) {
|
||
Image(systemName: "doc.on.doc")
|
||
.resizable()
|
||
.scaledToFit()
|
||
.frame(width: 15, height: 15) // 缩小图标大小
|
||
}
|
||
|
||
Button(action: {
|
||
onFeedback(TranslateFeedback(input: translation.input, translation: translation.translation, good: true))
|
||
}) {
|
||
Image(systemName: "hand.thumbsup")
|
||
.resizable()
|
||
.scaledToFit()
|
||
.frame(width: 15, height: 15) // 缩小图标大小
|
||
}
|
||
|
||
|
||
Button(action: {
|
||
onFeedback(TranslateFeedback(input: translation.input, translation: translation.translation, bad: true))
|
||
}) {
|
||
Image(systemName: "hand.thumbsdown")
|
||
.resizable()
|
||
.scaledToFit()
|
||
.frame(width: 15, height: 15) // 缩小图标大小
|
||
}
|
||
}
|
||
}
|
||
.padding(.bottom, 1) // 减少与原文之间的间距
|
||
}
|
||
.padding()
|
||
}
|
||
.padding(0)
|
||
.background(Color.gray.opacity(0.2)) // 设置整个卡片的背景色为LightGray
|
||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gray, lineWidth: 1))
|
||
}
|
||
|
||
func synthesizeAndPlay(text: String, lang: String) {
|
||
// 记录事件
|
||
Analytics.logEvent("TTSClick", parameters: [
|
||
"vendor": "TencentTTS",
|
||
"lang" : lang
|
||
])
|
||
|
||
isSynthesizing = true
|
||
TTSManager.shared.synthesizeAndPlay(text: text, isVIP: globalEnvironment.isVip, onSuccess: {
|
||
isSynthesizing = false
|
||
logger.info("Synthesis succ.")
|
||
}, onFailure: { error in
|
||
isSynthesizing = false
|
||
logger.info("Synthesis error: \(error)")
|
||
})
|
||
}
|
||
|
||
}
|
||
|
||
|
||
extension View {
|
||
func dashed() -> some View {
|
||
self.overlay(
|
||
Rectangle()
|
||
.fill(Color.clear)
|
||
//.border( StrokeStyle(lineWidth: 1, dash: [5]))
|
||
)
|
||
}
|
||
}
|
||
|
||
|
||
#Preview {
|
||
TranslateView()
|
||
}
|