LanguageTranslatorView.swift 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. import SwiftUI
  2. struct LanguageTranslatorView: View {
  3. @StateObject private var viewModel = LanguageTranslatorViewModel()
  4. var body: some View {
  5. GeometryReader { proxy in
  6. let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
  7. let dynamicTopPadding = min(max(proxy.size.height * 0.05, 24), 44)
  8. let dynamicBottomPadding = min(max(proxy.size.height * 0.06, 28), 56)
  9. let dynamicPanelHeight = min(max(proxy.size.height * 0.22, 180), 280)
  10. ScrollView(.vertical, showsIndicators: false) {
  11. VStack(alignment: .leading, spacing: 0) {
  12. header
  13. .padding(.bottom, 28)
  14. contentCard(panelHeight: dynamicPanelHeight)
  15. .padding(.bottom, 24)
  16. GradientButton(title: "Translate") {
  17. viewModel.translate()
  18. }
  19. .disabled(!viewModel.canTranslate)
  20. .opacity(viewModel.canTranslate ? 1 : 0.55)
  21. }
  22. .frame(maxWidth: 1200, alignment: .topLeading)
  23. .frame(maxWidth: .infinity, alignment: .topLeading)
  24. .padding(.horizontal, dynamicHorizontalPadding)
  25. .padding(.top, dynamicTopPadding)
  26. .padding(.bottom, dynamicBottomPadding)
  27. }
  28. }
  29. .onChange(of: viewModel.sourceText) { newValue in
  30. if newValue.count > LanguageTranslatorViewModel.maxCharacters {
  31. viewModel.sourceText = String(newValue.prefix(LanguageTranslatorViewModel.maxCharacters))
  32. }
  33. }
  34. .onChange(of: viewModel.sourceLanguage) { _ in
  35. viewModel.syncTargetLanguageIfNeeded()
  36. }
  37. }
  38. private var header: some View {
  39. HStack(alignment: .top) {
  40. VStack(alignment: .leading, spacing: 8) {
  41. Text("Language Translator")
  42. .font(.system(size: 30, weight: .bold))
  43. .foregroundStyle(AppTheme.textPrimary)
  44. HStack(spacing: 0) {
  45. Text("Speak ")
  46. .foregroundStyle(AppTheme.textSecondary)
  47. Text("any language")
  48. .fontWeight(.semibold)
  49. .foregroundStyle(AppTheme.teal)
  50. Text(". Reach ")
  51. .foregroundStyle(AppTheme.textSecondary)
  52. Text("everyone")
  53. .fontWeight(.semibold)
  54. .foregroundStyle(AppTheme.teal)
  55. Text(".")
  56. .foregroundStyle(AppTheme.textSecondary)
  57. }
  58. .font(.system(size: 15))
  59. }
  60. Spacer()
  61. IconButton {
  62. SettingsGearIcon()
  63. } action: {}
  64. .accessibilityLabel("Settings")
  65. }
  66. }
  67. private func contentCard(panelHeight: CGFloat) -> some View {
  68. VStack(alignment: .leading, spacing: 16) {
  69. Image("YourContentLogo")
  70. .resizable()
  71. .aspectRatio(contentMode: .fit)
  72. .frame(height: 52, alignment: .topLeading)
  73. .frame(height: 46, alignment: .topLeading)
  74. .clipped()
  75. .accessibilityLabel("Your Content")
  76. sourcePanel(height: panelHeight)
  77. HStack(spacing: 8) {
  78. ToolbarButton(title: "Paste Text", iconName: "doc.on.clipboard") {
  79. viewModel.pasteText()
  80. }
  81. ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {
  82. viewModel.uploadFile()
  83. }
  84. Spacer()
  85. swapLanguagesButton
  86. ClearButton {
  87. viewModel.clearText()
  88. }
  89. }
  90. targetPanel(height: panelHeight)
  91. Text("Press ⌘ + Return to translate")
  92. .font(.system(size: 11))
  93. .foregroundStyle(AppTheme.textMuted)
  94. }
  95. .padding(22)
  96. .background(AppTheme.cardBackground)
  97. .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
  98. .overlay(
  99. RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
  100. .stroke(AppTheme.border, lineWidth: 1)
  101. )
  102. .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
  103. }
  104. private func sourcePanel(height: CGFloat) -> some View {
  105. VStack(alignment: .leading, spacing: 12) {
  106. LanguagePicker(
  107. title: "From",
  108. selection: $viewModel.sourceLanguage,
  109. languages: viewModel.sourceLanguageOptions
  110. )
  111. ZStack(alignment: .bottomTrailing) {
  112. ThinCaretTextEditor(text: $viewModel.sourceText, onSubmit: viewModel.translate)
  113. .font(.system(size: 14))
  114. .foregroundStyle(AppTheme.textPrimary)
  115. .frame(minHeight: height, idealHeight: height)
  116. .background(Color.white)
  117. .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
  118. .overlay(
  119. RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
  120. .stroke(AppTheme.inputBorder, lineWidth: 1)
  121. )
  122. .overlay(alignment: .topLeading) {
  123. if viewModel.sourceText.isEmpty {
  124. Text("Start writing...")
  125. .font(.system(size: 14))
  126. .foregroundStyle(AppTheme.textMuted)
  127. .padding(.leading, AppTheme.textEditorHorizontalInset)
  128. .padding(.top, AppTheme.textEditorVerticalInset)
  129. .allowsHitTesting(false)
  130. }
  131. }
  132. Text(viewModel.characterCountLabel)
  133. .font(.system(size: 11))
  134. .foregroundStyle(AppTheme.textMuted)
  135. .padding(.trailing, 16)
  136. .padding(.bottom, 12)
  137. }
  138. }
  139. .padding(16)
  140. .background(AppTheme.background)
  141. .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
  142. .overlay(
  143. RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
  144. .stroke(AppTheme.border.opacity(0.6), lineWidth: 1)
  145. )
  146. .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
  147. }
  148. private func targetPanel(height: CGFloat) -> some View {
  149. VStack(alignment: .leading, spacing: 12) {
  150. LanguagePicker(
  151. title: "To",
  152. selection: $viewModel.targetLanguage,
  153. languages: viewModel.targetLanguageOptions
  154. )
  155. ZStack(alignment: .topLeading) {
  156. ThinCaretTextEditor(
  157. text: $viewModel.translatedText,
  158. isEditable: false
  159. )
  160. .font(.system(size: 14))
  161. .foregroundStyle(AppTheme.textPrimary)
  162. .frame(minHeight: height, idealHeight: height)
  163. .background(Color.white)
  164. .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
  165. .overlay(
  166. RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
  167. .stroke(AppTheme.inputBorder, lineWidth: 1)
  168. )
  169. if viewModel.translatedText.isEmpty {
  170. Text(viewModel.isTranslating ? "Translating..." : "Translation will appear here...")
  171. .font(.system(size: 14))
  172. .foregroundStyle(AppTheme.textMuted)
  173. .padding(.leading, AppTheme.textEditorHorizontalInset)
  174. .padding(.top, AppTheme.textEditorVerticalInset)
  175. .allowsHitTesting(false)
  176. }
  177. }
  178. }
  179. .padding(16)
  180. .background(AppTheme.background)
  181. .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
  182. .overlay(
  183. RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
  184. .stroke(AppTheme.border.opacity(0.6), lineWidth: 1)
  185. )
  186. .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
  187. }
  188. private var swapLanguagesButton: some View {
  189. Button {
  190. viewModel.swapLanguages()
  191. } label: {
  192. Image(systemName: "arrow.up.arrow.down")
  193. .font(.system(size: 12, weight: .semibold))
  194. .foregroundStyle(AppTheme.textSecondary)
  195. .padding(.horizontal, 10)
  196. .padding(.vertical, 7)
  197. .background(Color.white)
  198. .clipShape(RoundedRectangle(cornerRadius: 8))
  199. .overlay(
  200. RoundedRectangle(cornerRadius: 8)
  201. .stroke(AppTheme.border, lineWidth: 1)
  202. )
  203. }
  204. .buttonStyle(.plain)
  205. .accessibilityLabel("Swap languages")
  206. }
  207. }
  208. private struct LanguagePicker: View {
  209. let title: String
  210. @Binding var selection: TranslationLanguage
  211. let languages: [TranslationLanguage]
  212. var body: some View {
  213. ToolbarMenuButton(
  214. title: "\(title): \(selection.name)",
  215. iconName: "globe"
  216. ) {
  217. ForEach(languages) { language in
  218. Button {
  219. selection = language
  220. } label: {
  221. HStack {
  222. Text(language.name)
  223. if selection == language {
  224. Spacer()
  225. Image(systemName: "checkmark")
  226. }
  227. }
  228. }
  229. }
  230. }
  231. .accessibilityLabel("\(title) language: \(selection.name)")
  232. }
  233. }