feat: tab shell, home/onboarding UI, settings and keyboard polish

Introduce MainTabView with history and Liquid Glass dock; refresh home and
onboarding layouts; unify accent green and provider localization; refine
settings/API rows; redesign keyboard mic and flanking controls; set Utility
iPhone-only targets and Flow session reliability fixes.
This commit is contained in:
Rocky
2026-06-20 00:31:58 +08:00
parent e2ccc10cfd
commit 38a36ed504
42 changed files with 1380 additions and 547 deletions
+133
View File
@@ -0,0 +1,133 @@
// HistoryView.swift
// OSGKeyboard · Main App
import SwiftUI
import OSGKeyboardShared
struct HistoryView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject private var store = SpeechHistoryStore.shared
@State private var showClearConfirm = false
private static let dayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .none
return f
}()
private static let timeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .none
f.timeStyle = .short
return f
}()
var body: some View {
NavigationStack {
ZStack {
palette.background.ignoresSafeArea()
if store.entries.isEmpty {
emptyState
} else {
VStack(spacing: 0) {
ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
ForEach(store.groupedByDay, id: \.day) { group in
daySection(day: group.day, items: group.items)
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.md)
.padding(.bottom, 100)
}
clearFooter
}
}
}
.navigationTitle(LocalizedStringKey("history.title"))
.navigationBarTitleDisplayMode(.inline)
}
.confirmationDialog(
LocalizedStringKey("history.clear.title"),
isPresented: $showClearConfirm,
titleVisibility: .visible
) {
Button(LocalizedStringKey("history.clear.confirm"), role: .destructive) {
store.clearAll()
}
Button(LocalizedStringKey("common.cancel"), role: .cancel) {}
} message: {
Text("history.clear.message")
}
}
private var emptyState: some View {
VStack(spacing: Spacing.sm) {
Spacer()
MaterialIcon(name: .menuBook, size: 36)
.foregroundStyle(palette.textTertiary.opacity(0.5))
Text("history.empty")
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
Spacer()
}
.padding(.horizontal, Spacing.xl)
}
private func daySection(day: Date, items: [SpeechHistoryEntry]) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(Self.dayFormatter.string(from: day))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.textCase(.uppercase)
.tracking(0.5)
VStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { index, entry in
historyRow(entry)
if index < items.count - 1 {
Divider().background(palette.divider)
}
}
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
private func historyRow(_ entry: SpeechHistoryEntry) -> some View {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(Self.timeFormatter.string(from: entry.createdAt))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.monospacedDigit()
Text(entry.text)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
}
private var clearFooter: some View {
Button(role: .destructive) {
showClearConfirm = true
} label: {
Text("history.clear.button")
.font(TypeStyle.caption)
.foregroundStyle(palette.danger)
.frame(maxWidth: .infinity, minHeight: 44)
}
.buttonStyle(.plain)
.padding(.horizontal, Spacing.md)
.padding(.bottom, Spacing.sm)
}
}