From 3afcba0d3658c0b6c86bb8f17ee50934c154f1d7 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Tue, 18 Aug 2026 15:56:51 +0200 Subject: [PATCH] Polish igiRadio UI with premium audio, home, and FM screens. Adds graphic EQ, vertical mixer faders, redesigned home and FM layouts, and an Audio tab for quicker access to DSP controls. Co-authored-by: Cursor --- .../igiRadio.xcodeproj/project.pbxproj | 134 +++--- .../igiRadio/Components/AudioComponents.swift | 435 ++++++++++++++++++ .../igiRadio/Components/RadioComponents.swift | 270 +++++++++++ .../igiRadio/ViewModels/AudioViewModel.swift | 75 +++ .../igiRadio/igiRadio/Views/AudioView.swift | 279 ++++++++--- .../igiRadio/igiRadio/Views/FMRadioView.swift | 220 +++++---- .../igiRadio/igiRadio/Views/HomeView.swift | 209 +++++---- .../igiRadio/igiRadio/Views/RootView.swift | 3 + .../igiRadio/Views/SettingsViews.swift | 6 +- 9 files changed, 1311 insertions(+), 320 deletions(-) create mode 100644 Software/APP/igiRadio/igiRadio/Components/AudioComponents.swift create mode 100644 Software/APP/igiRadio/igiRadio/Components/RadioComponents.swift create mode 100644 Software/APP/igiRadio/igiRadio/ViewModels/AudioViewModel.swift diff --git a/Software/APP/igiRadio/igiRadio.xcodeproj/project.pbxproj b/Software/APP/igiRadio/igiRadio.xcodeproj/project.pbxproj index cd105b9..c944f63 100644 --- a/Software/APP/igiRadio/igiRadio.xcodeproj/project.pbxproj +++ b/Software/APP/igiRadio/igiRadio.xcodeproj/project.pbxproj @@ -3,12 +3,9 @@ archiveVersion = 1; classes = { }; - objectVersion = 77; + objectVersion = 71; objects = { -/* Begin PBXBuildFile section */ -/* End PBXBuildFile section */ - /* Begin PBXContainerItemProxy section */ C050F319BDFADBF7CE6700F8 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -25,7 +22,7 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - 7D085337BF0EA6EB04337F45 /* Exceptions for "igiRadio" folder in "igiRadio" target */ = { + 7D085337BF0EA6EB04337F45 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Resources/Info.plist, @@ -35,30 +32,19 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - F7E3CB0B027CB68EA63A4AB8 /* igiRadio */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - 7D085337BF0EA6EB04337F45 /* Exceptions for "igiRadio" folder in "igiRadio" target */, - ); - path = igiRadio; - sourceTree = ""; - }; - 693436FA1780106DA05D06E3 /* igiRadioTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = Tests/igiRadioTests; - sourceTree = ""; - }; + 693436FA1780106DA05D06E3 /* Tests/igiRadioTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests/igiRadioTests; sourceTree = ""; }; + F7E3CB0B027CB68EA63A4AB8 /* igiRadio */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (7D085337BF0EA6EB04337F45 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = igiRadio; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ - 71F357EA3EEC95DAEFCF7345 /* Frameworks */ = { + 0A8032441559F0A3B8F103F8 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - 0A8032441559F0A3B8F103F8 /* Frameworks */ = { + 71F357EA3EEC95DAEFCF7345 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( @@ -81,7 +67,7 @@ isa = PBXGroup; children = ( F7E3CB0B027CB68EA63A4AB8 /* igiRadio */, - 693436FA1780106DA05D06E3 /* igiRadioTests */, + 693436FA1780106DA05D06E3 /* Tests/igiRadioTests */, 2761999F42D5E1910DD2B679 /* Products */, ); sourceTree = ""; @@ -122,7 +108,7 @@ 0E0716BAD2A3DFD42CA27715 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( - 693436FA1780106DA05D06E3 /* igiRadioTests */, + 693436FA1780106DA05D06E3 /* Tests/igiRadioTests */, ); name = igiRadioTests; productName = igiRadioTests; @@ -203,24 +189,6 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - FDBD275B398C4976FD6BC80D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ENABLE_MODULES = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_TESTABILITY = YES; - GCC_OPTIMIZATION_LEVEL = 0; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; 54274B7C1572A50D5BE0DEA8 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -262,31 +230,6 @@ }; name = Debug; }; - FA1BD134133059F9163AC34F /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = igiRadio/Resources/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.digiradio.igiRadio; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; 90735310944459417946066C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -327,9 +270,61 @@ }; name = Release; }; + FA1BD134133059F9163AC34F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = igiRadio/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.digiradio.igiRadio; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + FDBD275B398C4976FD6BC80D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_TESTABILITY = YES; + GCC_OPTIMIZATION_LEVEL = 0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 61D281E9594B71DA372FEE34 /* Build configuration list for PBXNativeTarget "igiRadioTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 90735310944459417946066C /* Debug */, + E6AD029DFF2D9078F77BDCBA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; C8A2C2BD01CBC6B9DFC6F199 /* Build configuration list for PBXProject "igiRadio" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -348,15 +343,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 61D281E9594B71DA372FEE34 /* Build configuration list for PBXNativeTarget "igiRadioTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 90735310944459417946066C /* Debug */, - E6AD029DFF2D9078F77BDCBA /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ }; rootObject = D7ACBDA7D90DDE7BBA02A41B /* Project object */; diff --git a/Software/APP/igiRadio/igiRadio/Components/AudioComponents.swift b/Software/APP/igiRadio/igiRadio/Components/AudioComponents.swift new file mode 100644 index 0000000..b0954ea --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Components/AudioComponents.swift @@ -0,0 +1,435 @@ +import SwiftUI + +// MARK: - Vertical fader (mixer channels) + +struct IGIVerticalFader: View { + var label: String + var icon: String + @Binding var valueDb: Double + var range: ClosedRange = -40 ... 12 + var onCommit: () -> Void + + private let trackHeight: CGFloat = 160 + + var body: some View { + VStack(spacing: IGITheme.spacingS) { + Text(formattedDb) + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(valueDb > 0 ? IGITheme.accent : .secondary) + .contentTransition(.numericText()) + .animation(.snappy, value: valueDb) + + GeometryReader { geo in + let h = geo.size.height + ZStack(alignment: .bottom) { + Capsule() + .fill(Color.primary.opacity(0.08)) + Capsule() + .fill( + LinearGradient( + colors: [IGITheme.accent.opacity(0.35), IGITheme.accent], + startPoint: .bottom, + endPoint: .top + ) + ) + .frame(height: fillHeight(total: h)) + } + .overlay(alignment: .top) { + Circle() + .fill(.background) + .shadow(color: .black.opacity(0.18), radius: 4, y: 2) + .overlay(Circle().stroke(IGITheme.accent.opacity(0.5), lineWidth: 2)) + .frame(width: 28, height: 28) + .offset(y: thumbOffset(total: h)) + } + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { drag in + valueDb = db(from: drag.location.y, height: h) + } + .onEnded { _ in onCommit() } + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(label), \(formattedDb)") + .accessibilityAdjustableAction { direction in + let step = 0.5 + switch direction { + case .increment: valueDb = min(range.upperBound, valueDb + step) + case .decrement: valueDb = max(range.lowerBound, valueDb - step) + @unknown default: break + } + onCommit() + } + } + .frame(height: trackHeight) + + Image(systemName: icon) + .font(.caption) + .foregroundStyle(.secondary) + Text(label) + .font(.caption2.weight(.medium)) + .multilineTextAlignment(.center) + .lineLimit(2) + .frame(width: 52) + } + .frame(width: 64) + } + + private var formattedDb: String { + valueDb >= 0 ? String(format: "+%.1f", valueDb) : String(format: "%.1f", valueDb) + } + + private func normalized(_ db: Double) -> Double { + (db - range.lowerBound) / (range.upperBound - range.lowerBound) + } + + private func fillHeight(total: CGFloat) -> CGFloat { + max(4, total * normalized(valueDb)) + } + + private func thumbOffset(total: CGFloat) -> CGFloat { + let travel = total - 28 + return travel * (1 - normalized(valueDb)) + } + + private func db(from y: CGFloat, height: CGFloat) -> Double { + let clamped = max(0, min(height, y)) + let norm = 1 - (clamped / height) + let raw = range.lowerBound + norm * (range.upperBound - range.lowerBound) + return (raw * 2).rounded() / 2 + } +} + +// MARK: - Mixer channel group + +struct IGIMixerChannelGroup: View { + var title: String + var subtitle: String + var systemImage: String + @Binding var leftDb: Double + @Binding var rightDb: Double + var onCommit: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: IGITheme.spacingM) { + HStack(spacing: IGITheme.spacingS) { + Image(systemName: systemImage) + .font(.title3) + .foregroundStyle(IGITheme.accent) + .frame(width: 36, height: 36) + .background(IGITheme.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.headline) + Text(subtitle).font(.caption).foregroundStyle(.secondary) + } + } + + HStack(spacing: IGITheme.spacingL) { + Spacer() + IGIVerticalFader(label: "Sinistra", icon: "l.circle", valueDb: $leftDb, onCommit: onCommit) + IGIVerticalFader(label: "Destra", icon: "r.circle", valueDb: $rightDb, onCommit: onCommit) + Spacer() + } + } + .igiAudioCard() + } +} + +// MARK: - Graphic equalizer + +struct IGIGraphicEqualizer: View { + @Binding var bands: [EQBandState] + var onCommit: () -> Void + + private let gainRange: ClosedRange = -12 ... 12 + private let barMaxHeight: CGFloat = 120 + + var body: some View { + VStack(spacing: IGITheme.spacingM) { + // Curve preview + GeometryReader { geo in + let w = geo.size.width + let h = geo.size.height + let sorted = bands.sorted { $0.centerHz < $1.centerHz } + if sorted.count >= 2 { + Path { path in + for (i, band) in sorted.enumerated() { + let x = w * CGFloat(i) / CGFloat(max(1, sorted.count - 1)) + let y = h - normalizedGain(band.gainDb) * h + if i == 0 { path.move(to: CGPoint(x: x, y: y)) } + else { path.addLine(to: CGPoint(x: x, y: y)) } + } + } + .stroke( + LinearGradient(colors: [IGITheme.accent, .purple], startPoint: .leading, endPoint: .trailing), + style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round) + ) + } + } + .frame(height: 48) + .padding(.horizontal, IGITheme.spacingS) + + HStack(alignment: .bottom, spacing: IGITheme.spacingS) { + ForEach($bands) { $band in + EQBar( + centerHz: band.centerHz, + gainDb: $band.gainDb, + maxHeight: barMaxHeight, + range: gainRange, + onCommit: onCommit + ) + } + } + .frame(maxWidth: .infinity) + + HStack { + Text("-12 dB").font(.caption2).foregroundStyle(.secondary) + Spacer() + Text("0").font(.caption2.weight(.medium)) + Spacer() + Text("+12 dB").font(.caption2).foregroundStyle(.secondary) + } + } + .igiAudioCard() + } + + private func normalizedGain(_ db: Double) -> CGFloat { + CGFloat((db - gainRange.lowerBound) / (gainRange.upperBound - gainRange.lowerBound)) + } +} + +private struct EQBar: View { + var centerHz: Double + @Binding var gainDb: Double + var maxHeight: CGFloat + var range: ClosedRange + var onCommit: () -> Void + + var body: some View { + VStack(spacing: 6) { + Text(gainLabel) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(gainDb == 0 ? .secondary : IGITheme.accent) + .frame(height: 14) + + GeometryReader { geo in + let h = geo.size.height + let mid = h * 0.5 + ZStack(alignment: .bottom) { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.primary.opacity(0.06)) + + if gainDb >= 0 { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(barGradient) + .frame(height: max(4, normalized(gainDb) * mid)) + .offset(y: -mid) + } else { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(barGradient) + .frame(height: max(4, normalized(abs(gainDb)) * mid)) + .offset(y: -(mid - max(4, normalized(abs(gainDb)) * mid))) + } + } + .overlay { + Rectangle() + .fill(Color.primary.opacity(0.2)) + .frame(height: 1) + .position(x: geo.size.width / 2, y: mid) + } + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { drag in + gainDb = db(from: drag.location.y, height: h) + } + .onEnded { _ in onCommit() } + ) + } + .frame(height: maxHeight) + + Text(freqLabel) + .font(.system(size: 9, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Banda \(freqLabel)") + .accessibilityValue(gainLabel) + } + + private var gainLabel: String { + gainDb >= 0 ? String(format: "+%.1f", gainDb) : String(format: "%.1f", gainDb) + } + + private var freqLabel: String { + if centerHz >= 1000 { + return String(format: "%.0fk", centerHz / 1000) + } + return String(format: "%.0f", centerHz) + } + + private var barGradient: LinearGradient { + if gainDb >= 0 { + return LinearGradient(colors: [IGITheme.accent.opacity(0.5), IGITheme.accent], startPoint: .bottom, endPoint: .top) + } + return LinearGradient(colors: [.orange.opacity(0.4), .orange], startPoint: .top, endPoint: .bottom) + } + + private func normalized(_ db: Double) -> CGFloat { + CGFloat(db / range.upperBound) + } + + private func db(from y: CGFloat, height: CGFloat) -> Double { + let clamped = max(0, min(height, y)) + let norm = 1 - (clamped / height) + let raw = range.lowerBound + norm * (range.upperBound - range.lowerBound) + return (raw * 2).rounded() / 2 + } +} + +// MARK: - Enhancement arc dial + +struct IGIEnhancementDial: View { + var title: String + var systemImage: String + @Binding var level: Double + var onCommit: () -> Void + + @State private var dragOrigin: Double? + + var body: some View { + VStack(spacing: IGITheme.spacingS) { + ZStack { + Circle() + .stroke(Color.primary.opacity(0.08), lineWidth: 10) + Circle() + .trim(from: 0, to: level / 100) + .stroke( + AngularGradient(colors: [IGITheme.accent.opacity(0.4), IGITheme.accent], center: .center), + style: StrokeStyle(lineWidth: 10, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + VStack(spacing: 2) { + Image(systemName: systemImage) + .font(.title3) + .foregroundStyle(IGITheme.accent) + Text("\(Int(level))") + .font(.title2.weight(.bold).monospacedDigit()) + } + } + .frame(width: 88, height: 88) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { drag in + if dragOrigin == nil { dragOrigin = level } + let base = dragOrigin ?? level + level = max(0, min(100, base - drag.translation.height / 2)) + } + .onEnded { _ in + dragOrigin = nil + onCommit() + } + ) + .accessibilityLabel(title) + .accessibilityValue("\(Int(level)) su cento") + + Text(title) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } +} + +// MARK: - Master volume hero + +struct IGIMasterVolumeHero: View { + @Binding var leftDb: Double + @Binding var rightDb: Double + var onCommit: () -> Void + + private var linkedDb: Binding { + Binding( + get: { (leftDb + rightDb) / 2 }, + set: { newValue in + leftDb = newValue + rightDb = newValue + } + ) + } + + var body: some View { + VStack(spacing: IGITheme.spacingM) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Volume master") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + Text("\(Int(linkedDb.wrappedValue))") + .font(.system(size: 48, weight: .bold, design: .rounded)) + .monospacedDigit() + .contentTransition(.numericText()) + } + Spacer() + Image(systemName: "speaker.wave.3.fill") + .font(.largeTitle) + .foregroundStyle(IGITheme.accent.gradient) + .symbolEffect(.variableColor.iterative, options: .repeating, value: linkedDb.wrappedValue) + } + + Slider(value: linkedDb, in: 0 ... 100, step: 1) { editing in + if !editing { onCommit() } + } + .tint(IGITheme.accent) + + HStack(spacing: IGITheme.spacingL) { + stereoBalance(label: "L", value: $leftDb) + stereoBalance(label: "R", value: $rightDb) + } + } + .igiAudioCard() + } + + @ViewBuilder + private func stereoBalance(label: String, value: Binding) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text("Canale \(label)") + .font(.caption2) + .foregroundStyle(.secondary) + HStack { + Text(label).font(.caption.weight(.bold)) + Slider(value: value, in: 0 ... 100, step: 1) { editing in + if !editing { onCommit() } + } + .tint(.secondary) + } + } + } +} + +// MARK: - Card style + +extension View { + func igiAudioCard() -> some View { + igiPremiumCard() + } +} + +enum AudioPanel: String, CaseIterable, Identifiable { + case mixer = "Mixer" + case equalizer = "EQ" + case enhance = "FX" + + var id: String { rawValue } + + var icon: String { + switch self { + case .mixer: "slider.vertical.3" + case .equalizer: "waveform.path.ecg" + case .enhance: "sparkles" + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Components/RadioComponents.swift b/Software/APP/igiRadio/igiRadio/Components/RadioComponents.swift new file mode 100644 index 0000000..f01f705 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Components/RadioComponents.swift @@ -0,0 +1,270 @@ +import SwiftUI + +// MARK: - Shared premium surface + +extension View { + func igiPremiumCard() -> some View { + self + .padding(IGITheme.spacingM) + .background { + RoundedRectangle(cornerRadius: IGITheme.cardRadius, style: .continuous) + .fill(.ultraThinMaterial) + .shadow(color: .black.opacity(0.06), radius: 12, y: 4) + } + } +} + +struct IGIHeroBackground: View { + var body: some View { + LinearGradient( + colors: [ + IGITheme.screenBackground, + IGITheme.accent.opacity(0.08), + IGITheme.screenBackground + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + } +} + +// MARK: - Transport + +struct IGITransportCluster: View { + var onPrevious: () -> Void + var onPlay: () -> Void + var onNext: () -> Void + + var body: some View { + HStack(spacing: IGITheme.spacingL) { + transportButton(icon: "backward.fill", size: 56, prominent: false, action: onPrevious) + transportButton(icon: "play.fill", size: 76, prominent: true, action: onPlay) + transportButton(icon: "forward.fill", size: 56, prominent: false, action: onNext) + } + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private func transportButton(icon: String, size: CGFloat, prominent: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: icon) + .font(prominent ? .largeTitle : .title2) + .foregroundStyle(prominent ? .white : .primary) + .frame(width: size, height: size) + .background { + if prominent { + Circle().fill(IGITheme.accent.gradient) + .shadow(color: IGITheme.accent.opacity(0.35), radius: 12, y: 6) + } else { + Circle().fill(.ultraThinMaterial) + } + } + } + .buttonStyle(.plain) + } +} + +// MARK: - Band badge + +struct IGIBandBadge: View { + var band: TunerBand + var locked: Bool + + var body: some View { + HStack(spacing: 6) { + Image(systemName: band == .fm ? "dot.radiowaves.left.and.right" : "antenna.radiowaves.left.and.right") + Text(band.rawValue.uppercased()) + .fontWeight(.bold) + if locked { + Image(systemName: "lock.fill") + .font(.caption2) + } + } + .font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(IGITheme.accent.opacity(0.15), in: Capsule()) + .foregroundStyle(IGITheme.accent) + } +} + +// MARK: - Preset chip + +struct IGIPresetChip: View { + var name: String + var subtitle: String + var action: () -> Void + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 4) { + Text(name) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + Text(subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(minWidth: 110, alignment: .leading) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.primary.opacity(0.06)) + ) + } + .buttonStyle(.plain) + } +} + +// MARK: - Metric bar + +struct IGIMetricBar: View { + var label: String + var value: String + var level: Double + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(label).font(.caption).foregroundStyle(.secondary) + Spacer() + Text(value).font(.caption.monospacedDigit().weight(.medium)) + } + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.primary.opacity(0.08)) + Capsule() + .fill(IGITheme.accent.gradient) + .frame(width: geo.size.width * max(0, min(1, level))) + } + } + .frame(height: 6) + } + } +} + +// MARK: - FM frequency hero + +struct IGIFrequencyHero: View { + @Binding var frequencyMHz: Double + var stationName: String? + var isTuning: Bool + var onTune: () -> Void + + var body: some View { + VStack(spacing: IGITheme.spacingL) { + if let stationName, !stationName.isEmpty { + Text(stationName) + .font(.title3.weight(.semibold)) + .foregroundStyle(IGITheme.accent) + .lineLimit(1) + } + + ZStack { + Circle() + .stroke(Color.primary.opacity(0.06), lineWidth: 20) + Circle() + .trim(from: 0, to: normalizedFrequency) + .stroke( + AngularGradient(colors: [IGITheme.accent.opacity(0.3), IGITheme.accent], center: .center), + style: StrokeStyle(lineWidth: 20, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.snappy, value: frequencyMHz) + + VStack(spacing: 4) { + Text(String(format: "%.2f", frequencyMHz)) + .font(.system(size: 44, weight: .bold, design: .rounded)) + .monospacedDigit() + .contentTransition(.numericText()) + Text("MHz") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + .frame(width: 220, height: 220) + + Slider(value: $frequencyMHz, in: 64 ... 108, step: 0.05) + .tint(IGITheme.accent) + + HStack(spacing: IGITheme.spacingM) { + stepButton("−0.1") { frequencyMHz = max(64, frequencyMHz - 0.1) } + Button(action: onTune) { + Group { + if isTuning { + ProgressView().tint(.white) + } else { + Text("Sintonizza") + .fontWeight(.semibold) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + .buttonStyle(.borderedProminent) + .disabled(isTuning) + stepButton("+0.1") { frequencyMHz = min(108, frequencyMHz + 0.1) } + } + } + .igiPremiumCard() + } + + private var normalizedFrequency: Double { + (frequencyMHz - 64) / (108 - 64) + } + + @ViewBuilder + private func stepButton(_ title: String, action: @escaping () -> Void) -> some View { + Button(title, action: action) + .font(.subheadline.weight(.semibold).monospacedDigit()) + .frame(width: 56, height: 48) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) + .buttonStyle(.plain) + } +} + +// MARK: - Scan result row + +struct IGIScanResultRow: View { + var title: String + var frequencyMHz: Double + var rssi: Int? + var action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: IGITheme.spacingM) { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(IGITheme.accent.opacity(0.12)) + .frame(width: 44, height: 44) + Image(systemName: "radio.fill") + .foregroundStyle(IGITheme.accent) + } + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.headline).lineLimit(1) + Text(String(format: "%.2f MHz", frequencyMHz)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if let rssi { + Text("\(rssi)") + .font(.caption.monospacedDigit().weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.primary.opacity(0.06), in: Capsule()) + } + Image(systemName: "play.circle.fill") + .font(.title2) + .foregroundStyle(IGITheme.accent) + } + .padding(IGITheme.spacingS) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .buttonStyle(.plain) + } +} diff --git a/Software/APP/igiRadio/igiRadio/ViewModels/AudioViewModel.swift b/Software/APP/igiRadio/igiRadio/ViewModels/AudioViewModel.swift new file mode 100644 index 0000000..fb7f875 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/ViewModels/AudioViewModel.swift @@ -0,0 +1,75 @@ +import Foundation +import Observation + +@Observable +final class AudioViewModel { + private let service: any DigiRadioService + private var applyTask: Task? + + var panel: AudioPanel = .mixer + var mixer = MixerState() + var master = MasterVolumeState() + var eqBands: [EQBandState] = [] + var stereoLevel: Double = 0 + var bassLevel: Double = 0 + var isLoading = false + var isSaving = false + + init(service: any DigiRadioService) { + self.service = service + } + + func load() async { + isLoading = true + defer { isLoading = false } + try? await service.refreshAudioProfile() + syncFromDevice() + } + + func syncFromDevice() { + let audio = service.state.audio + mixer = audio.mixer + master = audio.master + eqBands = audio.eq + stereoLevel = Double(audio.enhancements.stereoLevel) + bassLevel = Double(audio.enhancements.bassLevel) + if master.leftDb == 0 && master.rightDb == 0 { + let vol = Double(service.state.tuner.volume) + master = MasterVolumeState(leftDb: vol, rightDb: vol) + } + } + + func scheduleApply() { + applyTask?.cancel() + applyTask = Task { + try? await Task.sleep(for: .milliseconds(350)) + guard !Task.isCancelled else { return } + await applyNow() + } + } + + func applyNow() async { + isSaving = true + defer { isSaving = false } + let profile = AudioProfileDTO( + mixer: mixer, + master: master, + eq: eqBands, + enhancements: EnhancementsState(stereoLevel: Int(stereoLevel), bassLevel: Int(bassLevel)) + ) + try? await service.applyAudioProfile(profile) + try? await service.setVolume(Int((master.leftDb + master.rightDb) / 2)) + syncFromDevice() + } + + func reset() async { + try? await service.resetAudio() + syncFromDevice() + } + + func applyEnhancements() async { + try? await service.setStereoEnhance(level: Int(stereoLevel)) + try? await service.setBassEnhance(level: Int(bassLevel)) + syncFromDevice() + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/AudioView.swift b/Software/APP/igiRadio/igiRadio/Views/AudioView.swift index d5f21f2..8518bd0 100644 --- a/Software/APP/igiRadio/igiRadio/Views/AudioView.swift +++ b/Software/APP/igiRadio/igiRadio/Views/AudioView.swift @@ -2,85 +2,224 @@ import SwiftUI struct AudioView: View { @Environment(AppEnvironment.self) private var environment - @State private var stereoLevel: Double = 0 - @State private var bassLevel: Double = 0 - @State private var masterVolume: Double = 0 - @State private var eqBands: [EQBandState] = [] + @State private var viewModel: AudioViewModel? var body: some View { - Form { - Section("Volume master") { - IGIVolumeSlider(value: $masterVolume) { value in - Task { try? await environment.digiRadio.setVolume(Int(value)) } - } - } + let vm = viewModel ?? AudioViewModel(service: environment.digiRadio) - Section("Enhancements") { - VStack(alignment: .leading) { - Text("Stereo enhance") - Slider(value: $stereoLevel, in: 0 ... 100, step: 1) { editing in - if !editing { - Task { try? await environment.digiRadio.setStereoEnhance(level: Int(stereoLevel)) } - } - } - } - VStack(alignment: .leading) { - Text("Bass enhance") - Slider(value: $bassLevel, in: 0 ... 100, step: 1) { editing in - if !editing { - Task { try? await environment.digiRadio.setBassEnhance(level: Int(bassLevel)) } - } - } - } - } + ScrollView { + VStack(alignment: .leading, spacing: IGITheme.spacingL) { + header(vm) - Section("Equalizzatore (6 bande)") { - ForEach($eqBands) { $band in - VStack(alignment: .leading) { - Text("\(Int(band.centerHz)) Hz") - .font(.caption) - .foregroundStyle(.secondary) - Slider(value: $band.gainDb, in: -12 ... 12, step: 0.5) { editing in - if !editing { Task { await applyEQ() } } - } - } - } - } + if vm.isLoading { + ProgressView("Caricamento profilo audio…") + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } else { + IGIMasterVolumeHero( + leftDb: Binding( + get: { vm.master.leftDb }, + set: { vm.master.leftDb = $0; vm.scheduleApply() } + ), + rightDb: Binding( + get: { vm.master.rightDb }, + set: { vm.master.rightDb = $0; vm.scheduleApply() } + ), + onCommit: { vm.scheduleApply() } + ) - Section { - Button("Reset profilo audio", role: .destructive) { - Task { - try? await environment.digiRadio.resetAudio() - syncFromState() + panelPicker(vm) + + switch vm.panel { + case .mixer: + mixerPanel(vm) + case .equalizer: + equalizerPanel(vm) + case .enhance: + enhancePanel(vm) } + + resetButton(vm) } } + .padding(IGITheme.spacingM) } - .navigationTitle("Audio") - .onAppear { - Task { - try? await environment.digiRadio.refreshAudioProfile() - syncFromState() - } - } - .onChange(of: environment.state.audio.enhancements.stereoLevel) { _, _ in syncFromState() } - } - - private func syncFromState() { - let audio = environment.state.audio - stereoLevel = Double(audio.enhancements.stereoLevel) - bassLevel = Double(audio.enhancements.bassLevel) - masterVolume = Double(environment.state.tuner.volume) - eqBands = audio.eq - } - - private func applyEQ() async { - let profile = AudioProfileDTO( - mixer: environment.state.audio.mixer, - master: environment.state.audio.master, - eq: eqBands, - enhancements: EnhancementsState(stereoLevel: Int(stereoLevel), bassLevel: Int(bassLevel)) + .background( + LinearGradient( + colors: [ + IGITheme.screenBackground, + IGITheme.accent.opacity(0.06), + IGITheme.screenBackground + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() ) - try? await environment.digiRadio.applyAudioProfile(profile) + .navigationTitle("Audio") + .navigationBarTitleDisplayMode(.large) + .onAppear { + if viewModel == nil { viewModel = vm } + Task { await vm.load() } + } + } + + @ViewBuilder + private func header(_ vm: AudioViewModel) -> some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("ADAU1701") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(IGITheme.accent) + Text("Mixer · EQ · DSP") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if vm.isSaving { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Salvataggio") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + @ViewBuilder + private func panelPicker(_ vm: AudioViewModel) -> some View { + HStack(spacing: IGITheme.spacingS) { + ForEach(AudioPanel.allCases) { panel in + Button { + withAnimation(.snappy) { vm.panel = panel } + } label: { + Label(panel.rawValue, systemImage: panel.icon) + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + vm.panel == panel + ? AnyShapeStyle(IGITheme.accent.gradient) + : AnyShapeStyle(Color.primary.opacity(0.06)), + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + .foregroundStyle(vm.panel == panel ? .white : .primary) + } + .buttonStyle(.plain) + } + } + } + + @ViewBuilder + private func mixerPanel(_ vm: AudioViewModel) -> some View { + VStack(spacing: IGITheme.spacingM) { + IGIMixerChannelGroup( + title: "Radio Si4684", + subtitle: "FM / DAB — ingresso tuner", + systemImage: "antenna.radiowaves.left.and.right", + leftDb: Binding( + get: { vm.mixer.si4684LeftDb }, + set: { vm.mixer.si4684LeftDb = $0 } + ), + rightDb: Binding( + get: { vm.mixer.si4684RightDb }, + set: { vm.mixer.si4684RightDb = $0 } + ), + onCommit: { vm.scheduleApply() } + ) + + IGIMixerChannelGroup( + title: "Stream ESP32", + subtitle: "Web radio / phone push", + systemImage: "waveform", + leftDb: Binding( + get: { vm.mixer.esp32LeftDb }, + set: { vm.mixer.esp32LeftDb = $0 } + ), + rightDb: Binding( + get: { vm.mixer.esp32RightDb }, + set: { vm.mixer.esp32RightDb = $0 } + ), + onCommit: { vm.scheduleApply() } + ) + + IGIMixerChannelGroup( + title: "Mix bus", + subtitle: "Uscita mixer ADAU", + systemImage: "speaker.wave.2.fill", + leftDb: Binding( + get: { vm.mixer.mixLeftDb }, + set: { vm.mixer.mixLeftDb = $0 } + ), + rightDb: Binding( + get: { vm.mixer.mixRightDb }, + set: { vm.mixer.mixRightDb = $0 } + ), + onCommit: { vm.scheduleApply() } + ) + } + } + + @ViewBuilder + private func equalizerPanel(_ vm: AudioViewModel) -> some View { + VStack(alignment: .leading, spacing: IGITheme.spacingM) { + Text("Equalizzatore 6 bande") + .font(.headline) + Text("Trascina le barre per regolare il guadagno. Rilascia per applicare al DSP.") + .font(.caption) + .foregroundStyle(.secondary) + + if vm.eqBands.isEmpty { + ProgressView() + .frame(maxWidth: .infinity) + } else { + IGIGraphicEqualizer( + bands: Binding( + get: { vm.eqBands }, + set: { vm.eqBands = $0 } + ), + onCommit: { vm.scheduleApply() } + ) + } + } + } + + @ViewBuilder + private func enhancePanel(_ vm: AudioViewModel) -> some View { + HStack(spacing: IGITheme.spacingL) { + IGIEnhancementDial( + title: "Stereo", + systemImage: "circle.lefthalf.filled", + level: Binding( + get: { vm.stereoLevel }, + set: { vm.stereoLevel = $0 } + ), + onCommit: { Task { await vm.applyEnhancements() } } + ) + IGIEnhancementDial( + title: "Bass", + systemImage: "waveform.path", + level: Binding( + get: { vm.bassLevel }, + set: { vm.bassLevel = $0 } + ), + onCommit: { Task { await vm.applyEnhancements() } } + ) + } + .igiAudioCard() + } + + @ViewBuilder + private func resetButton(_ vm: AudioViewModel) -> some View { + Button(role: .destructive) { + Task { await vm.reset() } + } label: { + Label("Ripristina profilo audio", systemImage: "arrow.counterclockwise") + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + .buttonStyle(.bordered) + .padding(.top, IGITheme.spacingS) } } diff --git a/Software/APP/igiRadio/igiRadio/Views/FMRadioView.swift b/Software/APP/igiRadio/igiRadio/Views/FMRadioView.swift index 9c5c8b7..82d8ee7 100644 --- a/Software/APP/igiRadio/igiRadio/Views/FMRadioView.swift +++ b/Software/APP/igiRadio/igiRadio/Views/FMRadioView.swift @@ -10,96 +10,30 @@ struct FMRadioView: View { var body: some View { let fm = environment.state.tuner.fm - Form { - Section { - VStack(spacing: IGITheme.spacingM) { - Text(String(format: "%.2f", frequencyMHz)) - .font(.system(size: 56, weight: .bold, design: .rounded)) - .monospacedDigit() - .accessibilityLabel("Frequenza \(frequencyMHz) megahertz") - Slider(value: $frequencyMHz, in: 64 ... 108, step: 0.05) - HStack { - Button("−") { frequencyMHz = max(64, frequencyMHz - 0.1) } - Spacer() - Button("Sintonizza") { Task { await tune() } } - .buttonStyle(.borderedProminent) - .disabled(isTuning) - Spacer() - Button("+") { frequencyMHz = min(108, frequencyMHz + 0.1) } - } - } - .padding(.vertical, IGITheme.spacingS) - } + ScrollView { + VStack(alignment: .leading, spacing: IGITheme.spacingL) { + IGIFrequencyHero( + frequencyMHz: $frequencyMHz, + stationName: fm?.stationName, + isTuning: isTuning, + onTune: { Task { await tune() } } + ) - if let fm { - Section("Stato") { - LabeledContent("Frequenza", value: String(format: "%.2f MHz", Double(fm.frequencyKhz) / 1000)) - if let rssi = fm.rssiDbuv { LabeledContent("RSSI", value: "\(rssi) dBµV") } - if let snr = fm.snrDb { LabeledContent("SNR", value: "\(snr) dB") } - if let stereo = fm.stereo { - LabeledContent("Stereo", value: stereo ? "Sì" : "Mono") + if let fm { + signalCard(fm) + if let ps = fm.stationName, !ps.isEmpty { + rdsCard(fm, ps: ps) } } - if let ps = fm.stationName, !ps.isEmpty { - Section("RDS") { - LabeledContent("PS", value: ps) - if let rt = fm.radiotext, !rt.isEmpty { - Text(rt).font(.body) - } - } - } - } - Section("Seek") { - Button("Seek su") { Task { try? await environment.digiRadio.seekFM(direction: "up") } } - Button("Seek giù") { Task { try? await environment.digiRadio.seekFM(direction: "down") } } - } - - Section("Scan") { - if isScanning { - HStack { - IGIScanningIndicator().frame(width: 24, height: 24) - Text("Scansione in corso…") - } - } - if let scanError { - Text(scanError).foregroundStyle(.red) - } - Button("Cerca prossima stazione") { - Task { await scanNext() } - } - .disabled(isScanning) - Button("Scansione completa banda FM") { - Task { await scanFull() } - } - .disabled(isScanning) - - if !scanResults.isEmpty { - ForEach(scanResults) { hit in - Button { - Task { try? await environment.digiRadio.tuneFM(frequencyKhz: hit.frequencyKhz) } - } label: { - HStack { - VStack(alignment: .leading) { - Text(hit.stationName ?? "Stazione FM") - .font(.headline) - Text(String(format: "%.2f MHz", Double(hit.frequencyKhz) / 1000)) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - if let rssi = hit.rssiDbuv { - Text("\(rssi) dBµV") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - } + seekSection + scanSection } + .padding(IGITheme.spacingM) } + .background(IGIHeroBackground()) .navigationTitle("FM") + .navigationBarTitleDisplayMode(.large) .onAppear { if let khz = environment.state.tuner.fm?.frequencyKhz { frequencyMHz = Double(khz) / 1000.0 @@ -107,10 +41,128 @@ struct FMRadioView: View { Task { try? await environment.digiRadio.refreshTunerStatus() } } .onChange(of: environment.state.tuner.fm?.frequencyKhz) { _, newValue in - if let khz = newValue { frequencyMHz = Double(khz) / 1000.0 } + if let khz = newValue { + withAnimation(.snappy) { + frequencyMHz = Double(khz) / 1000.0 + } + } } } + @ViewBuilder + private func signalCard(_ fm: FMTunerState) -> some View { + VStack(alignment: .leading, spacing: IGITheme.spacingM) { + Text("Segnale") + .font(.headline) + if let rssi = fm.rssiDbuv { + IGIMetricBar(label: "RSSI", value: "\(rssi) dBµV", level: Double(rssi) / 80.0) + } + if let snr = fm.snrDb { + IGIMetricBar(label: "SNR", value: "\(snr) dB", level: Double(snr) / 40.0) + } + if let stereo = fm.stereo { + HStack { + Text("Stereo") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Text(stereo ? "Sì" : "Mono") + .font(.caption.weight(.semibold)) + .foregroundStyle(stereo ? .green : .secondary) + } + } + } + .igiPremiumCard() + } + + @ViewBuilder + private func rdsCard(_ fm: FMTunerState, ps: String) -> some View { + VStack(alignment: .leading, spacing: IGITheme.spacingS) { + HStack { + Image(systemName: "text.bubble.fill") + .foregroundStyle(IGITheme.accent) + Text("RDS") + .font(.headline) + } + Text(ps) + .font(.title3.weight(.semibold)) + if let rt = fm.radiotext, !rt.isEmpty { + Text(rt) + .font(.subheadline) + .foregroundStyle(.secondary) + .italic() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .igiPremiumCard() + } + + private var seekSection: some View { + HStack(spacing: IGITheme.spacingM) { + Button { + Task { try? await environment.digiRadio.seekFM(direction: "down") } + } label: { + Label("Giù", systemImage: "chevron.down") + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + .buttonStyle(.bordered) + + Button { + Task { try? await environment.digiRadio.seekFM(direction: "up") } + } label: { + Label("Su", systemImage: "chevron.up") + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + .buttonStyle(.bordered) + } + } + + @ViewBuilder + private var scanSection: some View { + VStack(alignment: .leading, spacing: IGITheme.spacingM) { + Text("Scan") + .font(.headline) + + if isScanning { + HStack(spacing: IGITheme.spacingS) { + IGIScanningIndicator().frame(width: 28, height: 28) + Text("Scansione in corso…") + .foregroundStyle(.secondary) + } + } + + if let scanError { + Text(scanError).font(.caption).foregroundStyle(.red) + } + + HStack(spacing: IGITheme.spacingS) { + Button("Prossima stazione") { Task { await scanNext() } } + .buttonStyle(.borderedProminent) + .disabled(isScanning) + Button("Banda completa") { Task { await scanFull() } } + .buttonStyle(.bordered) + .disabled(isScanning) + } + + if !scanResults.isEmpty { + VStack(spacing: IGITheme.spacingS) { + ForEach(scanResults) { hit in + IGIScanResultRow( + title: hit.stationName ?? "Stazione FM", + frequencyMHz: Double(hit.frequencyKhz) / 1000, + rssi: hit.rssiDbuv + ) { + Task { try? await environment.digiRadio.tuneFM(frequencyKhz: hit.frequencyKhz) } + } + } + } + } + } + .igiPremiumCard() + } + private func tune() async { isTuning = true defer { isTuning = false } diff --git a/Software/APP/igiRadio/igiRadio/Views/HomeView.swift b/Software/APP/igiRadio/igiRadio/Views/HomeView.swift index ed1aaa6..8e6b71c 100644 --- a/Software/APP/igiRadio/igiRadio/Views/HomeView.swift +++ b/Software/APP/igiRadio/igiRadio/Views/HomeView.swift @@ -11,31 +11,23 @@ struct HomeView: View { VStack(alignment: .leading, spacing: IGITheme.spacingL) { header(state: state) nowPlayingCard(state: state) - transportControls + quickLinks + IGITransportCluster( + onPrevious: { Task { await viewModel?.seekFM("down") } }, + onPlay: { Task { await viewModel?.refreshAll() } }, + onNext: { Task { await viewModel?.seekFM("up") } } + ) volumeSection presetsSection(state: state) } .padding(IGITheme.spacingM) } - .background(IGITheme.screenBackground) + .background(IGIHeroBackground()) .navigationTitle("igiRadio") + .navigationBarTitleDisplayMode(.large) .safeAreaInset(edge: .top) { if !state.connection.isConnected { - NavigationLink { - ConnectionView() - } label: { - HStack { - Image(systemName: "wifi.exclamationmark") - Text("DigiRadio non connesso — tocca per collegare") - .font(.subheadline.weight(.medium)) - Spacer() - Image(systemName: "chevron.right") - } - .padding(.horizontal, IGITheme.spacingM) - .padding(.vertical, 10) - .background(.orange.opacity(0.15)) - } - .buttonStyle(.plain) + connectionBanner } } .toolbar { @@ -60,26 +52,46 @@ struct HomeView: View { } } + private var connectionBanner: some View { + NavigationLink { + ConnectionView() + } label: { + HStack { + Image(systemName: "wifi.exclamationmark") + Text("DigiRadio non connesso — tocca per collegare") + .font(.subheadline.weight(.medium)) + Spacer() + Image(systemName: "chevron.right") + } + .padding(.horizontal, IGITheme.spacingM) + .padding(.vertical, 10) + .background(.orange.opacity(0.15)) + } + .buttonStyle(.plain) + } + @ViewBuilder private func header(state: DigiRadioState) -> some View { - HStack { - VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 8) { Text("DigiRadio") - .font(.largeTitle.bold()) + .font(.title.weight(.bold)) HStack(spacing: 8) { IGIStatusDot(isConnected: state.connection.isConnected) Text(state.connection.isConnected ? "Connesso" : "Non connesso") + .font(.subheadline) .foregroundStyle(.secondary) } + IGIBandBadge(band: state.tuner.band, locked: state.tuner.locked) } Spacer() if let rssi = state.tuner.fm?.rssiDbuv { - VStack(alignment: .trailing) { + VStack(alignment: .trailing, spacing: 6) { Text("Segnale") .font(.caption) .foregroundStyle(.secondary) IGISignalBar(level: Double(rssi) / 80.0) - .frame(width: 120) + .frame(width: 100) } } } @@ -87,75 +99,96 @@ struct HomeView: View { @ViewBuilder private func nowPlayingCard(state: DigiRadioState) -> some View { - IGICard { - VStack(spacing: IGITheme.spacingM) { - ZStack { - RoundedRectangle(cornerRadius: 24, style: .continuous) - .fill( - LinearGradient( - colors: [.accentColor.opacity(0.35), .purple.opacity(0.25)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) + VStack(spacing: IGITheme.spacingM) { + ZStack { + RoundedRectangle(cornerRadius: 28, style: .continuous) + .fill( + LinearGradient( + colors: [ + IGITheme.accent.opacity(0.45), + .purple.opacity(0.35), + IGITheme.accent.opacity(0.25) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing ) - .frame(height: 220) - Image(systemName: state.tuner.band == .fm ? "radio.fill" : "antenna.radiowaves.left.and.right") - .font(.system(size: 64)) - .foregroundStyle(.white.opacity(0.9)) - } + ) + .frame(height: 200) + .overlay { + Circle() + .fill(.white.opacity(0.08)) + .frame(width: 160, height: 160) + .blur(radius: 2) + } - VStack(spacing: 6) { - Text(primaryTitle(state: state)) - .font(.title2.weight(.semibold)) - .multilineTextAlignment(.center) - Text(secondaryLine(state: state)) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } + Image(systemName: state.tuner.band == .fm ? "radio.fill" : "antenna.radiowaves.left.and.right") + .font(.system(size: 56)) + .foregroundStyle(.white.opacity(0.95)) + .symbolEffect(.pulse, options: .repeating, value: state.tuner.locked) + } + + VStack(spacing: 6) { + Text(primaryTitle(state: state)) + .font(.title2.weight(.bold)) + .multilineTextAlignment(.center) + .contentTransition(.interpolate) + Text(secondaryLine(state: state)) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(2) + } + } + .igiPremiumCard() + .animation(.snappy, value: state.tuner.fm?.frequencyKhz) + .animation(.snappy, value: state.tuner.fm?.stationName) + } + + private var quickLinks: some View { + HStack(spacing: IGITheme.spacingS) { + NavigationLink { + FMRadioView() + } label: { + quickLinkLabel("FM", icon: "dot.radiowaves.left.and.right") + } + NavigationLink { + DABRadioView() + } label: { + quickLinkLabel("DAB", icon: "antenna.radiowaves.left.and.right") + } + NavigationLink { + AudioView() + } label: { + quickLinkLabel("Audio", icon: "slider.vertical.3") } } } - private var transportControls: some View { - HStack(spacing: IGITheme.spacingL) { - Button { - Task { await viewModel?.seekFM("down") } - } label: { - Image(systemName: "backward.fill") - .font(.title2) - .frame(width: 56, height: 56) - } - .buttonStyle(.bordered) - - Button { - Task { await viewModel?.refreshAll() } - } label: { - Image(systemName: "play.fill") - .font(.largeTitle) - .frame(width: 72, height: 72) - } - .buttonStyle(.borderedProminent) - .clipShape(Circle()) - - Button { - Task { await viewModel?.seekFM("up") } - } label: { - Image(systemName: "forward.fill") - .font(.title2) - .frame(width: 56, height: 56) - } - .buttonStyle(.bordered) - } - .frame(maxWidth: .infinity) + @ViewBuilder + private func quickLinkLabel(_ title: String, icon: String) -> some View { + Label(title, systemImage: icon) + .font(.caption.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } private var volumeSection: some View { - IGICard { + VStack(alignment: .leading, spacing: IGITheme.spacingS) { + HStack { + Text("Volume") + .font(.headline) + Spacer() + Text("\(Int(volume))") + .font(.title3.weight(.bold).monospacedDigit()) + .foregroundStyle(IGITheme.accent) + .contentTransition(.numericText()) + } IGIVolumeSlider(value: $volume) { value in Task { await viewModel?.setVolume(value) } } } + .igiPremiumCard() } @ViewBuilder @@ -171,20 +204,12 @@ struct HomeView: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: IGITheme.spacingS) { ForEach(Array(state.stations.enumerated()), id: \.offset) { index, station in - Button { + IGIPresetChip( + name: station.name, + subtitle: station.band.rawValue.uppercased() + ) { Task { await viewModel?.tunePreset(at: index) } - } label: { - VStack(alignment: .leading) { - Text(station.name) - .font(.headline) - Text(station.band.rawValue.uppercased()) - .font(.caption) - .foregroundStyle(.secondary) - } - .padding() - .background(IGITheme.cardBackground, in: RoundedRectangle(cornerRadius: 14)) } - .buttonStyle(.plain) } } } @@ -208,7 +233,9 @@ struct HomeView: View { switch state.tuner.band { case .fm: if let khz = state.tuner.fm?.frequencyKhz { - return String(format: "%.2f MHz", Double(khz) / 1000.0) + let freq = String(format: "%.2f MHz", Double(khz) / 1000.0) + if let rt = state.tuner.fm?.radiotext, !rt.isEmpty { return "\(freq) · \(rt)" } + return freq } return state.tuner.fm?.radiotext ?? "FM Radio" case .dab: diff --git a/Software/APP/igiRadio/igiRadio/Views/RootView.swift b/Software/APP/igiRadio/igiRadio/Views/RootView.swift index d11c887..787a8d8 100644 --- a/Software/APP/igiRadio/igiRadio/Views/RootView.swift +++ b/Software/APP/igiRadio/igiRadio/Views/RootView.swift @@ -33,6 +33,9 @@ private struct IPhoneRootView: View { NavigationStack { PresetsView() } .tabItem { Label("Preset", systemImage: "star.fill") } + NavigationStack { AudioView() } + .tabItem { Label("Audio", systemImage: "waveform") } + NavigationStack { SettingsRootView() } .tabItem { Label("Impostazioni", systemImage: "gearshape.fill") } } diff --git a/Software/APP/igiRadio/igiRadio/Views/SettingsViews.swift b/Software/APP/igiRadio/igiRadio/Views/SettingsViews.swift index f841ed9..00de520 100644 --- a/Software/APP/igiRadio/igiRadio/Views/SettingsViews.swift +++ b/Software/APP/igiRadio/igiRadio/Views/SettingsViews.swift @@ -15,7 +15,11 @@ struct SettingsRootView: View { NavigationLink("Preset") { PresetsView() } } Section("Audio") { - NavigationLink("Profilo audio") { AudioView() } + NavigationLink { + AudioView() + } label: { + Label("Mixer & Equalizzatore", systemImage: "slider.vertical.3") + } NavigationLink("Bluetooth speaker") { BluetoothView() } } }