TheNoiseClock/TheNoiseClock/Views/Noise/Components/SoundCategoryView.swift

280 lines
9.3 KiB
Swift

//
// SoundCategoryView.swift
// TheNoiseClock
//
// Created by Matt Bruce on 9/7/25.
//
import SwiftUI
/// Category-based sound selection view with grid layout
struct SoundCategoryView: View {
// MARK: - Properties
let sounds: [Sound]
@Binding var selectedSound: Sound?
@State private var selectedCategory: String = "all"
@State private var searchText: String = ""
@State private var viewModel = NoiseViewModel()
// MARK: - Computed Properties
private var filteredSounds: [Sound] {
let nonAlarmSounds = sounds.filter { $0.category != "alarm" }
let categoryFiltered = selectedCategory == "all"
? nonAlarmSounds
: nonAlarmSounds.filter { $0.category == selectedCategory }
if searchText.isEmpty {
return categoryFiltered
} else {
return categoryFiltered.filter { sound in
sound.name.localizedCaseInsensitiveContains(searchText) ||
sound.description.localizedCaseInsensitiveContains(searchText)
}
}
}
// MARK: - Helper Methods
private func getCategoryCount(for category: String) -> Int {
let nonAlarmSounds = sounds.filter { $0.category != "alarm" }
if category == "all" {
return nonAlarmSounds.count
} else {
return nonAlarmSounds.filter { $0.category == category }.count
}
}
private var categories: [String] {
let nonAlarmSounds = sounds.filter { $0.category != "alarm" }
let uniqueCategories = Set(nonAlarmSounds.map { $0.category })
return ["all"] + Array(uniqueCategories).sorted()
}
private var categoryDisplayName: (String) -> String {
return { category in
switch category {
case "all": return "All"
case "ambient": return "Ambient"
case "nature": return "Nature"
case "mechanical": return "Mechanical"
default: return category.capitalized
}
}
}
// MARK: - Body
var body: some View {
VStack(spacing: UIConstants.Spacing.medium) {
// Search Bar
searchBar
// Category Tabs
categoryTabs
// Scrollable Sound Grid
ScrollView {
soundGrid
}
}
.padding(.top, UIConstants.Spacing.medium)
}
// MARK: - Subviews
private var searchBar: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("Search sounds...", text: $searchText)
.textFieldStyle(.plain)
.foregroundColor(.primary)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
.padding(.horizontal, UIConstants.Spacing.medium)
.padding(.vertical, UIConstants.Spacing.small)
.background(Color(.systemGray6))
.cornerRadius(10)
}
private var categoryTabs: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: UIConstants.Spacing.small) {
ForEach(categories, id: \.self) { category in
CategoryTab(
title: categoryDisplayName(category),
isSelected: selectedCategory == category,
count: getCategoryCount(for: category)
) {
selectedCategory = category
}
}
}
.padding(.horizontal, UIConstants.Spacing.medium)
}
}
private var soundGrid: some View {
LazyVStack(spacing: UIConstants.Spacing.small) {
ForEach(filteredSounds) { sound in
SoundCard(
sound: sound,
isSelected: selectedSound?.id == sound.id,
isPlaying: viewModel.isPlaying && selectedSound?.id == sound.id,
isPreviewing: viewModel.isPreviewing && viewModel.previewSound?.id == sound.id,
onSelect: {
selectedSound = sound
},
onPreview: {
viewModel.previewSound(sound)
}
)
}
}
.padding(.horizontal, UIConstants.Spacing.medium)
}
}
// MARK: - Supporting Views
struct CategoryTab: View {
let title: String
let isSelected: Bool
let count: Int
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 4) {
Text(title)
.font(.subheadline.weight(.medium))
if count > 0 {
Text("(\(count))")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, UIConstants.Spacing.medium)
.padding(.vertical, UIConstants.Spacing.small)
.background(isSelected ? UIConstants.Colors.accentColor : Color(.systemGray6))
.foregroundColor(isSelected ? .white : UIConstants.Colors.primaryText)
.cornerRadius(20)
}
.buttonStyle(.plain)
}
}
struct SoundCard: View {
let sound: Sound
let isSelected: Bool
let isPlaying: Bool
let isPreviewing: Bool
let onSelect: () -> Void
let onPreview: () -> Void
var body: some View {
HStack(spacing: UIConstants.Spacing.medium) {
// Sound Icon (Left)
ZStack {
Circle()
.fill(isSelected ? UIConstants.Colors.accentColor : Color(.systemGray5))
.frame(width: 50, height: 50)
Image(systemName: soundIcon)
.font(.title3)
.foregroundColor(isSelected ? .white : UIConstants.Colors.primaryText)
if isPlaying {
Circle()
.stroke(UIConstants.Colors.accentColor, lineWidth: 2)
.frame(width: 58, height: 58)
.scaleEffect(1.05)
.animation(.easeInOut(duration: 1).repeatForever(autoreverses: true), value: isPlaying)
}
if isPreviewing {
Circle()
.stroke(UIConstants.Colors.accentColor, lineWidth: 2)
.frame(width: 58, height: 58)
.scaleEffect(1.02)
.animation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true), value: isPreviewing)
}
}
// Sound Info (Right)
VStack(alignment: .leading, spacing: 4) {
// Sound Name
Text(sound.name)
.font(.subheadline.weight(.medium))
.foregroundColor(UIConstants.Colors.primaryText)
.lineLimit(1)
// Description
Text(sound.description)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
// Category Badge
HStack {
Text(sound.category.capitalized)
.font(.caption2.weight(.medium))
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(UIConstants.Colors.accentColor, in: Capsule())
Spacer()
}
}
Spacer()
}
.padding(.horizontal, UIConstants.Spacing.medium)
.padding(.vertical, UIConstants.Spacing.small)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isSelected ? UIConstants.Colors.accentColor.opacity(0.1) : Color(.systemBackground))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(isSelected ? UIConstants.Colors.accentColor : Color.clear, lineWidth: 2)
)
)
.onTapGesture {
onSelect()
}
.onLongPressGesture {
onPreview()
}
}
private var soundIcon: String {
switch sound.category {
case "ambient":
return "waveform"
case "nature":
return "cloud.rain"
case "mechanical":
return "fan"
default:
return "speaker.wave.2"
}
}
}
// MARK: - Preview
#Preview {
SoundCategoryView(
sounds: [
Sound(name: "White Noise", fileName: "white-noise.mp3", category: "ambient", description: "Classic white noise"),
Sound(name: "Heavy Rain", fileName: "heavy-rain.mp3", category: "nature", description: "Heavy rainfall sounds"),
Sound(name: "Fan Noise", fileName: "fan-noise.mp3", category: "mechanical", description: "Fan sounds"),
Sound(name: "Digital Alarm", fileName: "digital-alarm.mp3", category: "alarm", description: "Alarm sound")
],
selectedSound: .constant(nil)
)
.padding()
}