280 lines
9.3 KiB
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()
|
|
}
|