feat: add deck, sidebar with controls and the main window layout
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import SstData
|
||||
import SstUI
|
||||
import SwiftUI
|
||||
|
||||
/// The transport deck: VU meter, counter, LED bar and the REC/STOP keys. Always visible at the top.
|
||||
struct DeckView: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: Layout.panelGap) {
|
||||
instruments
|
||||
Spacer(minLength: 0)
|
||||
controls
|
||||
}
|
||||
.padding(Layout.panelPadding)
|
||||
.background(PanelBackground())
|
||||
}
|
||||
|
||||
private var instruments: some View {
|
||||
TimelineView(.animation(minimumInterval: 1 / Motion.needleUpdateHz, paused: !model.isRecording)) { context in
|
||||
let dbfs = Double(model.levelSource.dbfs)
|
||||
HStack(alignment: .top, spacing: Layout.panelGap) {
|
||||
VUMeterView(levelDbfs: dbfs, isActive: model.isRecording, brand: AppIdentity.brandCaption)
|
||||
.frame(width: MeterTokens.vuWidth)
|
||||
VStack(alignment: .leading, spacing: Layout.panelGap) {
|
||||
SilkLabel(Strings.time, style: Typography.labelSmall)
|
||||
SegmentCounterView(StopwatchFormat.string(elapsed(at: context.date)))
|
||||
SilkLabel(Strings.level, style: Typography.labelSmall)
|
||||
LEDBarView(fraction: model.isRecording ? VUScale.fraction(forDbfs: dbfs) : 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func elapsed(at date: Date) -> TimeInterval {
|
||||
if let started = model.recordingStartedAt { return date.timeIntervalSince(started) }
|
||||
return model.lastDuration ?? 0
|
||||
}
|
||||
|
||||
private var controls: some View {
|
||||
VStack(alignment: .trailing, spacing: Layout.panelGap) {
|
||||
HStack(spacing: Layout.panelGap) {
|
||||
Button { model.toggleWindowRecording() } label: {
|
||||
HStack(spacing: Space.s3) {
|
||||
LampView(isOn: model.isRecording)
|
||||
Text(Strings.record)
|
||||
}
|
||||
}
|
||||
.buttonStyle(KeyButtonStyle(kind: .record, latched: model.isRecording))
|
||||
.disabled(!model.isRecording && model.recordingBlocker != nil)
|
||||
.help(model.recordingBlocker ?? "")
|
||||
|
||||
Button(Strings.stop) { model.stopWindowRecording() }
|
||||
.buttonStyle(KeyButtonStyle())
|
||||
.disabled(!model.isRecording)
|
||||
}
|
||||
if let error = model.lastError {
|
||||
HStack(spacing: Space.s3) {
|
||||
LampView(isOn: true, tint: .amber)
|
||||
Text(error)
|
||||
.textStyle(Typography.meta)
|
||||
.foregroundStyle(Palette.inkSecondary)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
} else {
|
||||
Text(model.statusLine)
|
||||
.textStyle(Typography.meta)
|
||||
.foregroundStyle(Palette.inkSecondary)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: Layout.sidebarWidth, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import SstData
|
||||
import SstUI
|
||||
import SwiftUI
|
||||
|
||||
// Temporary: replaced in Task 5.
|
||||
struct DictionaryView: View {
|
||||
@ObservedObject var list: DictionaryViewModel
|
||||
var body: some View { PaperBackground() }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import SstData
|
||||
import SstUI
|
||||
import SwiftUI
|
||||
|
||||
// Temporary: replaced in Task 4.
|
||||
struct HistoryView: View {
|
||||
@ObservedObject var list: HistoryViewModel
|
||||
var body: some View { PaperBackground() }
|
||||
}
|
||||
@@ -1,23 +1,29 @@
|
||||
import SwiftUI
|
||||
import SstUI
|
||||
import SwiftUI
|
||||
|
||||
/// Placeholder shell; phase 5 replaces the content with the deck, history and dictionary.
|
||||
struct MainWindow: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
BrushedBackground().ignoresSafeArea()
|
||||
VStack(alignment: .leading, spacing: Layout.panelGap) {
|
||||
SilkLabel(AppIdentity.brandCaption)
|
||||
Text(model.statusLine)
|
||||
.textStyle(Typography.reading)
|
||||
.foregroundStyle(Palette.inkPrimary)
|
||||
.padding(Layout.panelPadding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(PanelBackground())
|
||||
HStack(spacing: 0) {
|
||||
Sidebar()
|
||||
VStack(spacing: Layout.panelGap) {
|
||||
DeckView()
|
||||
switch model.section {
|
||||
case .transcripts: HistoryView(list: model.historyList)
|
||||
case .dictionary: DictionaryView(list: model.dictionaryList)
|
||||
}
|
||||
}
|
||||
.padding(Layout.panelPadding)
|
||||
}
|
||||
.padding(Layout.panelPadding)
|
||||
}
|
||||
.confirmationDialog(Strings.clearHistoryConfirm, isPresented: $model.confirmingClearHistory) {
|
||||
Button(Strings.erase, role: .destructive) { model.clearHistory() }
|
||||
Button(Strings.cancel, role: .cancel) {}
|
||||
} message: {
|
||||
Text(Strings.clearHistoryDetail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import SstUI
|
||||
import SwiftUI
|
||||
|
||||
struct Sidebar: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Layout.panelGap) {
|
||||
SilkLabel(AppIdentity.brandCaption)
|
||||
ForEach(AppModel.Section.allCases) { section in
|
||||
Button {
|
||||
model.section = section
|
||||
} label: {
|
||||
Text(title(for: section)).frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(KeyButtonStyle(latched: model.section == section))
|
||||
}
|
||||
Spacer()
|
||||
ControlsBlock()
|
||||
}
|
||||
.padding(Layout.panelPadding)
|
||||
.frame(width: Layout.sidebarWidth)
|
||||
}
|
||||
|
||||
private func title(for section: AppModel.Section) -> String {
|
||||
switch section {
|
||||
case .transcripts: Strings.transcripts
|
||||
case .dictionary: Strings.dictionary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Four lamps: the three macOS permissions and the speech model.
|
||||
struct ControlsBlock: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Space.s3) {
|
||||
SilkLabel(Strings.controls)
|
||||
SeamDivider()
|
||||
ForEach(PermissionMonitor.Kind.allCases) { kind in
|
||||
let granted = model.permissions.isGranted(kind)
|
||||
HStack(spacing: Space.s3) {
|
||||
LampView(isOn: granted, tint: .amber)
|
||||
Text(kind.title).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary)
|
||||
Spacer()
|
||||
if !granted {
|
||||
Button(Strings.open) { model.permissions.openSettings(for: kind) }
|
||||
.buttonStyle(KeyButtonStyle())
|
||||
.help(Strings.openSystemSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack(spacing: Space.s3) {
|
||||
LampView(isOn: model.modelReady, tint: .amber)
|
||||
Text(Strings.modelLamp).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.onAppear { model.permissions.startPolling() }
|
||||
.onDisappear { model.permissions.stopPolling() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user