diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index d35c949..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/BusRoad.xcodeproj/project.pbxproj b/BusRoad.xcodeproj/project.pbxproj index 66b752d..7fee719 100644 --- a/BusRoad.xcodeproj/project.pbxproj +++ b/BusRoad.xcodeproj/project.pbxproj @@ -7,10 +7,12 @@ objects = { /* Begin PBXBuildFile section */ + 60043BAB2E8AC91300167A28 /* Secrets.plist in Resources */ = {isa = PBXBuildFile; fileRef = 60043BAA2E8AC91300167A28 /* Secrets.plist */; }; 60A8CDB02E7CE7B900530B92 /* .swiftlint.yml in Resources */ = {isa = PBXBuildFile; fileRef = 60A8CDAF2E7CE7A100530B92 /* .swiftlint.yml */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 60043BAA2E8AC91300167A28 /* Secrets.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Secrets.plist; sourceTree = ""; }; 60A8CDA12E7CDFE500530B92 /* BusRoad.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BusRoad.app; sourceTree = BUILT_PRODUCTS_DIR; }; 60A8CDAF2E7CE7A100530B92 /* .swiftlint.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = .swiftlint.yml; sourceTree = ""; }; /* End PBXFileReference section */ @@ -51,6 +53,7 @@ 60A8CD982E7CDFE500530B92 = { isa = PBXGroup; children = ( + 60043BAA2E8AC91300167A28 /* Secrets.plist */, 60A8CDAF2E7CE7A100530B92 /* .swiftlint.yml */, 60A8CDA32E7CDFE500530B92 /* BusRoad */, 60A8CDA22E7CDFE500530B92 /* Products */, @@ -75,7 +78,7 @@ 60A8CD9D2E7CDFE500530B92 /* Sources */, 60A8CD9E2E7CDFE500530B92 /* Frameworks */, 60A8CD9F2E7CDFE500530B92 /* Resources */, - 60A8CDB12E7CE82300530B92 /* ShellScript */, + 60A8CDB12E7CE82300530B92 /* Run Script */, ); buildRules = ( ); @@ -130,6 +133,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 60043BAB2E8AC91300167A28 /* Secrets.plist in Resources */, 60A8CDB02E7CE7B900530B92 /* .swiftlint.yml in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -137,7 +141,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 60A8CDB12E7CE82300530B92 /* ShellScript */ = { + 60A8CDB12E7CE82300530B92 /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -148,6 +152,7 @@ inputPaths = ( "$(SRCROOT)/.swiftlint.yml", ); + name = "Run Script"; outputFileListPaths = ( ); outputPaths = ( @@ -304,6 +309,8 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BusRoad/Info.plist; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "\"음성 검색 기능을 사용하기 위해 마이크 접근 권한이 필요합니다.\""; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "\"말씀하신 내용을 텍스트로 변환하기 위해 음성 인식 권한이 필요합니다.\""; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -342,6 +349,8 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BusRoad/Info.plist; + INFOPLIST_KEY_NSMicrophoneUsageDescription = "\"음성 검색 기능을 사용하기 위해 마이크 접근 권한이 필요합니다.\""; + INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "\"말씀하신 내용을 텍스트로 변환하기 위해 음성 인식 권한이 필요합니다.\""; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/BusRoad/Component/MainSearchView/PlaceCard.swift b/BusRoad/Component/MainSearchView/PlaceCard.swift new file mode 100644 index 0000000..8c77f1c --- /dev/null +++ b/BusRoad/Component/MainSearchView/PlaceCard.swift @@ -0,0 +1,97 @@ +import SwiftUI + +struct PlaceCard: View { + let title: String + let address: String + var searchQuery: String? + var onTap: (() -> Void)? // 카드 탭 액션 + + var body: some View { + Button(action: { onTap?() }) { + VStack(alignment: .leading, spacing: 6) { + + if let query = searchQuery, !query.isEmpty { + Text(title.highlightedText(searchQuery: query)) + .lineLimit(1) + .truncationMode(.tail) + } else { + Text(title) + .font(.headline) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + } + + Text(address) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + .truncationMode(.tail) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.white)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color(.separator), lineWidth: 0.5) + ) + .shadow(color: Color.black.opacity(0.04), radius: 3, x: 0, y: 1) + } + .buttonStyle(.plain) + .contentShape(RoundedRectangle(cornerRadius: 12)) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(title), \(address)") + } +} + +// MARK: - 텍스트 하이라이트 헬퍼 +extension String { + /// 검색어와 일치하는 부분을 찾아서 AttributedString으로 변환 + func highlightedText(searchQuery: String, highlightColor: Color = .green) -> AttributedString { + var attributedString = AttributedString(self) + + guard !searchQuery.isEmpty else { return attributedString } + + let lowercasedText = self.lowercased() + let lowercasedQuery = searchQuery.lowercased() + + var searchStartIndex = lowercasedText.startIndex + + while let range = lowercasedText.range(of: lowercasedQuery, range: searchStartIndex.. Void)? + var onMicTap: (() -> Void)? + var onClearTap: (() -> Void)? + + var body: some View { + HStack(spacing: 8) { + searchIcon + textField + actionButton + } + .padding(.horizontal, compact ? 12 : 14) + .padding(.vertical, compact ? 10 : 12) + .background(searchBarBackground) + } +} + +// MARK: - Components +private extension SearchBar { + var searchIcon: some View { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + } + + var textField: some View { + TextField(placeholder, text: $text) + .focused($isFocused) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .submitLabel(.search) + .onSubmit { onSubmit?() } + } + + var actionButton: some View { + Button { + if text.isEmpty { onMicTap?() } else { onClearTap?() } + } label: { + Image(systemName: text.isEmpty ? "mic.fill" : "xmark.circle.fill") + .font(.title3) + .foregroundStyle(.black) + .padding(6) + .animation(nil, value: text.isEmpty) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + } + + var searchBarBackground: some View { + RoundedRectangle(cornerRadius: compact ? 14 : 16) + .fill(Color.white) + .shadow(color: .black.opacity(0.05), radius: 6, x: 0, y: 2) + } +} diff --git a/BusRoad/Component/MainSearchView/WaveRingsView.swift b/BusRoad/Component/MainSearchView/WaveRingsView.swift new file mode 100644 index 0000000..6a8b7e7 --- /dev/null +++ b/BusRoad/Component/MainSearchView/WaveRingsView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct WaveRingsView: View { + @State private var s1: CGFloat = 0.001 + @State private var s2: CGFloat = 0.001 + @State private var s3: CGFloat = 0.001 + + private let a1: CGFloat = 0.22 + private let a2: CGFloat = 0.28 + private let a3: CGFloat = 0.34 + + private let baseSize: CGFloat = 120 + private let maxScale: CGFloat = 2.1 + private let duration: Double = 2.0 + + var body: some View { + ZStack { + ring(scale: s1, baseAlpha: a1) + ring(scale: s2, baseAlpha: a2) + ring(scale: s3, baseAlpha: a3) + } + .frame(width: baseSize, height: baseSize) + .onAppear { start() } + .onDisappear { reset() } + } + + private func ring(scale: CGFloat, baseAlpha: CGFloat) -> some View { + Circle() + .fill(Color.white.opacity(baseAlpha)) + .scaleEffect(scale) + .opacity(max(0.0, min(1.0, (maxScale + 0.2) - scale))) + } + + private func start() { + s1 = 0.001; s2 = 0.001; s3 = 0.001 + + withAnimation(.easeOut(duration: duration).repeatForever(autoreverses: false)) { + s1 = maxScale + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { + withAnimation(.easeOut(duration: duration).repeatForever(autoreverses: false)) { + s2 = maxScale + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.90) { + withAnimation(.easeOut(duration: duration).repeatForever(autoreverses: false)) { + s3 = maxScale + } + } + } + + private func reset() { + withAnimation(.easeOut(duration: 0.2)) { + s1 = 0.001; s2 = 0.001; s3 = 0.001 + } + } +} diff --git a/BusRoad/Component/dummyComponent.swift b/BusRoad/Component/dummyComponent.swift deleted file mode 100644 index 54e7c29..0000000 --- a/BusRoad/Component/dummyComponent.swift +++ /dev/null @@ -1,7 +0,0 @@ -// -// Untitled.swift -// BusRoad -// -// Created by 박난 on 9/23/25. -// - diff --git a/BusRoad/Feature/MainSearch/IntroSection.swift b/BusRoad/Feature/MainSearch/IntroSection.swift new file mode 100644 index 0000000..f31cdea --- /dev/null +++ b/BusRoad/Feature/MainSearch/IntroSection.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct IntroSection: View { + @Binding var query: String + var isFocused: FocusState.Binding + + let onSubmit: () -> Void + let onMicTap: () -> Void + let onClear: () -> Void + + var body: some View { + VStack(spacing: 16) { + Spacer() + Text("어디로 갈까요?") + .font(.system(size: 28, weight: .heavy)) + .foregroundStyle(Color.green.opacity(0.9)) + + SearchBar( + text: $query, + placeholder: "장소 이름 검색하기", + isFocused: isFocused, + compact: false, + onSubmit: onSubmit, + onMicTap: onMicTap, + onClearTap: onClear + ) + .padding(.horizontal, 16) + + Spacer() + } + } +} diff --git a/BusRoad/Feature/MainSearch/MainSearchView.swift b/BusRoad/Feature/MainSearch/MainSearchView.swift new file mode 100644 index 0000000..f6eaf6d --- /dev/null +++ b/BusRoad/Feature/MainSearch/MainSearchView.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct MainSearchView: View { + @EnvironmentObject private var coordinator: NavigationCoordinator + @StateObject private var vm = MainSearchViewModel() + + @State private var hasSubmitted = false + @State private var isSearchMode = false + @FocusState private var isFocused: Bool + + var body: some View { + Group { + if isSearchMode { + SearchModeSection( + query: Binding(get: { vm.query }, set: { vm.query = $0 }), + results: vm.results, + isFocused: $isFocused, + onBack: { exitSearchMode() }, + onSubmit: { performSearch() }, + onClear: { clearSearch() }, + onMicTap: { + isFocused = false + coordinator.push(.voiceSearch) + } + ) + } else { + IntroSection( + query: Binding(get: { vm.query }, set: { vm.query = $0 }), + isFocused: $isFocused, + onSubmit: { performSearch() }, + onMicTap: { + isFocused = false + coordinator.push(.voiceSearch) + }, + onClear: { clearSearch() } + ) + } + } + .onTapGesture { isFocused = false } + .animation(nil, value: vm.query) + .toolbar(.hidden, for: .navigationBar) + .background(Color(.systemBackground).ignoresSafeArea()) + .onChange(of: isFocused) { _, new in + if new && !isSearchMode { + isSearchMode = true + DispatchQueue.main.async { isFocused = true } + } + } + // ✅ SearchManager가 올린 전환 신호 감시 + .onChange(of: vm.shouldShowSearchMode) { _, show in + if show { + isSearchMode = true + hasSubmitted = true + vm.resetSearchMode() + } + } + } + + // MARK: - Helpers + @MainActor func exitSearchMode() { + isSearchMode = false + isFocused = false + hasSubmitted = false + vm.query = "" + // vm.results는 매니저가 관리하니 굳이 초기화 필요 없음 + } + + @MainActor func performSearch() { + isSearchMode = true + hasSubmitted = true + Task { await vm.search() } + } + + @MainActor func clearSearch() { + vm.query = "" + hasSubmitted = false + isFocused = true + } +} +//#Preview { +// MainSearchView() +// .environmentObject(NavigationCoordinator()) +//} diff --git a/BusRoad/Feature/MainSearch/MainSearchViewModel.swift b/BusRoad/Feature/MainSearch/MainSearchViewModel.swift new file mode 100644 index 0000000..f56d32d --- /dev/null +++ b/BusRoad/Feature/MainSearch/MainSearchViewModel.swift @@ -0,0 +1,30 @@ +import Combine +import Foundation + +@MainActor +final class MainSearchViewModel: ObservableObject { + + let searchManager = SearchManager.shared + + // SearchManager의 변경을 View로 릴레이 (UI 갱신 보장) + private var bag = Set() + init() { + searchManager.objectWillChange + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &bag) + } + + // 뷰에서 쓰기 편한 프록시 + var query: String { + get { searchManager.query } + set { searchManager.query = newValue } + } + var results: [NaverLocalItem] { searchManager.results } + var shouldShowSearchMode: Bool { searchManager.shouldShowSearchMode } + var isLoading: Bool { searchManager.isLoading } + var errorMessage: String? { searchManager.errorMessage } + + + func search() async { await searchManager.search() } + func resetSearchMode() { searchManager.resetSearchMode() } +} diff --git a/BusRoad/Feature/MainSearch/SearchModeSection.swift b/BusRoad/Feature/MainSearch/SearchModeSection.swift new file mode 100644 index 0000000..dbef65e --- /dev/null +++ b/BusRoad/Feature/MainSearch/SearchModeSection.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct SearchModeSection: View { + @Binding var query: String + let results: [NaverLocalItem] // vm.results의 요소 타입에 맞춰서 + var isFocused: FocusState.Binding + + let onBack: () -> Void + let onSubmit: () -> Void + let onClear: () -> Void + let onMicTap: () -> Void + + var body: some View { + VStack(spacing: 12) { + header + list + } + } + + private var header: some View { + HStack(spacing: 12) { + Button(action: onBack) { + Image(systemName: "chevron.left") + .font(.title3.weight(.semibold)) + } + .buttonStyle(.plain) + + SearchBar( + text: $query, + placeholder: "장소 이름 검색하기", + isFocused: isFocused, + compact: true, + onSubmit: onSubmit, + onMicTap: onMicTap, // 검색 모드 헤더엔 마이크 없으면 비워둠 + onClearTap: onClear + ) + } + .padding(.horizontal, 16) + } + + private var list: some View { + VStack { + if results.isEmpty && query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text("") // 필요 시 플레이스홀더 + .foregroundStyle(.secondary) + .padding(.top, 8) + } + + ScrollView { + LazyVStack(spacing: 12) { + ForEach(results) { item in + PlaceCard( + title: item.plainTitle, + address: item.displayAddress, + searchQuery: query.trimmingCharacters(in: .whitespacesAndNewlines) + ) { + // 예: 아이템 탭 시 수행할 액션 + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + .scrollDismissesKeyboard(.interactively) + } + } +} diff --git a/BusRoad/Feature/TextSearch/TextSearchView.swift b/BusRoad/Feature/TextSearch/TextSearchView.swift deleted file mode 100644 index 0c411e4..0000000 --- a/BusRoad/Feature/TextSearch/TextSearchView.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Untitled.swift -// BusRoad -// -// Created by 박난 on 9/23/25. -// - -import SwiftUI - -struct TextSearchView: View { - @EnvironmentObject private var coordinator: NavigationCoordinator - var body: some View { - Button { - coordinator.push(.voiceSearch) - } label: { - Text("Move to VoiceSearch") - } - } -} diff --git a/BusRoad/Feature/VoiceSearch/VioceSearchView+Style.swift b/BusRoad/Feature/VoiceSearch/VioceSearchView+Style.swift new file mode 100644 index 0000000..d83bae8 --- /dev/null +++ b/BusRoad/Feature/VoiceSearch/VioceSearchView+Style.swift @@ -0,0 +1,32 @@ +import SwiftUI + +extension VoiceSearchView { + var backgroundGradient: some View { + LinearGradient( + colors: [ + Color.green.opacity(0.8), + Color.green.opacity(0.6), + Color.green.opacity(0.4) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + } + + var micButtonColor: Color { + switch vm.state { + case .ready, .failed, .listening, .processing, .completed: return .white + } + } + + var micIconColor: Color { + switch vm.state { + case .ready, .failed, .listening, .processing, .completed: return .black + } + } + + var micIconName: String { + "mic.fill" + } +} diff --git a/BusRoad/Feature/VoiceSearch/VoiceSearchView+Actions.swift b/BusRoad/Feature/VoiceSearch/VoiceSearchView+Actions.swift new file mode 100644 index 0000000..6751f35 --- /dev/null +++ b/BusRoad/Feature/VoiceSearch/VoiceSearchView+Actions.swift @@ -0,0 +1,12 @@ +import SwiftUI + +extension VoiceSearchView { + func handleMicButtonTap() { + switch vm.state { + case .ready, .failed: + vm.retry() + case .listening, .processing, .completed: + break + } + } +} diff --git a/BusRoad/Feature/VoiceSearch/VoiceSearchView.swift b/BusRoad/Feature/VoiceSearch/VoiceSearchView.swift index 99cbabd..604ede2 100644 --- a/BusRoad/Feature/VoiceSearch/VoiceSearchView.swift +++ b/BusRoad/Feature/VoiceSearch/VoiceSearchView.swift @@ -1,14 +1,82 @@ -// -// Untitled.swift -// BusRoad -// -// Created by 박난 on 9/23/25. -// - import SwiftUI struct VoiceSearchView: View { + @EnvironmentObject private var coordinator: NavigationCoordinator + @StateObject var vm = VoiceSearchViewModel() + @Environment(\.dismiss) private var dismiss + + var onSearchCompleted: ((String) -> Void)? = nil + var body: some View { - Text("VoiceSearch View") + ZStack { + backgroundGradient + + VStack { + Spacer() + + Text(vm.centerMessage) + .font(.title2.weight(.medium)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + Spacer() + + ZStack { + if vm.showWaveAnimation { + WaveRingsView() + } + Button(action: handleMicButtonTap) { + ZStack { + Circle() + .fill(micButtonColor) + .frame(width: 120, height: 120) + .shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5) + Image(systemName: micIconName) + .font(.system(size: 40, weight: .medium)) + .foregroundColor(micIconColor) + } + } + // 듣는 중/처리 중에는 살짝 눌린 느낌 + .scaleEffect(vm.isMicButtonEnabled ? 1.0 : 0.95) + .animation(.spring(response: 0.3, dampingFraction: 0.6), value: vm.state) + .disabled(!vm.isMicButtonEnabled) // 준비/실패 외 상태에서는 탭 방지 + } + .padding(.bottom, 60) + .animation(.easeInOut(duration: 0.25), value: vm.showWaveAnimation) + } + .padding(.horizontal, 32) + + // 닫기 버튼 + VStack { + HStack { + Spacer() + Button { vm.dismiss() } label: { + Image(systemName: "xmark") + .font(.title3) + .foregroundColor(.white) + .padding(12) + .background(Circle().fill(Color.white.opacity(0.2))) + } + .padding(.top, 8) + .padding(.trailing, 16) + } + Spacer() + } + } + .toolbar(.hidden, for: .navigationBar) + .onAppear { + // 1) 완료 시: 외부 콜백(있으면) -> pop + vm.onSearchCompleted = { text in + onSearchCompleted?(text) + coordinator.pop() + } + // 2) 닫기(X) 시 pop + vm.onDismiss = { coordinator.pop() } + // 3) 실제 리스닝 시작/바인딩 + vm.onAppear() + } + .onDisappear { + vm.stopListening() + } } } diff --git a/BusRoad/Feature/VoiceSearch/VoiceSearchViewModel.swift b/BusRoad/Feature/VoiceSearch/VoiceSearchViewModel.swift new file mode 100644 index 0000000..9d94283 --- /dev/null +++ b/BusRoad/Feature/VoiceSearch/VoiceSearchViewModel.swift @@ -0,0 +1,172 @@ +import Combine +import Foundation + +enum VoiceSearchState { case ready, listening, processing, completed, failed } + +@MainActor +final class VoiceSearchViewModel: ObservableObject { + @Published var state: VoiceSearchState = .ready + @Published var recognizedText = "" + @Published var errorMessage: String? + + private let searchManager = SearchManager.shared + private let speechManager = SpeechRecognitionManager() + private var cancellables = Set() + private var lastTranscript: String = "" + private var isSearchCompleted = false + + var onSearchCompleted: ((String) -> Void)? + var onDismiss: (() -> Void)? + + init() { setupSpeechManager() } + + // MARK: - 공개 메서드들 + + /// 음성 인식 시작 + func startListening() { + guard speechManager.isAvailable else { + handleError("음성 인식을 사용할 수 없습니다.") + return + } + isSearchCompleted = false + + + state = .listening + errorMessage = nil + recognizedText = "" + + speechManager.startRecording() + } + + /// 음성 인식 중지 + func stopListening() { + speechManager.stopRecording() + if state == .listening { + state = .ready + } + } + + /// 재시도 + func retry() { + isSearchCompleted = false + + speechManager.reset() + startListening() + } + + /// 화면 닫기 + func dismiss() { + speechManager.stopRecording() + isSearchCompleted = false + + onDismiss?() + } + + /// 뷰가 나타날 때 자동 시작 + func onAppear() { + Task { + try? await Task.sleep(nanoseconds: 500_000_000) + startListening() + } + } + + + // MARK: - 프라이빗 메서드들 + private func setupSpeechManager() { + // 녹음 상태 + speechManager.$isRecording + .sink { [weak self] isRecording in + guard let self else { return } + if !isRecording && self.state == .listening { self.state = .processing } + } + .store(in: &cancellables) + + // 인식 텍스트 + speechManager.$recognizedText + .sink { [weak self] text in + guard let self else { return } + self.recognizedText = text + if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.lastTranscript = text + } + } + .store(in: &cancellables) + + // 에러 + speechManager.$errorMessage + .sink { [weak self] err in + if let e = err { self?.handleError(e) } + } + .store(in: &cancellables) + + // 완료 판정 + speechManager.$isRecording + .combineLatest(speechManager.$recognizedText) + .sink { [weak self] isRecording, _ in + guard let self else { return } + guard !isRecording, self.state == .processing else { return } + + let now = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines) + if !now.isEmpty { self.completeVoiceSearch(with: now); return } + + Task { @MainActor in + try? await Task.sleep(nanoseconds: 500_000_000) + guard self.state == .processing else { return } + let later = self.lastTranscript.trimmingCharacters(in: .whitespacesAndNewlines) + if !later.isEmpty { self.completeVoiceSearch(with: later) } + else { self.handleError("음성을 인식하지 못했습니다.") } + } + } + .store(in: &cancellables) + } + + private func completeVoiceSearch(with text: String) { + isSearchCompleted = true + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return handleError("음성을 인식하지 못했습니다.") } + + state = .completed + recognizedText = trimmed + + Task { @MainActor in + await searchManager.searchWithVoiceResult(trimmed) + onSearchCompleted?(trimmed) + } + } + + private func handleError(_ message: String) { + guard !isSearchCompleted else { return } + state = .failed + errorMessage = message + } +} +// MARK: - 편의 확장 +extension VoiceSearchViewModel { + + /// 가운데 표시할 메시지 + var centerMessage: String { + switch state { + case .ready: + return "원하는 장소를 말해보세요" + case .listening: + // 듣는 중에도 실시간으로 인식된 텍스트 표시 + return recognizedText.isEmpty ? "원하는 장소를 말해보세요" : recognizedText + case .processing: + return recognizedText.isEmpty ? "" : recognizedText + case .completed: + return recognizedText + case .failed: + return "마이크를 눌러서 다시 말해주세요" + } + } + + /// 파동 애니메이션 표시 여부 + var showWaveAnimation: Bool { + return state == .listening + } + + /// 마이크 버튼 활성화 여부 + var isMicButtonEnabled: Bool { + return state == .ready || state == .failed + } +} diff --git a/BusRoad/Model/PlaceSearchModel.swift b/BusRoad/Model/PlaceSearchModel.swift new file mode 100644 index 0000000..9439320 --- /dev/null +++ b/BusRoad/Model/PlaceSearchModel.swift @@ -0,0 +1,56 @@ +import Foundation + +struct NaverLocalResponse: Decodable { + let items: [NaverLocalItem] +} + +struct NaverLocalItem: Identifiable, Hashable, Decodable { + var id: UUID = UUID() + + let title: String? + let address: String? + let roadAddress: String? + let mapx: String? + let mapy: String? + + enum CodingKeys: String, CodingKey { + case title, address, roadAddress, mapx, mapy + } + + // 태그 제거 + var plainTitle: String { + (title ?? "") + .replacingOccurrences(of: "", with: "") + .replacingOccurrences(of: "", with: "") + } + + // 도로명 우선 표시, 없으면 지번 + var displayAddress: String { + if let road = roadAddress, !road.isEmpty { return road } + return address ?? "" + } + + // 좌표 변환 + var longitude: Double? { + mapx.flatMap { Double($0) }.map { $0 / 1e7 } + } + var latitude: Double? { + mapy.flatMap { Double($0) }.map { $0 / 1e7 } + } +} + +//MARK: - 화면전달용 DTO +struct PlaceSummary: Hashable, Identifiable, Codable { + var id = UUID() + let name: String + let address: String + let latitude: Double + let longitude: Double +} + +extension NaverLocalItem { + func toSummary() -> PlaceSummary? { + guard let lat = latitude, let lon = longitude else { return nil } + return .init(name: plainTitle, address: displayAddress, latitude: lat, longitude: lon) + } +} diff --git a/BusRoad/Model/dummyModel.swift b/BusRoad/Model/dummyModel.swift deleted file mode 100644 index 54e7c29..0000000 --- a/BusRoad/Model/dummyModel.swift +++ /dev/null @@ -1,7 +0,0 @@ -// -// Untitled.swift -// BusRoad -// -// Created by 박난 on 9/23/25. -// - diff --git a/BusRoad/Navigation/AppNavigationView.swift b/BusRoad/Navigation/AppNavigationView.swift index e912aef..b9beec0 100644 --- a/BusRoad/Navigation/AppNavigationView.swift +++ b/BusRoad/Navigation/AppNavigationView.swift @@ -12,7 +12,7 @@ struct AppNavigationView: View { var body: some View { NavigationStack(path: $coordinator.path) { - TextSearchView() + MainSearchView() .navigationDestination(for: Route.self) { route in switch route { case .beforeRide: @@ -23,8 +23,8 @@ struct AppNavigationView: View { OnRideView() case .routeSuggestion: RouteSuggestionView() - case .textSearch: - TextSearchView() + case .mainSearch: + MainSearchView() case .voiceSearch: VoiceSearchView() case .walking: diff --git a/BusRoad/Navigation/Route.swift b/BusRoad/Navigation/Route.swift index 77f9db6..f852780 100644 --- a/BusRoad/Navigation/Route.swift +++ b/BusRoad/Navigation/Route.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI enum Route: Hashable { - case textSearch + case mainSearch case voiceSearch case routeSuggestion case walking diff --git a/BusRoad/Utility/PlaceSearchService.swift b/BusRoad/Utility/PlaceSearchService.swift new file mode 100644 index 0000000..9cdf619 --- /dev/null +++ b/BusRoad/Utility/PlaceSearchService.swift @@ -0,0 +1,44 @@ +import Foundation + +/// 네이버 지역 검색 API를 호출하는 매니저 +final class PlaceSearchService { + private let clientID: String + private let clientSecret: String + + init( + clientID: String = Secrets.naverClientId, + clientSecret: String = Secrets.naverClientSecret + ) { + self.clientID = clientID + self.clientSecret = clientSecret + } + + /// 네이버 키워드 검색 + func search(keyword: String, display: Int = 5, sort: String = "random") async throws -> [NaverLocalItem] { + var comps = URLComponents() + comps.scheme = "https" + comps.host = "openapi.naver.com" + comps.path = "/v1/search/local.json" + comps.queryItems = [ + .init(name: "query", value: keyword), + .init(name: "display", value: String(display)), + .init(name: "sort", value: sort) // random=정확도, comment=리뷰순 + ] + guard let url = comps.url else { throw URLError(.badURL) } + + var req = URLRequest(url: url) + req.httpMethod = "GET" + req.addValue(clientID, forHTTPHeaderField: "X-Naver-Client-ID") + req.addValue(clientSecret, forHTTPHeaderField: "X-Naver-Client-Secret") + + let (data, resp) = try await URLSession.shared.data(for: req) + if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + let body = String(data: data, encoding: .utf8) ?? "" + print("[HTTP] \(http.statusCode) [BODY] \(body)") + throw URLError(.badServerResponse) + } + + let decoded = try JSONDecoder().decode(NaverLocalResponse.self, from: data) + return decoded.items + } +} diff --git a/BusRoad/Utility/SearchManager.swift b/BusRoad/Utility/SearchManager.swift new file mode 100644 index 0000000..56d9b9e --- /dev/null +++ b/BusRoad/Utility/SearchManager.swift @@ -0,0 +1,49 @@ +import Combine +import Foundation + + +@MainActor +final class SearchManager: ObservableObject { + + static let shared = SearchManager() + + // 공개 상태 + @Published var query: String = "" + @Published var results: [NaverLocalItem] = [] + @Published var isLoading = false + @Published var errorMessage: String? + @Published var shouldShowSearchMode = false // MainSearchView에서 onChange로 감시 + + // 의존 서비스 + private let service: PlaceSearchService + + private init(service: PlaceSearchService = PlaceSearchService()) { + self.service = service + } + + // 일반 검색 + func search() async { + errorMessage = nil + let kw = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !kw.isEmpty else { results = []; return } + + isLoading = true + defer { isLoading = false } + + do { + results = try await service.search(keyword: kw, display: 5, sort: "random") + } catch { + errorMessage = error.localizedDescription + results = [] + } + } + + // 음성검색: 전환 신호 먼저 → 검색 + func searchWithVoiceResult(_ text: String) async { + query = text + shouldShowSearchMode = true + await search() + } + + func resetSearchMode() { shouldShowSearchMode = false } +} diff --git a/BusRoad/Utility/SecretsLoader.swift b/BusRoad/Utility/SecretsLoader.swift new file mode 100644 index 0000000..a585028 --- /dev/null +++ b/BusRoad/Utility/SecretsLoader.swift @@ -0,0 +1,24 @@ +import Foundation + +private enum _SecretsLoader { + static func string(for key: String) -> String { + guard + let url = Bundle.main.url(forResource: "Secrets", withExtension: "plist"), + let data = try? Data(contentsOf: url), + let dict = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any], + let value = dict[key] as? String + else { + #if DEBUG + assertionFailure("🚨 Secrets.plist에서 \(key) 값을 찾지 못했습니다.") + #endif + return "" + } + return value + } +} + +enum Secrets { + static let odsayApiKey = _SecretsLoader.string(for: "ODSAY_API_KEY") + static let naverClientId = _SecretsLoader.string(for: "NAVER_CLIENT_ID") + static let naverClientSecret = _SecretsLoader.string(for: "NAVER_CLIENT_SECRET") +} diff --git a/BusRoad/Utility/SpeechRecognitionManager.swift b/BusRoad/Utility/SpeechRecognitionManager.swift new file mode 100644 index 0000000..36ff591 --- /dev/null +++ b/BusRoad/Utility/SpeechRecognitionManager.swift @@ -0,0 +1,246 @@ +import AVFoundation +import Combine +import Foundation +import Speech + +// MARK: - 음성 인식 매니저 +@MainActor +class SpeechRecognitionManager: ObservableObject { + + // MARK: - 퍼블리시 프로퍼티들 + @Published var isRecording = false // 녹음 중인지 여부 + @Published var recognizedText = "" // 인식된 텍스트 + @Published var isAvailable = false // 음성 인식 사용 가능 여부 + @Published var errorMessage: String? // 에러 메시지 + + // MARK: - 프라이빗 프로퍼티들 + private let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "ko-KR")) + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private let audioEngine = AVAudioEngine() + + // 침묵 감지를 위한 타이머 + private var silenceTimer: Timer? + private let silenceThreshold: TimeInterval = 3.0 // 3초 + + // MARK: - 초기화 + init() { + checkAvailability() + } + + // MARK: - 공개 메서드들 + + /// 음성 인식 시작 + func startRecording() { + guard !isRecording else { return } + + Task { + do { + try await requestPermissions() + try startRecognition() + } catch { + handleError(error) + } + } + } + + /// 음성 인식 중지 + func stopRecording() { + guard isRecording else { return } + + // 1) 오디오 엔진/탭 정리 + audioEngine.stop() + audioEngine.inputNode.removeTap(onBus: 0) + + // 2) 리퀘스트/태스크 종료 + recognitionRequest?.endAudio() + recognitionTask?.cancel() + recognitionTask = nil + recognitionRequest = nil + + // 3) 세션 비활성화 (다음 시작을 위한 깔끔한 상태) + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + + // 4) 상태/타이머 + isRecording = false + silenceTimer?.invalidate() + silenceTimer = nil + } + + /// 상태 초기화 + func reset() { + stopRecording() + recognizedText = "" + errorMessage = nil + } +} + +// MARK: - 프라이빗 메서드들 +private extension SpeechRecognitionManager { + + /// 사용 가능 여부 확인 + func checkAvailability() { + isAvailable = speechRecognizer?.isAvailable ?? false + + speechRecognizer?.delegate = SpeechRecognizerDelegate { [weak self] isAvailable in + Task { @MainActor in + self?.isAvailable = isAvailable + } + } + } + + /// 권한 요청 + func requestPermissions() async throws { + // 마이크 권한 요청 + let audioPermission: Bool + + if #available(iOS 17.0, *) { + audioPermission = await withCheckedContinuation { continuation in + AVAudioApplication.requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + } else { + audioPermission = await withCheckedContinuation { continuation in + AVAudioSession.sharedInstance().requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + } + + guard audioPermission else { + throw SpeechError.audioPermissionDenied + } + + // 음성 인식 권한 요청 + let speechPermission = await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status == .authorized) + } + } + + guard speechPermission else { + throw SpeechError.speechPermissionDenied + } + } + + /// 음성 인식 시작 + func startRecognition() throws { + // 기존 태스크 정리 + recognitionTask?.cancel() + recognitionTask = nil + + // 오디오 세션 + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers) + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + + // 요청 + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + // iOS 13+ : 검색 용도 힌트 (선택) + if #available(iOS 13.0, *) { request.taskHint = .search } + recognitionRequest = request + + // 입력 탭 + let input = audioEngine.inputNode + let format = input.outputFormat(forBus: 0) + input.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in + self?.recognitionRequest?.append(buffer) + } + + audioEngine.prepare() + try audioEngine.start() + + recognitionTask = speechRecognizer?.recognitionTask(with: request) { [weak self] result, error in + Task { @MainActor in + self?.handleRecognitionResult(result: result, error: error) + } + } + + isRecording = true + recognizedText = "" + errorMessage = nil + + startSilenceTimer() + } + + /// 인식 결과 처리 + func handleRecognitionResult(result: SFSpeechRecognitionResult?, error: Error?) { + if let error = error { + handleError(error) + return + } + + if let result = result { + recognizedText = result.bestTranscription.formattedString + + // 최종 결과가 나왔으면 타이머 재시작, 아니면 계속 진행 + if result.isFinal { + stopRecording() + } else { + restartSilenceTimer() + } + } + } + + /// 침묵 타이머 시작 + func startSilenceTimer() { + silenceTimer = Timer.scheduledTimer(withTimeInterval: silenceThreshold, repeats: false) { [weak self] _ in + Task { @MainActor in + self?.stopRecording() + } + } + } + + /// 침묵 타이머 재시작 + func restartSilenceTimer() { + silenceTimer?.invalidate() + startSilenceTimer() + } + + /// 에러 처리 + func handleError(_ error: Error) { + stopRecording() + + if let speechError = error as? SpeechError { + errorMessage = speechError.localizedDescription + } else { + errorMessage = "음성 인식 중 오류가 발생했습니다." + } + } +} + +// MARK: - 음성 인식 에러 타입 +enum SpeechError: LocalizedError { + case audioPermissionDenied // 마이크 권한 거부 + case speechPermissionDenied // 음성 인식 권한 거부 + case recognitionRequestFailed // 인식 요청 실패 + case recognitionFailed // 인식 실패 + + var errorDescription: String? { + switch self { + case .audioPermissionDenied: + return "마이크 권한이 필요합니다." + case .speechPermissionDenied: + return "음성 인식 권한이 필요합니다." + case .recognitionRequestFailed: + return "음성 인식 요청을 생성할 수 없습니다." + case .recognitionFailed: + return "음성 인식에 실패했습니다." + } + } +} + +// MARK: - 음성 인식기 delegate +private class SpeechRecognizerDelegate: NSObject, SFSpeechRecognizerDelegate { + let onAvailabilityChanged: (Bool) -> Void + + init(onAvailabilityChanged: @escaping (Bool) -> Void) { + self.onAvailabilityChanged = onAvailabilityChanged + } + + func speechRecognizer(_ speechRecognizer: SFSpeechRecognizer, availabilityDidChange available: Bool) { + onAvailabilityChanged(available) + } +} diff --git a/BusRoad/Utility/dummyUtility.swift b/BusRoad/Utility/dummyUtility.swift deleted file mode 100644 index 54e7c29..0000000 --- a/BusRoad/Utility/dummyUtility.swift +++ /dev/null @@ -1,7 +0,0 @@ -// -// Untitled.swift -// BusRoad -// -// Created by 박난 on 9/23/25. -// -