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() } } }