Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions iosApp/iosApp/Base.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -350,9 +350,7 @@
"boardset.error.cell_update_failed" = "Could not update field.";
"boardset.error.cell_clear_failed" = "Could not clear field.";

"sentence.box.title" = "Sentence Box";
"sentence.box.delete_phrase" = "Delete phrase from sentence";
"sentence.box.speak" = "Speak sentence";
"sentence.box.delete_phrase" = "Remove from Message";

/* Azure F0 Setup */
"azure_setup.title" = "Azure Speech Service (Free Tier)";
Expand Down Expand Up @@ -540,3 +538,4 @@
"category.move_left" = "Move left";
"category.move_right" = "Move right";
"accessibility.category.menu_hint" = "Double tap to move or delete this category.";
"screens.message_bar" = "Message";
118 changes: 0 additions & 118 deletions iosApp/iosApp/Components/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,124 +100,6 @@ struct MultiLineInput: View {
}
}

struct SentenceBoxView: View {
let phrases: [SentencePhraseToken]
let onDelete: (Int) -> Void
var onSpeak: (() -> Void)? = nil
var animationNamespace: Namespace.ID? = nil
var animatedTokenId: String? = nil

var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("sentence.box.title")
.font(.subheadline)
.fontWeight(.semibold)

ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(Array(phrases.enumerated()), id: \.element.id) { index, token in
sentenceTokenCard(index: index, token: token)
}

if let onSpeak, !phrases.isEmpty {
Button(action: onSpeak) {
VStack(alignment: .center, spacing: 8) {
Image(systemName: "play.circle.fill")
.font(.system(size: 28, weight: .semibold))
Text("sentence.box.speak")
.font(.caption)
.multilineTextAlignment(.center)
.lineLimit(2)
.frame(maxWidth: .infinity)
}
.padding(.horizontal, 10)
.padding(.vertical, 10)
.frame(width: 108)
.frame(minHeight: 94)
.background(Color.accentColor.opacity(0.16))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.accentColor.opacity(0.5), lineWidth: 1)
)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("sentence.box.speak"))
}
}
.padding(.horizontal, 2)
.padding(.vertical, 2)
.animation(.spring(response: 0.38, dampingFraction: 0.82), value: phrases.map(\.id))
}
}
}

@ViewBuilder
private func sentenceTokenCard(index: Int, token: SentencePhraseToken) -> some View {
let card = ZStack(alignment: .topTrailing) {
VStack(alignment: .center, spacing: 8) {
if let imageUrl = token.imageUrl,
let url = URL(string: imageUrl) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFit()
.frame(width: 42, height: 42)
case .failure(_):
Image(systemName: "photo")
.foregroundStyle(.secondary)
.frame(width: 42, height: 42)
case .empty:
ProgressView()
.frame(width: 42, height: 42)
@unknown default:
EmptyView()
}
}
}

Text(token.title)
.font(.caption)
.multilineTextAlignment(.center)
.lineLimit(2)
.frame(maxWidth: .infinity)
}
.padding(.horizontal, 10)
.padding(.vertical, 10)
.frame(width: 108)
.frame(minHeight: 94)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color(.separator), lineWidth: 1)
)

Button {
onDelete(index)
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
.background(Color(.systemBackground), in: Circle())
}
.buttonStyle(.plain)
.padding(4)
.accessibilityLabel(Text("sentence.box.delete_phrase"))
}

if let animationNamespace, animatedTokenId == token.id {
card
.matchedGeometryEffect(id: token.id, in: animationNamespace, isSource: false)
.zIndex(2)
} else {
card
.transition(.move(edge: .trailing).combined(with: .opacity))
}
}
}

struct SelectableTextView: UIViewRepresentable {
@Binding var text: String
@Binding var selectedRange: NSRange
Expand Down
82 changes: 82 additions & 0 deletions iosApp/iosApp/Views/MessageBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,85 @@ struct HeldMessageRow: View {
.accessibilityHidden(model.scanningEnabled && !model.scanPlaybackAreaEnabled)
}
}

/// The Screens message bar: the Message's Buttons as symbol chips in the same
/// rounded field as Typing, followed by the same speech control.
struct ScreensMessageBar: View {
@ObservedObject var model: IosViewModel
let tokens: [SentencePhraseToken]
let showSpeakControl: Bool
let onDelete: (Int) -> Void
let onSpeak: () -> Void
var animationNamespace: Namespace.ID? = nil
var animatedTokenId: String? = nil
var iconSize: CGFloat = 36

private var buttonSize: CGFloat { min(max(iconSize * 1.55, 48), 72) }
private var speakSize: CGFloat { min(max(iconSize * 1.75, 56), 88) }

var body: some View {
HStack(alignment: .bottom, spacing: 8) {
ScrollViewReader { reader in
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(tokens.enumerated()), id: \.element.id) { index, token in
tokenChip(token, index: index)
.id(token.id)
}
}
.padding(8)
.animation(.spring(response: 0.38, dampingFraction: 0.82), value: tokens.map(\.id))
}
.onChange(of: tokens.count) { _, _ in
// Keep the newest Button in view.
if let last = tokens.last { withAnimation { reader.scrollTo(last.id, anchor: .trailing) } }
}
}
.frame(maxWidth: .infinity, minHeight: speakSize, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 16, style: .continuous).fill(Color(.secondarySystemFill)))
.accessibilityElement(children: .contain)
.accessibilityLabel(Text("screens.message_bar"))

if showSpeakControl {
SpeechControls(model: model, buttonSize: buttonSize, speakSize: speakSize, onSpeak: onSpeak)
}
}
}

@ViewBuilder
private func tokenChip(_ token: SentencePhraseToken, index: Int) -> some View {
let chip = HStack(spacing: 6) {
if let imageUrl = token.imageUrl, let url = URL(string: imageUrl) {
AsyncImage(url: url) { image in
image.resizable().scaledToFit()
} placeholder: {
Color.clear
}
.frame(width: 32, height: 32)
.accessibilityHidden(true)
}
Text(token.title.isEmpty ? " " : token.title)
.lineLimit(1)
Button { onDelete(index) } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
.frame(width: 32, height: 32)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text("sentence.box.delete_phrase"))
}
.padding(.leading, 10)
.padding(.trailing, 2)
.frame(minHeight: 44)
.background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color(.tertiarySystemBackground)))

if let animationNamespace, animatedTokenId == token.id {
chip
.matchedGeometryEffect(id: token.id, in: animationNamespace, isSource: false)
.zIndex(2)
} else {
chip.transition(.move(edge: .trailing).combined(with: .opacity))
}
}
}
29 changes: 15 additions & 14 deletions iosApp/iosApp/Views/SymbolBoardWorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -650,19 +650,19 @@ struct SymbolBoardWorkspaceView: View {
.padding(.horizontal)
}

// Sentence Box in Run mode
// Message bar in Run mode, in the same dialect as Typing.
if mode == .run && model.boardMessageBarVisible {
SentenceBoxView(
phrases: boardSentenceTokens,
ScreensMessageBar(
model: model,
tokens: boardSentenceTokens,
showSpeakControl: model.boardSpeakButtonVisible,
onDelete: { index in
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
model.removeMessagePart(at: index)
}
activeSentenceAnimation = nil
},
onSpeak: model.boardSpeakButtonVisible ? {
model.speakBoardMessage(boardSetId: boardSetId)
} : nil,
onSpeak: { model.speakBoardMessage(boardSetId: boardSetId) },
animationNamespace: sentenceAnimationNamespace,
animatedTokenId: activeSentenceAnimation?.tokenId
)
Expand All @@ -680,15 +680,15 @@ struct SymbolBoardWorkspaceView: View {
.onDisappear { model.stopBoardPredictions() }
.fullScreenCover(isPresented: $isFullscreen) {
VStack(spacing: 12) {
HStack {
HStack(alignment: .bottom) {
if model.boardMessageBarVisible {
SentenceBoxView(
phrases: boardSentenceTokens,
ScreensMessageBar(
model: model,
tokens: boardSentenceTokens,
showSpeakControl: model.boardSpeakButtonVisible,
onDelete: { index in model.removeMessagePart(at: index) },
onSpeak: model.boardSpeakButtonVisible ? {
model.speakBoardMessage(boardSetId: boardSetId)
} : nil
)
onSpeak: { model.speakBoardMessage(boardSetId: boardSetId) }
)
}
Button("common.done") { isFullscreen = false }
.font(.headline)
Expand Down Expand Up @@ -968,7 +968,8 @@ struct SymbolBoardWorkspaceView: View {

private func boardCellCornerRadius(_ cell: BoardCellInfo?, width: CGFloat, height: CGFloat) -> CGFloat {
switch cell?.shape {
case "rounded", "speech", "thought": return 10
// Rounded is the default shape (Android 945427e9), at 16pt like Typing tiles.
case "rounded", "speech", "thought": return 16
case "pill": return min(width, height) / 2
default: return 0
}
Expand Down
5 changes: 2 additions & 3 deletions iosApp/iosApp/da.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,7 @@
"boardset.cell.border_enabled" = "V忙lg rammens farve";
"boardset.cell.border_picker" = "Rammens farve";

"sentence.box.title" = "S忙tningsboks";
"sentence.box.delete_phrase" = "Slet frase fra s忙tning";
"sentence.box.speak" = "Sig s忙tning";
"sentence.box.delete_phrase" = "Fjern fra beskeden";

/* Azure F0 Setup */
"azure_setup.title" = "Ops忙tning af Azure TTS";
Expand Down Expand Up @@ -516,3 +514,4 @@
"category.move_left" = "Flyt til venstre";
"category.move_right" = "Flyt til h酶jre";
"accessibility.category.menu_hint" = "Dobbelttryk for at flytte eller slette denne kategori.";
"screens.message_bar" = "Besked";
5 changes: 2 additions & 3 deletions iosApp/iosApp/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,7 @@
"boardset.cell.border_enabled" = "Randfarbe setzen";
"boardset.cell.border_picker" = "Randfarbe";

"sentence.box.title" = "Satzfeld";
"sentence.box.delete_phrase" = "Phrase aus Satz loschen";
"sentence.box.speak" = "Satz sprechen";
"sentence.box.delete_phrase" = "Aus der Nachricht entfernen";

/* Azure F0 Setup */
"azure_setup.title" = "Azure TTS Einrichtung";
Expand Down Expand Up @@ -477,3 +475,4 @@
"category.move_left" = "Nach links";
"category.move_right" = "Nach rechts";
"accessibility.category.menu_hint" = "Doppeltippen, um diese Kategorie zu verschieben oder zu l枚schen.";
"screens.message_bar" = "Nachricht";
Loading