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;
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 = "<group>";
};
693436FA1780106DA05D06E3 /* igiRadioTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Tests/igiRadioTests;
sourceTree = "<group>";
};
693436FA1780106DA05D06E3 /* Tests/igiRadioTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests/igiRadioTests; sourceTree = "<group>"; };
F7E3CB0B027CB68EA63A4AB8 /* igiRadio */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (7D085337BF0EA6EB04337F45 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = igiRadio; sourceTree = "<group>"; };
/* 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 = "<group>";
@@ -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 */;
@@ -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 {
@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)
ScrollView {
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") {
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)) }
@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)
}
}
}
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)) }
}
}
}
}
Section("Equalizzatore (6 bande)") {
ForEach($eqBands) { $band in
VStack(alignment: .leading) {
Text("\(Int(band.centerHz)) Hz")
Spacer()
if vm.isSaving {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Salvataggio")
.font(.caption)
.foregroundStyle(.secondary)
Slider(value: $band.gainDb, in: -12 ... 12, step: 0.5) { editing in
if !editing { Task { await applyEQ() } }
}
}
}
}
Section {
Button("Reset profilo audio", role: .destructive) {
Task {
try? await environment.digiRadio.resetAudio()
syncFromState()
}
}
}
}
.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))
@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)
)
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 {
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 ? "" : "Mono")
}
}
signalCard(fm)
if let ps = fm.stationName, !ps.isEmpty {
Section("RDS") {
LabeledContent("PS", value: ps)
if let rt = fm.radiotext, !rt.isEmpty {
Text(rt).font(.body)
}
}
rdsCard(fm, ps: ps)
}
}
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,9 +41,127 @@ 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 ? "" : "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
@@ -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)
RoundedRectangle(cornerRadius: 28, style: .continuous)
.fill(
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,
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")
.font(.system(size: 64))
.foregroundStyle(.white.opacity(0.9))
.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(.semibold))
.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)
}
@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:
@@ -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") }
}
@@ -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() }
}
}