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 <cursoragent@cursor.com>
This commit is contained in:
2026-08-18 15:56:51 +02:00
co-authored by Cursor
parent 6087bb5297
commit 3afcba0d36
9 changed files with 1311 additions and 320 deletions
@@ -3,12 +3,9 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 77; objectVersion = 71;
objects = { objects = {
/* Begin PBXBuildFile section */
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
C050F319BDFADBF7CE6700F8 /* PBXContainerItemProxy */ = { C050F319BDFADBF7CE6700F8 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
@@ -25,7 +22,7 @@
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
7D085337BF0EA6EB04337F45 /* Exceptions for "igiRadio" folder in "igiRadio" target */ = { 7D085337BF0EA6EB04337F45 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
Resources/Info.plist, Resources/Info.plist,
@@ -35,30 +32,19 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
F7E3CB0B027CB68EA63A4AB8 /* igiRadio */ = { 693436FA1780106DA05D06E3 /* Tests/igiRadioTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests/igiRadioTests; sourceTree = "<group>"; };
isa = PBXFileSystemSynchronizedRootGroup; F7E3CB0B027CB68EA63A4AB8 /* igiRadio */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (7D085337BF0EA6EB04337F45 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = igiRadio; sourceTree = "<group>"; };
exceptions = (
7D085337BF0EA6EB04337F45 /* Exceptions for "igiRadio" folder in "igiRadio" target */,
);
path = igiRadio;
sourceTree = "<group>";
};
693436FA1780106DA05D06E3 /* igiRadioTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Tests/igiRadioTests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
71F357EA3EEC95DAEFCF7345 /* Frameworks */ = { 0A8032441559F0A3B8F103F8 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
0A8032441559F0A3B8F103F8 /* Frameworks */ = { 71F357EA3EEC95DAEFCF7345 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@@ -81,7 +67,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
F7E3CB0B027CB68EA63A4AB8 /* igiRadio */, F7E3CB0B027CB68EA63A4AB8 /* igiRadio */,
693436FA1780106DA05D06E3 /* igiRadioTests */, 693436FA1780106DA05D06E3 /* Tests/igiRadioTests */,
2761999F42D5E1910DD2B679 /* Products */, 2761999F42D5E1910DD2B679 /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@@ -122,7 +108,7 @@
0E0716BAD2A3DFD42CA27715 /* PBXTargetDependency */, 0E0716BAD2A3DFD42CA27715 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
693436FA1780106DA05D06E3 /* igiRadioTests */, 693436FA1780106DA05D06E3 /* Tests/igiRadioTests */,
); );
name = igiRadioTests; name = igiRadioTests;
productName = igiRadioTests; productName = igiRadioTests;
@@ -203,24 +189,6 @@
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration 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 */ = { 54274B7C1572A50D5BE0DEA8 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -262,31 +230,6 @@
}; };
name = Debug; 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 */ = { 90735310944459417946066C /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -327,9 +270,61 @@
}; };
name = Release; 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 */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList 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" */ = { C8A2C2BD01CBC6B9DFC6F199 /* Build configuration list for PBXProject "igiRadio" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
@@ -348,15 +343,6 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
61D281E9594B71DA372FEE34 /* Build configuration list for PBXNativeTarget "igiRadioTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
90735310944459417946066C /* Debug */,
E6AD029DFF2D9078F77BDCBA /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = D7ACBDA7D90DDE7BBA02A41B /* Project object */; rootObject = D7ACBDA7D90DDE7BBA02A41B /* Project object */;
@@ -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<Double> = -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<Double> = -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<Double>
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<Double> {
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<Double>) -> 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"
}
}
}
@@ -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)
}
}
@@ -0,0 +1,75 @@
import Foundation
import Observation
@Observable
final class AudioViewModel {
private let service: any DigiRadioService
private var applyTask: Task<Void, Never>?
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()
}
}
@@ -2,85 +2,224 @@ import SwiftUI
struct AudioView: View { struct AudioView: View {
@Environment(AppEnvironment.self) private var environment @Environment(AppEnvironment.self) private var environment
@State private var stereoLevel: Double = 0 @State private var viewModel: AudioViewModel?
@State private var bassLevel: Double = 0
@State private var masterVolume: Double = 0
@State private var eqBands: [EQBandState] = []
var body: some View { var body: some View {
Form { let vm = viewModel ?? AudioViewModel(service: environment.digiRadio)
Section("Volume master") {
IGIVolumeSlider(value: $masterVolume) { value in ScrollView {
Task { try? await environment.digiRadio.setVolume(Int(value)) } VStack(alignment: .leading, spacing: IGITheme.spacingL) {
header(vm)
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() }
)
panelPicker(vm)
switch vm.panel {
case .mixer:
mixerPanel(vm)
case .equalizer:
equalizerPanel(vm)
case .enhance:
enhancePanel(vm)
}
resetButton(vm)
}
}
.padding(IGITheme.spacingM)
}
.background(
LinearGradient(
colors: [
IGITheme.screenBackground,
IGITheme.accent.opacity(0.06),
IGITheme.screenBackground
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
)
.navigationTitle("Audio")
.navigationBarTitleDisplayMode(.large)
.onAppear {
if viewModel == nil { viewModel = vm }
Task { await vm.load() }
} }
} }
Section("Enhancements") { @ViewBuilder
VStack(alignment: .leading) { private func header(_ vm: AudioViewModel) -> some View {
Text("Stereo enhance") HStack {
Slider(value: $stereoLevel, in: 0 ... 100, step: 1) { editing in VStack(alignment: .leading, spacing: 4) {
if !editing { Text("ADAU1701")
Task { try? await environment.digiRadio.setStereoEnhance(level: Int(stereoLevel)) } .font(.subheadline.weight(.semibold))
.foregroundStyle(IGITheme.accent)
Text("Mixer · EQ · DSP")
.font(.caption)
.foregroundStyle(.secondary)
} }
} Spacer()
} if vm.isSaving {
VStack(alignment: .leading) { HStack(spacing: 6) {
Text("Bass enhance") ProgressView().controlSize(.small)
Slider(value: $bassLevel, in: 0 ... 100, step: 1) { editing in Text("Salvataggio")
if !editing {
Task { try? await environment.digiRadio.setBassEnhance(level: Int(bassLevel)) }
}
}
}
}
Section("Equalizzatore (6 bande)") {
ForEach($eqBands) { $band in
VStack(alignment: .leading) {
Text("\(Int(band.centerHz)) Hz")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Slider(value: $band.gainDb, in: -12 ... 12, step: 0.5) { editing in
if !editing { Task { await applyEQ() } }
} }
} }
} }
} }
Section { @ViewBuilder
Button("Reset profilo audio", role: .destructive) { private func panelPicker(_ vm: AudioViewModel) -> some View {
Task { HStack(spacing: IGITheme.spacingS) {
try? await environment.digiRadio.resetAudio() ForEach(AudioPanel.allCases) { panel in
syncFromState() Button {
} withAnimation(.snappy) { vm.panel = panel }
} } label: {
} Label(panel.rawValue, systemImage: panel.icon)
} .font(.subheadline.weight(.semibold))
.navigationTitle("Audio") .frame(maxWidth: .infinity)
.onAppear { .padding(.vertical, 12)
Task { .background(
try? await environment.digiRadio.refreshAudioProfile() vm.panel == panel
syncFromState() ? AnyShapeStyle(IGITheme.accent.gradient)
} : AnyShapeStyle(Color.primary.opacity(0.06)),
} in: RoundedRectangle(cornerRadius: 12, style: .continuous)
.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))
) )
try? await environment.digiRadio.applyAudioProfile(profile) .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)
} }
} }
@@ -10,96 +10,30 @@ struct FMRadioView: View {
var body: some View { var body: some View {
let fm = environment.state.tuner.fm let fm = environment.state.tuner.fm
Form { ScrollView {
Section { VStack(alignment: .leading, spacing: IGITheme.spacingL) {
VStack(spacing: IGITheme.spacingM) { IGIFrequencyHero(
Text(String(format: "%.2f", frequencyMHz)) frequencyMHz: $frequencyMHz,
.font(.system(size: 56, weight: .bold, design: .rounded)) stationName: fm?.stationName,
.monospacedDigit() isTuning: isTuning,
.accessibilityLabel("Frequenza \(frequencyMHz) megahertz") onTune: { Task { await tune() } }
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)
}
if let fm { if let fm {
Section("Stato") { signalCard(fm)
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 ? "" : "Mono")
}
}
if let ps = fm.stationName, !ps.isEmpty { if let ps = fm.stationName, !ps.isEmpty {
Section("RDS") { rdsCard(fm, ps: ps)
LabeledContent("PS", value: ps)
if let rt = fm.radiotext, !rt.isEmpty {
Text(rt).font(.body)
}
}
} }
} }
Section("Seek") { seekSection
Button("Seek su") { Task { try? await environment.digiRadio.seekFM(direction: "up") } } scanSection
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)
}
}
}
}
}
} }
.padding(IGITheme.spacingM)
} }
.background(IGIHeroBackground())
.navigationTitle("FM") .navigationTitle("FM")
.navigationBarTitleDisplayMode(.large)
.onAppear { .onAppear {
if let khz = environment.state.tuner.fm?.frequencyKhz { if let khz = environment.state.tuner.fm?.frequencyKhz {
frequencyMHz = Double(khz) / 1000.0 frequencyMHz = Double(khz) / 1000.0
@@ -107,9 +41,127 @@ struct FMRadioView: View {
Task { try? await environment.digiRadio.refreshTunerStatus() } Task { try? await environment.digiRadio.refreshTunerStatus() }
} }
.onChange(of: environment.state.tuner.fm?.frequencyKhz) { _, newValue in .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 ? "" : "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 { private func tune() async {
isTuning = true isTuning = true
@@ -11,31 +11,23 @@ struct HomeView: View {
VStack(alignment: .leading, spacing: IGITheme.spacingL) { VStack(alignment: .leading, spacing: IGITheme.spacingL) {
header(state: state) header(state: state)
nowPlayingCard(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 volumeSection
presetsSection(state: state) presetsSection(state: state)
} }
.padding(IGITheme.spacingM) .padding(IGITheme.spacingM)
} }
.background(IGITheme.screenBackground) .background(IGIHeroBackground())
.navigationTitle("igiRadio") .navigationTitle("igiRadio")
.navigationBarTitleDisplayMode(.large)
.safeAreaInset(edge: .top) { .safeAreaInset(edge: .top) {
if !state.connection.isConnected { if !state.connection.isConnected {
NavigationLink { connectionBanner
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)
} }
} }
.toolbar { .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 @ViewBuilder
private func header(state: DigiRadioState) -> some View { private func header(state: DigiRadioState) -> some View {
HStack { HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 8) {
Text("DigiRadio") Text("DigiRadio")
.font(.largeTitle.bold()) .font(.title.weight(.bold))
HStack(spacing: 8) { HStack(spacing: 8) {
IGIStatusDot(isConnected: state.connection.isConnected) IGIStatusDot(isConnected: state.connection.isConnected)
Text(state.connection.isConnected ? "Connesso" : "Non connesso") Text(state.connection.isConnected ? "Connesso" : "Non connesso")
.font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
IGIBandBadge(band: state.tuner.band, locked: state.tuner.locked)
} }
Spacer() Spacer()
if let rssi = state.tuner.fm?.rssiDbuv { if let rssi = state.tuner.fm?.rssiDbuv {
VStack(alignment: .trailing) { VStack(alignment: .trailing, spacing: 6) {
Text("Segnale") Text("Segnale")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
IGISignalBar(level: Double(rssi) / 80.0) IGISignalBar(level: Double(rssi) / 80.0)
.frame(width: 120) .frame(width: 100)
} }
} }
} }
@@ -87,75 +99,96 @@ struct HomeView: View {
@ViewBuilder @ViewBuilder
private func nowPlayingCard(state: DigiRadioState) -> some View { private func nowPlayingCard(state: DigiRadioState) -> some View {
IGICard {
VStack(spacing: IGITheme.spacingM) { VStack(spacing: IGITheme.spacingM) {
ZStack { ZStack {
RoundedRectangle(cornerRadius: 24, style: .continuous) RoundedRectangle(cornerRadius: 28, style: .continuous)
.fill( .fill(
LinearGradient( LinearGradient(
colors: [.accentColor.opacity(0.35), .purple.opacity(0.25)], colors: [
IGITheme.accent.opacity(0.45),
.purple.opacity(0.35),
IGITheme.accent.opacity(0.25)
],
startPoint: .topLeading, startPoint: .topLeading,
endPoint: .bottomTrailing endPoint: .bottomTrailing
) )
) )
.frame(height: 220) .frame(height: 200)
.overlay {
Circle()
.fill(.white.opacity(0.08))
.frame(width: 160, height: 160)
.blur(radius: 2)
}
Image(systemName: state.tuner.band == .fm ? "radio.fill" : "antenna.radiowaves.left.and.right") Image(systemName: state.tuner.band == .fm ? "radio.fill" : "antenna.radiowaves.left.and.right")
.font(.system(size: 64)) .font(.system(size: 56))
.foregroundStyle(.white.opacity(0.9)) .foregroundStyle(.white.opacity(0.95))
.symbolEffect(.pulse, options: .repeating, value: state.tuner.locked)
} }
VStack(spacing: 6) { VStack(spacing: 6) {
Text(primaryTitle(state: state)) Text(primaryTitle(state: state))
.font(.title2.weight(.semibold)) .font(.title2.weight(.bold))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.contentTransition(.interpolate)
Text(secondaryLine(state: state)) Text(secondaryLine(state: state))
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .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 { @ViewBuilder
HStack(spacing: IGITheme.spacingL) { private func quickLinkLabel(_ title: String, icon: String) -> some View {
Button { Label(title, systemImage: icon)
Task { await viewModel?.seekFM("down") } .font(.caption.weight(.semibold))
} 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) .frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
} }
private var volumeSection: some View { 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 IGIVolumeSlider(value: $volume) { value in
Task { await viewModel?.setVolume(value) } Task { await viewModel?.setVolume(value) }
} }
} }
.igiPremiumCard()
} }
@ViewBuilder @ViewBuilder
@@ -171,20 +204,12 @@ struct HomeView: View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: IGITheme.spacingS) { HStack(spacing: IGITheme.spacingS) {
ForEach(Array(state.stations.enumerated()), id: \.offset) { index, station in 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) } 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 { switch state.tuner.band {
case .fm: case .fm:
if let khz = state.tuner.fm?.frequencyKhz { 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" return state.tuner.fm?.radiotext ?? "FM Radio"
case .dab: case .dab:
@@ -33,6 +33,9 @@ private struct IPhoneRootView: View {
NavigationStack { PresetsView() } NavigationStack { PresetsView() }
.tabItem { Label("Preset", systemImage: "star.fill") } .tabItem { Label("Preset", systemImage: "star.fill") }
NavigationStack { AudioView() }
.tabItem { Label("Audio", systemImage: "waveform") }
NavigationStack { SettingsRootView() } NavigationStack { SettingsRootView() }
.tabItem { Label("Impostazioni", systemImage: "gearshape.fill") } .tabItem { Label("Impostazioni", systemImage: "gearshape.fill") }
} }
@@ -15,7 +15,11 @@ struct SettingsRootView: View {
NavigationLink("Preset") { PresetsView() } NavigationLink("Preset") { PresetsView() }
} }
Section("Audio") { Section("Audio") {
NavigationLink("Profilo audio") { AudioView() } NavigationLink {
AudioView()
} label: {
Label("Mixer & Equalizzatore", systemImage: "slider.vertical.3")
}
NavigationLink("Bluetooth speaker") { BluetoothView() } NavigationLink("Bluetooth speaker") { BluetoothView() }
} }
} }