diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08ffd91..373ca02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,6 @@ name: CI on: - push: - branches: [ main, development ] pull_request: branches: [ main, development ] diff --git a/.gitignore b/.gitignore index 84518a2..b88dd4f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ Icon # These are local to your environment and should not be shared. DerivedData/ xcuserdata/ +*.xcodeproj/xcshareddata/ # Session-specific state data — avoid polluting commits *.xcuserstate diff --git a/Tiny.xcodeproj/xcshareddata/xcschemes/Tiny.xcscheme b/Tiny.xcodeproj/xcshareddata/xcschemes/Tiny.xcscheme new file mode 100644 index 0000000..a0f7986 --- /dev/null +++ b/Tiny.xcodeproj/xcshareddata/xcschemes/Tiny.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tiny/ContentView.swift b/Tiny/App/ContentView.swift similarity index 51% rename from Tiny/ContentView.swift rename to Tiny/App/ContentView.swift index 9b6f008..4257428 100644 --- a/Tiny/ContentView.swift +++ b/Tiny/App/ContentView.swift @@ -6,22 +6,32 @@ // import SwiftUI +import SwiftData struct ContentView: View { @AppStorage("hasShownOnboarding") var hasShownOnboarding: Bool = false + @StateObject private var heartbeatSoundManager = HeartbeatSoundManager() + @State private var showTimeline = false + + @Environment(\.modelContext) private var modelContext var body: some View { Group { if hasShownOnboarding { - OrbLiveListenView() + HeartbeatMainView() } else { OnBoardingView(hasShownOnboarding: $hasShownOnboarding) } } .preferredColorScheme(.dark) + .onAppear { + heartbeatSoundManager.modelContext = modelContext + heartbeatSoundManager.loadFromSwiftData() + } } } #Preview { ContentView() + .modelContainer(for: SavedHeartbeat.self, inMemory: true) } diff --git a/Tiny/App/tinyApp.swift b/Tiny/App/tinyApp.swift new file mode 100644 index 0000000..44b109e --- /dev/null +++ b/Tiny/App/tinyApp.swift @@ -0,0 +1,41 @@ +// +// tinyApp.swift +// tiny +// +// Created by Destu Cikal Ramdani on 25/10/25. +// + +import SwiftUI +import SwiftData + +@main +struct TinyApp: App { + @StateObject var heartbeatSoundManager = HeartbeatSoundManager() + @State private var isShowingSplashScreen: Bool = true // Add state to control splash screen + + // Define the container configuration + var sharedModelContainer: ModelContainer = { + let schema = Schema([ + SavedHeartbeat.self + ]) + let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + + do { + return try ModelContainer(for: schema, configurations: [modelConfiguration]) + } catch { + fatalError("Could not create ModelContainer: \(error)") + } + }() + + var body: some Scene { + WindowGroup { + if isShowingSplashScreen { + SplashScreenView(isShowingSplashScreen: $isShowingSplashScreen) + } else { + ContentView() + .environmentObject(heartbeatSoundManager) + } + } + .modelContainer(sharedModelContainer) + } +} diff --git a/Tiny/Components/FolderShapeButton.swift b/Tiny/Components/FolderShapeButton.swift deleted file mode 100644 index 0fb7824..0000000 --- a/Tiny/Components/FolderShapeButton.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// FolderCardView.swift -// Tiny -// -// Created by Destu Cikal Ramdani on 02/11/25. -// - -import SwiftUI - -struct FolderTabShape: Shape { - func path(in rect: CGRect) -> Path { - var path = Path() - let width = rect.size.width - let height = rect.size.height - path.move(to: CGPoint(x: 0.1*width, y: 0.00532*height)) - path.addLine(to: CGPoint(x: 0.35*width, y: 0.00532*height)) - path.addCurve(to: CGPoint(x: 0.42704*width, y: 0.05324*height), control1: CGPoint(x: 0.3764*width, y: 0.00532*height), control2: CGPoint(x: 0.40503*width, y: 0.02358*height)) - path.addCurve(to: CGPoint(x: 0.46406*width, y: 0.16755*height), control1: CGPoint(x: 0.44906*width, y: 0.08292*height), control2: CGPoint(x: 0.46406*width, y: 0.12342*height)) - path.addCurve(to: CGPoint(x: 0.56563*width, y: 0.34043*height), control1: CGPoint(x: 0.46406*width, y: 0.26303*height), control2: CGPoint(x: 0.50953*width, y: 0.34043*height)) - path.addLine(to: CGPoint(x: 0.9*width, y: 0.34043*height)) - path.addCurve(to: CGPoint(x: 0.99687*width, y: 0.50532*height), control1: CGPoint(x: 0.9535*width, y: 0.34043*height), control2: CGPoint(x: 0.99687*width, y: 0.41425*height)) - path.addLine(to: CGPoint(x: 0.99687*width, y: 0.82979*height)) - path.addCurve(to: CGPoint(x: 0.9*width, y: 0.99468*height), control1: CGPoint(x: 0.99687*width, y: 0.92086*height), control2: CGPoint(x: 0.9535*width, y: 0.99468*height)) - path.addLine(to: CGPoint(x: 0.1*width, y: 0.99468*height)) - path.addCurve(to: CGPoint(x: 0.00313*width, y: 0.82979*height), control1: CGPoint(x: 0.0465*width, y: 0.99468*height), control2: CGPoint(x: 0.00313*width, y: 0.92086*height)) - path.addLine(to: CGPoint(x: 0.00313*width, y: 0.17021*height)) - path.addCurve(to: CGPoint(x: 0.1*width, y: 0.00532*height), control1: CGPoint(x: 0.00313*width, y: 0.07914*height), control2: CGPoint(x: 0.0465*width, y: 0.00532*height)) - path.closeSubpath() - return path - } -} - -struct FolderShapeButton: View { - let library: LibraryModel - let onTap: () -> Void - - private func computedOffset(for index: Int) -> CGSize { - let xOffsets: [CGFloat] = [15, -20, 20] // right, left, right - let yOffsets: [CGFloat] = [-10, 0, 18] // above, middle, bottom - - guard index < xOffsets.count else { return .zero } - return CGSize(width: xOffsets[index], height: yOffsets[index]) - } - private func computedRotation(for index: Int) -> Angle { - let rotations: [Double] = [10, -20, 10] // manually tweakable rotation per image - - guard index < rotations.count else { return .degrees(0) } - return .degrees(rotations[index]) - } - - var body: some View { - Button( - action: { - onTap() - }, - label: { - ZStack(alignment: .bottom) { - // Background - RoundedRectangle(cornerRadius: 20) - .fill(Color("folderBackground")) - .frame(width: 160, height: 160) - - // Stacked preview images - ZStack { - ForEach(Array(library.imageURL.prefix(3).enumerated()), id: \.offset) { index, imageName in - Image(imageName) - .resizable() - .scaledToFill() - .frame(width: 90, height: 100) - .cornerRadius(12) - .rotationEffect(computedRotation(for: index)) - .offset(computedOffset(for: index)) - } - } - .offset(y: -30) - - // Bottom pocket overlay - FolderTabShape() - .glassEffect( - .clear - .tint(Color("folderBackground").opacity(0.6)), - in: FolderTabShape() - ) - .frame(width: 160, height: 88) - .overlay( - VStack(alignment: .leading, spacing: 4) { - Text(library.name) - .font(.default) - .fontWeight(.medium) - .lineLimit(1) - .foregroundColor(.white) - HStack(spacing: 4) { - Text("Week \(library.week)") - .font(.caption) - .foregroundColor(.white) - Spacer() - Image(systemName: "play.square.stack") - .foregroundColor(.white) - .font(.caption2) - Text("\(library.clipCount)") - .font(.caption) - .foregroundColor(.white) - } - } - .padding(.horizontal, 12) - .padding(.bottom, 10) - .frame(width: 160), - alignment: .bottomLeading - ) - } - } - ) - .buttonStyle(.plain) - } -} - -#Preview { - FolderShapeButton( - library: LibraryModel( - imageURL: ["librarySample1", "librarySample2", "librarySample3"], - id: UUID().uuidString, - name: "Library One", - week: 3, - clipCount: 4 - ), onTap: {} - ) -} diff --git a/Tiny/Components/BokehEffectView.swift b/Tiny/Core/Components/BokehEffectView.swift similarity index 100% rename from Tiny/Components/BokehEffectView.swift rename to Tiny/Core/Components/BokehEffectView.swift diff --git a/Tiny/Components/CoachMarkView.swift b/Tiny/Core/Components/CoachMarkView.swift similarity index 100% rename from Tiny/Components/CoachMarkView.swift rename to Tiny/Core/Components/CoachMarkView.swift diff --git a/Tiny/Components/CountdownTextView.swift b/Tiny/Core/Components/CountdownTextView.swift similarity index 100% rename from Tiny/Components/CountdownTextView.swift rename to Tiny/Core/Components/CountdownTextView.swift diff --git a/Tiny/Orb/OrbConfiguration.swift b/Tiny/Core/Components/Orb/Models/OrbConfiguration.swift similarity index 80% rename from Tiny/Orb/OrbConfiguration.swift rename to Tiny/Core/Components/Orb/Models/OrbConfiguration.swift index 1fc3cd6..5f02c96 100644 --- a/Tiny/Orb/OrbConfiguration.swift +++ b/Tiny/Core/Components/Orb/Models/OrbConfiguration.swift @@ -9,6 +9,8 @@ // // Modifications made by Destu Cikal Ramdani on 2025-11-14. // +// Modifications made by Benedictus Yogatama Favian Satyajati on 21/11/25. +// import SwiftUI @@ -75,3 +77,20 @@ public struct OrbConfiguration { ) } } + +extension OrbConfiguration { + init(style: OrbStyles) { + self.init( + backgroundColors: style.backgorundColors, + glowColor: style.glowColor, + particleColor: style.particleColor, + coreGlowIntensity: 0.1, + showBackground: true, + showWavyBlobs: true, + showParticles: false, + showGlowEffects: false, + showShadow: false, + speed: 30 + ) + } +} diff --git a/Tiny/Core/Components/Orb/Models/OrbStyles.swift b/Tiny/Core/Components/Orb/Models/OrbStyles.swift new file mode 100644 index 0000000..043548a --- /dev/null +++ b/Tiny/Core/Components/Orb/Models/OrbStyles.swift @@ -0,0 +1,51 @@ +// +// OrbStyles.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 21/11/25. +// + +import SwiftUI + +enum OrbStyles: String, CaseIterable, Identifiable { + case ocean = "Ocean" + case defaultStyle = "Default" + case forest = "Forest" + + var id: String { rawValue } + var displayName: String { rawValue } + + var backgorundColors: [Color] { + switch self { + case .ocean: + return [.blue, .cyan, .clear] + case .forest: + return [.green, .mint, .clear] + case .defaultStyle: + return [.orange, .orbOrange, .clear] + } + } + + var glowColor: Color { + switch self { + case .ocean: + return .cyan.opacity(1) + case .forest: + return .green.opacity(1) + case .defaultStyle: + return .orbOrange.opacity(1) + } + } + + var particleColor: Color { + switch self { + case .ocean: + return .cyan + case .forest: + return .green + case .defaultStyle: + return .white + } + } + +} diff --git a/Tiny/Orb/OrbPhysicsController.swift b/Tiny/Core/Components/Orb/ViewModels/OrbPhysicsController.swift similarity index 100% rename from Tiny/Orb/OrbPhysicsController.swift rename to Tiny/Core/Components/Orb/ViewModels/OrbPhysicsController.swift diff --git a/Tiny/Orb/AnimatedOrbView.swift b/Tiny/Core/Components/Orb/Views/AnimatedOrbView.swift similarity index 76% rename from Tiny/Orb/AnimatedOrbView.swift rename to Tiny/Core/Components/Orb/Views/AnimatedOrbView.swift index a4c26fe..6883520 100644 --- a/Tiny/Orb/AnimatedOrbView.swift +++ b/Tiny/Core/Components/Orb/Views/AnimatedOrbView.swift @@ -15,18 +15,11 @@ import SpriteKit struct AnimatedOrbView: View { @StateObject private var physicsController = OrbPhysicsController() var size: CGFloat = 200 - - private let configuration = OrbConfiguration( - backgroundColors: [.orange, .orbOrange, .clear], - glowColor: .white.opacity(0.1), - coreGlowIntensity: 0.1, - showBackground: true, - showWavyBlobs: true, - showParticles: false, - showGlowEffects: false, - showShadow: false, - speed: 30 - ) + var style: OrbStyles = .defaultStyle + + private var configuration: OrbConfiguration { + OrbConfiguration(style: style) + } var body: some View { ZStack { @@ -36,9 +29,9 @@ struct AnimatedOrbView: View { .stroke( AngularGradient( colors: [ - .orbOrange.opacity(0.8), - .orbOrange.opacity(2), - .orbOrange.opacity(0.6) + style.glowColor.opacity(0.8), + style.glowColor.opacity(2), + style.glowColor.opacity(0.6) ], center: .center, startAngle: .degrees(0), diff --git a/Tiny/Core/Components/Orb/Views/OrbStylePicker.swift b/Tiny/Core/Components/Orb/Views/OrbStylePicker.swift new file mode 100644 index 0000000..7e8bfce --- /dev/null +++ b/Tiny/Core/Components/Orb/Views/OrbStylePicker.swift @@ -0,0 +1,67 @@ +// +// OrbStylePicker.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 21/11/25. +// + +import SwiftUI + +struct OrbStylePicker: View { + @Binding var selectedStyle: OrbStyles + + var body: some View { + VStack(spacing: 40) { + // Large central orb + AnimatedOrbView(size: 200, style: selectedStyle) + .frame(width: 200, height: 200) + + // Horizontal scrollable style names + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 20) { + ForEach(OrbStyles.allCases) { style in + Button( + action: { + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + selectedStyle = style + } + }, + label: { + Text(style.displayName) + .font(.title3) + .fontWeight(selectedStyle == style ? .bold : .medium) + .foregroundColor(selectedStyle == style ? .white : .secondary) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(selectedStyle == style ? style.glowColor.opacity(0.3) : Color.clear) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(selectedStyle == style ? style.glowColor : Color.gray.opacity(0.3), lineWidth: 1) + ) + ) + } + ) + .buttonStyle(.plain) + .scaleEffect(selectedStyle == style ? 1.1 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: selectedStyle == style) + } + } + .padding(.horizontal) + } + .frame(height: 50) + } + } +} + +#Preview { + @Previewable @State var selectedStyle: OrbStyles = .ocean + + VStack { + OrbStylePicker(selectedStyle: $selectedStyle) + Spacer() + } + .padding() + .background(Color.black.ignoresSafeArea()) +} diff --git a/Tiny/Orb/OrbView.swift b/Tiny/Core/Components/Orb/Views/OrbView.swift similarity index 100% rename from Tiny/Orb/OrbView.swift rename to Tiny/Core/Components/Orb/Views/OrbView.swift diff --git a/Tiny/Orb/BackgroundView.swift b/Tiny/Core/Components/Orb/Views/SubViews/BackgroundView.swift similarity index 100% rename from Tiny/Orb/BackgroundView.swift rename to Tiny/Core/Components/Orb/Views/SubViews/BackgroundView.swift diff --git a/Tiny/Orb/Particles.swift b/Tiny/Core/Components/Orb/Views/SubViews/Particles.swift similarity index 100% rename from Tiny/Orb/Particles.swift rename to Tiny/Core/Components/Orb/Views/SubViews/Particles.swift diff --git a/Tiny/Orb/RealisticShadows.swift b/Tiny/Core/Components/Orb/Views/SubViews/RealisticShadows.swift similarity index 100% rename from Tiny/Orb/RealisticShadows.swift rename to Tiny/Core/Components/Orb/Views/SubViews/RealisticShadows.swift diff --git a/Tiny/Orb/WavyBlobView.swift b/Tiny/Core/Components/Orb/Views/SubViews/WavyBlobView.swift similarity index 100% rename from Tiny/Orb/WavyBlobView.swift rename to Tiny/Core/Components/Orb/Views/SubViews/WavyBlobView.swift diff --git a/Tiny/LiveListen/Views/ShareSheetView.swift b/Tiny/Core/Components/ShareSheetView.swift similarity index 100% rename from Tiny/LiveListen/Views/ShareSheetView.swift rename to Tiny/Core/Components/ShareSheetView.swift diff --git a/Tiny/Core/Components/SplashScreenView.swift b/Tiny/Core/Components/SplashScreenView.swift new file mode 100644 index 0000000..781c59b --- /dev/null +++ b/Tiny/Core/Components/SplashScreenView.swift @@ -0,0 +1,43 @@ +// +// SplashScreen.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 24/11/25. +// + +import SwiftUI + +struct SplashScreenView: View { + @Binding var isShowingSplashScreen: Bool + @State private var animate: Bool = false + + var body: some View { + ZStack { + Image("bgSplashScreen") + .resizable() + .scaledToFill() + + Image("titleSplashScreen") + .resizable() + .scaledToFill() + .frame(width: animate ? 100 :80, height: animate ? 100 : 80) // Animate size + .opacity(animate ? 1 : 0.5) // Animate opacity + } + .ignoresSafeArea() + .onAppear { + withAnimation(.easeOut(duration: 0.5)) { + animate = true + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + withAnimation { + isShowingSplashScreen = false + } + } + } + } +} + +#Preview { + SplashScreenView(isShowingSplashScreen: .constant(true)) + .preferredColorScheme(.dark) +} diff --git a/Tiny/Common/Extension/Color+ext.swift b/Tiny/Core/Extension/Color+ext.swift similarity index 100% rename from Tiny/Common/Extension/Color+ext.swift rename to Tiny/Core/Extension/Color+ext.swift diff --git a/Tiny/Core/Models/SavedHeartbeatModel.swift b/Tiny/Core/Models/SavedHeartbeatModel.swift new file mode 100644 index 0000000..3612aea --- /dev/null +++ b/Tiny/Core/Models/SavedHeartbeatModel.swift @@ -0,0 +1,22 @@ +// +// SavedHeartbeatModel.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 20/11/25. +// + +import SwiftData +import Foundation + +@Model +class SavedHeartbeat { + @Attribute(.unique) var id: UUID + var filePath: String + var timestamp: Date + + init(filePath: String, timestamp: Date = Date()) { + self.id = UUID() + self.filePath = filePath + self.timestamp = timestamp + } +} diff --git a/Tiny/Managers/AudioFilterChainBuilder.swift b/Tiny/Core/Services/Audio/AudioFilterChainBuilder.swift similarity index 100% rename from Tiny/Managers/AudioFilterChainBuilder.swift rename to Tiny/Core/Services/Audio/AudioFilterChainBuilder.swift diff --git a/Tiny/Managers/AudioPostProcessingManager.swift b/Tiny/Core/Services/Audio/AudioPostProcessingManager.swift similarity index 79% rename from Tiny/Managers/AudioPostProcessingManager.swift rename to Tiny/Core/Services/Audio/AudioPostProcessingManager.swift index 3671c65..bfa5aa7 100644 --- a/Tiny/Managers/AudioPostProcessingManager.swift +++ b/Tiny/Core/Services/Audio/AudioPostProcessingManager.swift @@ -9,6 +9,7 @@ import Foundation import AVFoundation import AudioKit import AudioKitEX +import CoreHaptics internal import Combine class AudioPostProcessingManager: ObservableObject { @@ -17,13 +18,17 @@ class AudioPostProcessingManager: ObservableObject { private var parametricEQ: ParametricEQ? private var highShelfFilter: HighShelfFilter? private var timer: Timer? - + private var hapticManager: HapticManager? + private var amplitudeTap: AmplitudeTap? + @Published var isPlaying = false @Published var currentTime: TimeInterval = 0 @Published var duration: TimeInterval = 0 - + @Published var amplitude: Float = 0.0 + init() { engine = AudioEngine() + hapticManager = HapticManager() } func setupEQChain(input: Node) -> Node { @@ -56,17 +61,23 @@ class AudioPostProcessingManager: ObservableObject { print("Failed to create audio player") return } - - let processedOutput = setupEQChain(input: player) - - engine.output = processedOutput if let audioFile = try? AVAudioFile(forReading: fileURL) { - let sampleRate = audioFile.processingFormat.sampleRate + let sampleRate = Float(audioFile.processingFormat.sampleRate) let frameCount = Double(audioFile.length) - duration = frameCount / sampleRate + duration = frameCount / Double(sampleRate) + print("▶️ File loaded: \(sampleRate) Hz | Duration: \(String(format: "%.1f", duration))s") + } + + let processedOutput = setupEQChain(input: player) + + // Attach Amplitude tap for haptic analysis + amplitudeTap = AmplitudeTap(processedOutput) { [weak self] amplitude in + self?.processAmplitude(amplitude: amplitude) } + engine.output = processedOutput + player.completionHandler = { [weak self] in DispatchQueue.main.async { self?.isPlaying = false @@ -74,7 +85,9 @@ class AudioPostProcessingManager: ObservableObject { } } + hapticManager?.prepareHaptics() try engine.start() + amplitudeTap?.start() player.play() isPlaying = true @@ -89,7 +102,15 @@ class AudioPostProcessingManager: ObservableObject { print("❌ Error loading audio: \(error.localizedDescription)") } } - + + // Process amplitude data for haptic feedback + private func processAmplitude(amplitude: Float) { + DispatchQueue.main.async { + self.amplitude = amplitude + self.hapticManager?.playHapticFromAmplitude(amplitude) + } + } + private func startTimeTracking() { // Stop any existing timer first timer?.invalidate() @@ -115,6 +136,7 @@ class AudioPostProcessingManager: ObservableObject { // Stop time tracking when paused timer?.invalidate() timer = nil + hapticManager?.stopHaptics() } func resume() { @@ -122,7 +144,9 @@ class AudioPostProcessingManager: ObservableObject { print("❌ No player available to resume") return } - + + hapticManager?.prepareHaptics() + // Don't try to restart engine if it's already running if !engine.avEngine.isRunning { do { @@ -150,8 +174,14 @@ class AudioPostProcessingManager: ObservableObject { // Stop time tracking timer?.invalidate() timer = nil - + + amplitudeTap?.stop() + amplitudeTap = nil engine.stop() + hapticManager?.stopHaptics() + + // Reset haptic state + hapticManager?.reset() } func seek(to time: TimeInterval) { diff --git a/Tiny/Core/Services/Audio/HapticManager.swift b/Tiny/Core/Services/Audio/HapticManager.swift new file mode 100644 index 0000000..897f8a2 --- /dev/null +++ b/Tiny/Core/Services/Audio/HapticManager.swift @@ -0,0 +1,101 @@ +import CoreHaptics +import os + +class HapticManager { + private var engine: CHHapticEngine? + private var beatPattern: CHHapticPattern? + private var beatPlayer: CHHapticPatternPlayer? + + private let logger = Logger(subsystem: "com.example.tiny", category: "HapticManager") + + // Haptic properties + private var lastHapticTime: Date? + private let hapticDebounceInterval: TimeInterval = 0.4 + private let amplitudeThresholdLower: Float = 0.08 // Triggers for sounds above this + private let amplitudeThresholdUpper: Float = 0.2 // Does not trigger for sounds above this (too loud noise) + + init() { + prepareHaptics() + } + + func prepareHaptics() { + guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { + logger.info("Haptics not supported on this device.") + return + } + + do { + if engine == nil { + engine = try CHHapticEngine() + logger.info("Haptic engine created.") + + engine?.stoppedHandler = { [weak self] reason in + self?.logger.info("Haptic engine stopped for reason: \(reason.rawValue)") + self?.beatPlayer = nil + } + + engine?.resetHandler = { [weak self] in + self?.logger.info("Haptic engine reset.") + self?.beatPlayer = nil + self?.prepareHaptics() + } + } + try engine?.start() + logger.info("Haptic engine started successfully.") + + // Create the reusable pattern + let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0) + let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.8) + let event = CHHapticEvent(eventType: .hapticTransient, parameters: [intensity, sharpness], relativeTime: 0) + beatPattern = try CHHapticPattern(events: [event], parameters: []) + + } catch { + logger.error("Error setting up haptic engine or pattern: \(error.localizedDescription)") + } + } + + func playHapticFromAmplitude(_ amplitude: Float) { + let now = Date() + var shouldTriggerHaptic = false + + if let lastTime = self.lastHapticTime { + if now.timeIntervalSince(lastTime) >= self.hapticDebounceInterval { + shouldTriggerHaptic = true + } + } else { + shouldTriggerHaptic = true + } + + if shouldTriggerHaptic && amplitude > self.amplitudeThresholdLower && amplitude < self.amplitudeThresholdUpper { + self.playBeatHaptic() + self.lastHapticTime = now + } + } + + private func playBeatHaptic() { + guard let engine = engine, let pattern = beatPattern else { + logger.warning("Haptic engine or pattern not available.") + return + } + + do { + if beatPlayer == nil { + beatPlayer = try engine.makePlayer(with: pattern) + logger.info("Haptic player created.") + } + try beatPlayer?.start(atTime: 0) + } catch { + logger.error("Failed to play haptic beat: \(error.localizedDescription)") + } + } + + func stopHaptics() { + engine?.stop() + beatPlayer = nil + logger.info("Haptic engine stopped.") + } + + func reset() { + lastHapticTime = nil + } +} diff --git a/Tiny/Managers/HeartbeatSoundManager.swift b/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift similarity index 91% rename from Tiny/Managers/HeartbeatSoundManager.swift rename to Tiny/Core/Services/Audio/HeartbeatSoundManager.swift index ad9a26a..79d23d2 100644 --- a/Tiny/Managers/HeartbeatSoundManager.swift +++ b/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift @@ -14,9 +14,14 @@ import AudioKitEX internal import Combine +import SwiftData +import UIKit +import SwiftUI + struct Recording: Identifiable, Equatable { let id = UUID() let fileURL: URL + let createdAt: Date var isPlaying: Bool = false } @@ -36,7 +41,10 @@ enum HeartbeatFilterMode { } // swiftlint:disable type_body_length +@MainActor class HeartbeatSoundManager: NSObject, ObservableObject { + var modelContext: ModelContext? + var engine: AudioEngine! var mic: AudioEngine.InputNode? var gain: Fader? @@ -58,7 +66,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { @Published var isRunning = false @Published var isRecording = false @Published var lastRecording: Recording? - @Published var savedRecordings: [Recording] = [] // new saved recordings + @Published var savedRecordings: [Recording] = [] @Published var isPlayingPlayback = false @Published var amplitudeVal: Float = 0.0 @Published var blinkAmplitude: Float = 0.0 @@ -90,7 +98,32 @@ class HeartbeatSoundManager: NSObject, ObservableObject { } } } - + + func loadFromSwiftData() { + guard let modelContext = modelContext else { return } + do { + let results = try modelContext.fetch(FetchDescriptor()) + let documentsURL = getDocumentsDirectory() + + DispatchQueue.main.async { + // ✅ Map the REAL timestamp from SwiftData + self.savedRecordings = results.map { savedItem in + let fileName = URL(fileURLWithPath: savedItem.filePath).lastPathComponent + + let currentURL = documentsURL.appendingPathComponent(fileName) + + return Recording( + fileURL: currentURL, + createdAt: savedItem.timestamp + ) + } + } + print("✅ Loaded \(self.savedRecordings.count) recordings from SwiftData") + } catch { + print("SwiftData load error: \(error)") + } + } + func setupAudio() { do { cleanupAudio() @@ -439,25 +472,23 @@ class HeartbeatSoundManager: NSObject, ObservableObject { guard let recorder = recorder, recorder.isRecording else { return } recorder.stop() isRecording = false - + if let audioFile = recorder.audioFile { let fileManager = FileManager.default - let documentsURL = getDocumentsDirectory() - let outputURL = documentsURL.appendingPathComponent("recording-\(Date().timeIntervalSince1970).caf") - + let outputURL = getDocumentsDirectory().appendingPathComponent("recording-\(Date().timeIntervalSince1970).caf") + do { if fileManager.fileExists(atPath: outputURL.path) { try fileManager.removeItem(at: outputURL) } try fileManager.moveItem(at: audioFile.url, to: outputURL) DispatchQueue.main.async { - self.lastRecording = Recording(fileURL: outputURL) + self.lastRecording = Recording(fileURL: outputURL, createdAt: Date()) } - print("Recording saved to \(outputURL)") } catch { - print("Error saving recording: \(error.localizedDescription)") + print(error) } - } + } } func togglePlayback(recording: Recording) { @@ -549,38 +580,21 @@ class HeartbeatSoundManager: NSObject, ObservableObject { } func saveRecording() { - guard let recording = lastRecording else { - print("No recording to save") - return - } + guard let recording = lastRecording else { return } + self.savedRecordings.append(recording) - let fileManager = FileManager.default - let documentsURL = getDocumentsDirectory() - - // Create a permanent filename (without timestamp prefix) - let permanentURL = documentsURL.appendingPathComponent("saved-heartbeat-\(Date().timeIntervalSince1970).caf") + guard let modelContext = modelContext else { return } + + let entry = SavedHeartbeat( + filePath: recording.fileURL.path, + timestamp: recording.createdAt + ) + modelContext.insert(entry) do { - // Check if file already exists at permanent location - if fileManager.fileExists(atPath: permanentURL.path) { - try fileManager.removeItem(at: permanentURL) - } - - // Copy the recording to permanent location - try fileManager.copyItem(at: recording.fileURL, to: permanentURL) - - print("Recording permanently saved to \(permanentURL)") - - let newRecording = Recording(fileURL: permanentURL) - - // Update lastRecording AND append to the timeline list - DispatchQueue.main.async { - self.lastRecording = newRecording - self.savedRecordings.append(newRecording) - } - + try modelContext.save() } catch { - print("Error saving recording permanently: \(error.localizedDescription)") + print("SwiftData save failed: \(error)") } } } diff --git a/Tiny/Managers/BluetoothManager.swift b/Tiny/Core/Services/Bluetooth/BluetoothManager.swift similarity index 100% rename from Tiny/Managers/BluetoothManager.swift rename to Tiny/Core/Services/Bluetooth/BluetoothManager.swift diff --git a/Tiny/Core/Services/Heartbeat/HeartbeatDetector.swift b/Tiny/Core/Services/Heartbeat/HeartbeatDetector.swift new file mode 100644 index 0000000..9c31455 --- /dev/null +++ b/Tiny/Core/Services/Heartbeat/HeartbeatDetector.swift @@ -0,0 +1,277 @@ +// +// HeartbeatDetector.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 30/10/25. +// + +import Foundation +import Accelerate + +struct HeartbeatStats { + let avgBPM: Double + let confidence: Float + let beatCount: Int +} + +class HeartbeatDetector { + private var heartbeatData: [HeartbeatData] = [] + private var recentPeaks: [Date] = [] + private var sampleRate: Float + private var fftSize: Int = 0 + + // Configuration for different use cases + enum DetectionMode { + case live // Real-time microphone input + case playback // Recorded audio playback + } + + private let mode: DetectionMode + + // Configurable parameters based on mode + private var confidenceThreshold: Float + private var peakThreshold: Float + + private let s1FreqRange: ClosedRange = 20.0...150.0 // Hz + private let s2FreqRange: ClosedRange = 80.0...200.0 // Hz + private let minTimeBetweenBeats: TimeInterval = 0.3 // 200 BPM max + private let maxTimeBetweenBeats: TimeInterval = 2.0 // 30 BPM min + + // Peak detection state + private var lastPeakTime: Date? + private var peakBuffer: [Float] = [] + private let peakBufferSize = 5 + + // Initializer with configurable parameters + init(sampleRate: Float = 44100.0, mode: DetectionMode = .live) { + self.sampleRate = sampleRate + self.mode = mode + + // Adjust thresholds based on mode + switch mode { + case .live: + // More sensitive for live detection (microphone may have more noise) + self.confidenceThreshold = 0.40 + self.peakThreshold = 0.18 + case .playback: + // More precise for playback (cleaner signal) + self.confidenceThreshold = 0.25 + self.peakThreshold = 0.10 + } + } + + func detectHeartbeat(from fftData: [Float]) -> HeartbeatData? { + guard !fftData.isEmpty else { return nil } + + // Initialize FFT size on first run + if fftSize == 0 { + fftSize = fftData.count + } + + // Convert frequency ranges to FFT bin ranges + let s1BinRange = frequencyToBinRange(s1FreqRange, fftSize: fftSize, sampleRate: sampleRate) + let s2BinRange = frequencyToBinRange(s2FreqRange, fftSize: fftSize, sampleRate: sampleRate) + + // Calculate amplitudes with proper normalization + let s1Amplitude = calculatePeakAmplitude(in: s1BinRange, from: fftData) + let s2Amplitude = calculatePeakAmplitude(in: s2BinRange, from: fftData) + + // Calculate total energy in heartbeat frequency range + let totalEnergy = calculateAverageAmplitude(in: s1BinRange.lowerBound...s2BinRange.upperBound, from: fftData) + + // Detect peaks in the signal + let isPeak = detectPeak(amplitude: s1Amplitude) + + // Calculate confidence based on multiple factors + let confidence = calculateEnhancedConfidence( + s1Amplitude: s1Amplitude, + s2Amplitude: s2Amplitude, + totalEnergy: totalEnergy, + isPeak: isPeak + ) + + // Only register heartbeat if confidence is high enough and it's a peak + if confidence > confidenceThreshold && isPeak { + let now = Date() + + // Check timing constraint to avoid double-counting + if let lastPeak = lastPeakTime { + let interval = now.timeIntervalSince(lastPeak) + if interval < minTimeBetweenBeats { + return nil // Too soon, likely noise or double detection + } + } + + lastPeakTime = now + recentPeaks.append(now) + + // Keep only recent peaks for BPM calculation + let cutoffTime = now.addingTimeInterval(-10.0) + recentPeaks.removeAll { $0 < cutoffTime } + + let bpm = estimateBPM() + + let heartbeat = HeartbeatData( + timestamp: now, + bpm: bpm, + s1Amplitude: s1Amplitude, + s2Amplitude: s2Amplitude, + confidence: confidence + ) + + heartbeatData.append(heartbeat) + if heartbeatData.count > 20 { + heartbeatData.removeFirst() + } + + return heartbeat + } + + return nil + } + + // Convert frequency range to FFT bin range + private func frequencyToBinRange(_ freqRange: ClosedRange, fftSize: Int, sampleRate: Float) -> ClosedRange { + let binWidth = sampleRate / Float(fftSize * 2) + let lowerBin = Int(freqRange.lowerBound / binWidth) + let upperBin = Int(freqRange.upperBound / binWidth) + return max(0, lowerBin)...min(fftSize - 1, upperBin) + } + + // Calculate peak amplitude (max value) in range + private func calculatePeakAmplitude(in range: ClosedRange, from fftData: [Float]) -> Float { + guard fftData.count > range.upperBound else { return 0.0 } + + let startIndex = max(0, range.lowerBound) + let endIndex = min(fftData.count, range.upperBound + 1) + + guard startIndex < endIndex else { return 0.0 } + + let slice = fftData[startIndex.., from fftData: [Float]) -> Float { + guard fftData.count > range.upperBound else { return 0.0 } + + let startIndex = max(0, range.lowerBound) + let endIndex = min(fftData.count, range.upperBound + 1) + + guard startIndex < endIndex else { return 0.0 } + + let slice = fftData[startIndex.. Bool { + peakBuffer.append(amplitude) + if peakBuffer.count > peakBufferSize { + peakBuffer.removeFirst() + } + + guard peakBuffer.count == peakBufferSize else { return false } + + // Check if middle value is a local maximum + let middleIndex = peakBufferSize / 2 + let middleValue = peakBuffer[middleIndex] + + // Must exceed threshold + guard middleValue > peakThreshold else { return false } + + // Must be higher than neighbors + for (index, value) in peakBuffer.enumerated() { + if index != middleIndex && value >= middleValue { + return false + } + } + + return true + } + + // Enhanced confidence calculation + private func calculateEnhancedConfidence( + s1Amplitude: Float, + s2Amplitude: Float, + totalEnergy: Float, + isPeak: Bool + ) -> Float { + // 1. Amplitude strength (0.0 - 1.0) + let amplitudeStrength = min(1.0, s1Amplitude * 5.0) + + // 2. S1/S2 ratio score (S1 should be stronger than S2) + let ratio = s2Amplitude / max(s1Amplitude, 0.001) + let idealRatio: Float = 0.5 // S2 is typically 50% of S1 + let ratioScore = max(0.0, 1.0 - abs(ratio - idealRatio) * 2.0) + + // 3. Energy concentration (heartbeat energy should be concentrated in expected range) + let energyScore = min(1.0, totalEnergy * 3.0) + + // 4. Peak bonus + let peakBonus: Float = isPeak ? 0.2 : 0.0 + + // Weighted combination + let confidence = ( + amplitudeStrength * 0.35 + + ratioScore * 0.25 + + energyScore * 0.20 + + peakBonus + ) + + return min(1.0, confidence) + } + + // Improved BPM estimation using inter-beat intervals + private func estimateBPM() -> Double { + guard recentPeaks.count >= 2 else { return 0.0 } + + // Calculate intervals between consecutive peaks + var intervals: [TimeInterval] = [] + for index in 1..= minTimeBetweenBeats && interval <= maxTimeBetweenBeats { + intervals.append(interval) + } + } + + guard !intervals.isEmpty else { return 0.0 } + + // Use median instead of mean for better outlier resistance + let sortedIntervals = intervals.sorted() + let medianInterval: TimeInterval + + if sortedIntervals.count % 2 == 0 { + let mid = sortedIntervals.count / 2 + medianInterval = (sortedIntervals[mid - 1] + sortedIntervals[mid]) / 2.0 + } else { + medianInterval = sortedIntervals[sortedIntervals.count / 2] + } + + // Convert interval to BPM + let bpm = 60.0 / medianInterval + + // Clamp to realistic range + return max(30.0, min(200.0, bpm)) + } + + // Get recent heartbeat statistics + func getHeartbeatStats() -> HeartbeatStats { + guard !heartbeatData.isEmpty else { return HeartbeatStats(avgBPM: 0.0, confidence: 0.0, beatCount: 0) } + + let recentData = Array(heartbeatData.suffix(10)) + let avgBPM = recentData.map { $0.bpm }.reduce(0, +) / Double(recentData.count) + let avgConfidence = recentData.map { $0.confidence }.reduce(0, +) / Float(recentData.count) + + return HeartbeatStats(avgBPM: avgBPM, confidence: avgConfidence, beatCount: recentPeaks.count) + } + + // Reset detector state + func reset() { + heartbeatData.removeAll() + recentPeaks.removeAll() + lastPeakTime = nil + peakBuffer.removeAll() + } +} diff --git a/Tiny/Views/AudioPostProcessingTestView.swift b/Tiny/Development/AudioPostProcessingTestView.swift similarity index 100% rename from Tiny/Views/AudioPostProcessingTestView.swift rename to Tiny/Development/AudioPostProcessingTestView.swift diff --git a/Tiny/Components/AudioVisualizationView.swift b/Tiny/Development/AudioVisualizationView.swift similarity index 100% rename from Tiny/Components/AudioVisualizationView.swift rename to Tiny/Development/AudioVisualizationView.swift diff --git a/Tiny/Views/EnhancedLiveListenView.swift b/Tiny/Development/EnhancedLiveListenView.swift similarity index 100% rename from Tiny/Views/EnhancedLiveListenView.swift rename to Tiny/Development/EnhancedLiveListenView.swift diff --git a/Tiny/Views/HeartbeatAnalysisTab.swift b/Tiny/Development/HeartbeatAnalysisTab.swift similarity index 100% rename from Tiny/Views/HeartbeatAnalysisTab.swift rename to Tiny/Development/HeartbeatAnalysisTab.swift diff --git a/Tiny/Components/HeartbeatAnalysisView.swift b/Tiny/Development/HeartbeatAnalysisView.swift similarity index 100% rename from Tiny/Components/HeartbeatAnalysisView.swift rename to Tiny/Development/HeartbeatAnalysisView.swift diff --git a/Tiny/Components/HeartbeatDataAnalysisView.swift b/Tiny/Development/HeartbeatDataAnalysisView.swift similarity index 100% rename from Tiny/Components/HeartbeatDataAnalysisView.swift rename to Tiny/Development/HeartbeatDataAnalysisView.swift diff --git a/Tiny/Components/Countdown.swift b/Tiny/Development/Legacy/Countdown.swift similarity index 100% rename from Tiny/Components/Countdown.swift rename to Tiny/Development/Legacy/Countdown.swift diff --git a/Tiny/Components/CustomAlertStop.swift b/Tiny/Development/Legacy/CustomAlertStop.swift similarity index 100% rename from Tiny/Components/CustomAlertStop.swift rename to Tiny/Development/Legacy/CustomAlertStop.swift diff --git a/Tiny/Components/FeatureCard.swift b/Tiny/Development/Legacy/FeatureCard.swift similarity index 100% rename from Tiny/Components/FeatureCard.swift rename to Tiny/Development/Legacy/FeatureCard.swift diff --git a/Tiny/Components/Gradients.swift b/Tiny/Development/Legacy/Gradients.swift similarity index 100% rename from Tiny/Components/Gradients.swift rename to Tiny/Development/Legacy/Gradients.swift diff --git a/Tiny/Components/PauseListeningButton.swift b/Tiny/Development/Legacy/PauseListeningButton.swift similarity index 100% rename from Tiny/Components/PauseListeningButton.swift rename to Tiny/Development/Legacy/PauseListeningButton.swift diff --git a/Tiny/Components/PregnancyAgeView.swift b/Tiny/Development/Legacy/PregnancyAgeView.swift similarity index 100% rename from Tiny/Components/PregnancyAgeView.swift rename to Tiny/Development/Legacy/PregnancyAgeView.swift diff --git a/Tiny/Development/Legacy/SaveStatusBanner.swift b/Tiny/Development/Legacy/SaveStatusBanner.swift new file mode 100644 index 0000000..4cb24ee --- /dev/null +++ b/Tiny/Development/Legacy/SaveStatusBanner.swift @@ -0,0 +1,61 @@ +// +// SaveStatusBanner.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 21/11/25. +// + +import SwiftUI + +struct SaveStatusBanner: View { + let statusIcon: String + let headerStatus: String + let message: String + + var body: some View { + HStack(spacing: 10) { + Image(systemName: statusIcon) + .resizable() + .frame(width: 28, height: 28) + .fontWeight(.bold) + .padding(5) + VStack(alignment: .leading) { + Text(headerStatus) + .font(.headline) + .fontWeight(.semibold) + Text(message) + .font(.callout) + } + } + .clipShape(Rectangle()) + .foregroundStyle(Color(.white)) + .frame(maxWidth: .infinity) + .padding(.vertical, 13) + .padding(.horizontal, 14) + .cornerRadius(24) + .glassEffect(.clear, in: .rect(cornerRadius: 24.0)) + } +} + +extension View { + func topBanner(isPresented: Binding, statusIcon: String, headerStatus: String, message: String) -> some View { + self.overlay( + VStack { + if isPresented.wrappedValue { + SaveStatusBanner(statusIcon: statusIcon, headerStatus: headerStatus, message: message) + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(1) + } + Spacer() + } + .animation(.spring(), value: isPresented.wrappedValue) + ) + } +} + +#Preview { + ZStack { + Color.black.edgesIgnoringSafeArea(.all) + SaveStatusBanner(statusIcon: "checkmark.circle", headerStatus: "Saved!", message: "Your recording is saved on timeline.") + } +} diff --git a/Tiny/Components/SelectLiveListenButton.swift b/Tiny/Development/Legacy/SelectLiveListenButton.swift similarity index 100% rename from Tiny/Components/SelectLiveListenButton.swift rename to Tiny/Development/Legacy/SelectLiveListenButton.swift diff --git a/Tiny/Components/StartListeningButton.swift b/Tiny/Development/Legacy/StartListeningButton.swift similarity index 100% rename from Tiny/Components/StartListeningButton.swift rename to Tiny/Development/Legacy/StartListeningButton.swift diff --git a/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift b/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift new file mode 100644 index 0000000..f8a73c3 --- /dev/null +++ b/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift @@ -0,0 +1,27 @@ +// +// HeartbeatMainViewModel.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 25/11/25. +// +import Foundation +import SwiftUI +import SwiftData +internal import Combine + +class HeartbeatMainViewModel: ObservableObject { + @Published var showTimeline = false + let heartbeatSoundManager = HeartbeatSoundManager() + + func setupManager(modelContext: ModelContext) { + heartbeatSoundManager.modelContext = modelContext + heartbeatSoundManager.loadFromSwiftData() + } + + func handleRecordingSelection(_ recording: Recording) { + heartbeatSoundManager.lastRecording = recording + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + showTimeline = false + } + } +} diff --git a/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift b/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift new file mode 100644 index 0000000..ad80ee4 --- /dev/null +++ b/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift @@ -0,0 +1,208 @@ +// +// OrbLiveListenViewModel.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 25/11/25. +// +import Foundation +internal import Combine +import SwiftUI + +class OrbLiveListenViewModel: ObservableObject { + @Published var isListening = false + @Published var animateOrb = false + @Published var showShareSheet = false + @Published var isPlaybackMode = false + + @Published var isLongPressing = false + @Published var longPressCountdown = 3 + @Published var longPressScale: CGFloat = 1.0 + @Published var dragOffset: CGFloat = 0 + @Published var isDraggingToSave = false + @Published var saveButtonScale: CGFloat = 1.0 + @Published var orbDragScale: CGFloat = 1.0 + @Published var canSaveCurrentRecording = false + + private var longPressTimer: Timer? + + let audioPostProcessingManager = AudioPostProcessingManager() + let physicsController = OrbPhysicsController() + + var orbScaleEffect: CGFloat { + if isListening { + return isLongPressing ? (animateOrb ? 1.6 : 1.1) * longPressScale : (animateOrb ? 1.5 : 1.0) + } else if isPlaybackMode { + return audioPostProcessingManager.isPlaying ? 1.3 : 0.8 + } + return 1.0 + } + + func orbOffset(geometry: GeometryProxy) -> CGFloat { + isListening ? geometry.size.height / 2 - 150 : 0 + } + + func handleOnAppear(recording: Recording?) { + guard let recording = recording, !isListening, !isPlaybackMode else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.setupPlayback(for: recording) + } + } + + func setupPlayback(for recording: Recording) { + isPlaybackMode = true + animateOrb = true + audioPostProcessingManager.stop() + audioPostProcessingManager.loadAndPlay(fileURL: recording.fileURL) + } + + func handleDragChange(value: SequenceGesture.Value, geometry: GeometryProxy) { + guard canSaveCurrentRecording else { return } + switch value { + case .second(true, let drag): + isDraggingToSave = true + let translation = max(0, drag?.translation.height ?? 0) + dragOffset = translation + let maxDragDistance = geometry.size.height / 2 + let dragProgress = min(translation / maxDragDistance, 1.0) + withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.7)) { + orbDragScale = 1.0 - (dragProgress * 0.4) + saveButtonScale = 1.0 + (dragProgress * 0.4) + } + default: break + } + } + + func handleDragEnd(value: SequenceGesture.Value, geometry: GeometryProxy, onSave: @escaping () -> Void) { + guard canSaveCurrentRecording else { return } + switch value { + case .second(true, let drag): + let translation = drag?.translation.height ?? 0 + if translation > geometry.size.height / 4 { + handleSaveRecording(onSave: onSave) + } else { + resetDragState() + } + default: resetDragState() + } + } + + func resetDragState() { + withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { + dragOffset = 0 + orbDragScale = 1.0 + saveButtonScale = 1.0 + isDraggingToSave = false + } + } + + func handleSaveRecording(onSave: @escaping () -> Void) { + guard canSaveCurrentRecording else { return } + withAnimation(.interpolatingSpring(mass: 1, stiffness: 200, damping: 15)) { + saveButtonScale = 1.6 + orbDragScale = 0.05 + } + onSave() + canSaveCurrentRecording = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.resetDragState() + } + } + + func handleBackButton() { + audioPostProcessingManager.stop() + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + isPlaybackMode = false + animateOrb = false + isDraggingToSave = false + dragOffset = 0 + } + } + + func handleDoubleTap(onStart: @escaping () -> Void) { + guard !isLongPressing, !isListening, !isPlaybackMode else { return } + withAnimation(.interpolatingSpring(mass: 2, stiffness: 100, damping: 20)) { + animateOrb = true + isListening = true + } + onStart() + } + + func handleSingleTap(lastRecording: Recording?) { + guard isPlaybackMode, !isListening, !isLongPressing, !isDraggingToSave else { return } + guard let lastRecording = lastRecording else { return } + if audioPostProcessingManager.isPlaying { + audioPostProcessingManager.pause() + } else if audioPostProcessingManager.currentTime > 0 { + audioPostProcessingManager.resume() + } else { + audioPostProcessingManager.loadAndPlay(fileURL: lastRecording.fileURL) + } + } + + func handleLongPressChange(pressing: Bool) { + guard isListening else { return } + if pressing { + startLongPressCountdown() + } else { + cancelLongPressCountdown() + } + } + + func startLongPressCountdown() { + isLongPressing = true + longPressCountdown = 3 + longPressScale = 1.0 + + var tickCount = 0 + let totalTicks = 30 + let scaleIncrement = 0.15 / 30 + + longPressTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in + tickCount += 1 + + if tickCount % 10 == 0 { + withAnimation(.easeInOut(duration: 0.2)) { self.longPressCountdown -= 1 } + } + + withAnimation(.linear(duration: 0.1)) { self.longPressScale += scaleIncrement } + + if tickCount >= totalTicks { timer.invalidate() } + } + } + + func cancelLongPressCountdown() { + isLongPressing = false + longPressCountdown = 3 + longPressScale = 1.0 + longPressTimer?.invalidate() + longPressTimer = nil + } + + func handleLongPressComplete(onStop: @escaping () -> Void) { + cancelLongPressCountdown() + + withAnimation(.interpolatingSpring(mass: 2, stiffness: 100, damping: 20)) { + isListening = false + animateOrb = false + isPlaybackMode = true + canSaveCurrentRecording = true + } + + onStop() + } + + func handleSelectRecordingFromTimeline(_ recording: Recording, onSelect: @escaping (Recording) -> Void) { + isListening = false + + withAnimation(.easeInOut(duration: 0.4)) { + isPlaybackMode = true + canSaveCurrentRecording = false + animateOrb = true + } + + audioPostProcessingManager.stop() + audioPostProcessingManager.loadAndPlay(fileURL: recording.fileURL) + + onSelect(recording) + } +} diff --git a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift new file mode 100644 index 0000000..40d2862 --- /dev/null +++ b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift @@ -0,0 +1,42 @@ +// +// HeartbeatMainView.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 20/11/25. +// + +import SwiftUI +import SwiftData + +struct HeartbeatMainView: View { + @StateObject private var viewModel = HeartbeatMainViewModel() + @Environment(\.modelContext) private var modelContext + + var body: some View { + ZStack { + if viewModel.showTimeline { + PregnancyTimelineView( + heartbeatSoundManager: viewModel.heartbeatSoundManager, + showTimeline: $viewModel.showTimeline, + onSelectRecording: viewModel.handleRecordingSelection + ) + .transition(.opacity) + } else { + OrbLiveListenView( + heartbeatSoundManager: viewModel.heartbeatSoundManager, + showTimeline: $viewModel.showTimeline + ) + .transition(.opacity) + } + } + .preferredColorScheme(.dark) + .onAppear { + viewModel.setupManager(modelContext: modelContext) + } + } +} + +#Preview { + HeartbeatMainView() + .modelContainer(for: SavedHeartbeat.self, inMemory: true) +} diff --git a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift new file mode 100644 index 0000000..92b1368 --- /dev/null +++ b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift @@ -0,0 +1,260 @@ +import SwiftUI +import SwiftData + +struct OrbLiveListenView: View { + @Environment(\.modelContext) private var modelContext + + @ObservedObject var heartbeatSoundManager: HeartbeatSoundManager + @Binding var showTimeline: Bool + + @StateObject private var viewModel = OrbLiveListenViewModel() + @StateObject private var tutorialViewModel = TutorialViewModel() + + var body: some View { + GeometryReader { geometry in + ZStack { + backgroundView + topControlsView + statusTextView + orbView(geometry: geometry) + + // Save/Library Button (Only visible when dragging) + saveButton(geometry: geometry) + + // Floating Button to Open Timeline manually + if !viewModel.isListening && !viewModel.isDraggingToSave { + libraryOpenButton(geometry: geometry) + } + + coachMarkView + + if let context = tutorialViewModel.activeTutorial { + TutorialOverlay(viewModel: tutorialViewModel, context: context) + } + } + .sheet(isPresented: $viewModel.showShareSheet) { + if let lastRecordingURL = heartbeatSoundManager.lastRecording?.fileURL { + ShareSheet(activityItems: [lastRecordingURL]) + } + } + .preferredColorScheme(.dark) + .onAppear { + tutorialViewModel.showInitialTutorialIfNeeded() + viewModel.handleOnAppear(recording: heartbeatSoundManager.lastRecording) + } + } + } + + private var backgroundView: some View { + ZStack { + Color.black.ignoresSafeArea() + Image("backgroundPurple") + .resizable() + .scaleEffect(viewModel.isListening ? 1.2 : 1.0) + .animation(.easeInOut(duration: 1.2), value: viewModel.isListening) + .ignoresSafeArea() + } + } + + private var topControlsView: some View { + VStack { + HStack { + if viewModel.isPlaybackMode { + Button(action: viewModel.handleBackButton, label: { + Image(systemName: "chevron.left") + .font(.system(size: 22, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 50, height: 50) + .clipShape(Circle()) + }) + .glassEffect(.clear) + .transition(.opacity.animation(.easeInOut)) + } + Spacer() + } + .padding() + Spacer() + } + } + + private func libraryOpenButton(geometry: GeometryProxy) -> some View { + VStack { + HStack { + Spacer() + Button { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + showTimeline = true + } + } label: { + Image(systemName: "book.fill") + .font(.body) + .foregroundColor(.white) + .frame(width: 50, height: 50) + .clipShape(Circle()) + } + .glassEffect(.clear) + .padding(.bottom, 50) + } + .padding() + Spacer() + } + } + + private func saveButton(geometry: GeometryProxy) -> some View { + Image(systemName: "book.fill") + .font(.system(size: 28)) + .foregroundColor(.white) + .frame(width: 77, height: 77) + .background(Circle().fill(Color.white.opacity(0.1))) + .clipShape(Circle()) + .scaleEffect(viewModel.saveButtonScale) + .position(x: geometry.size.width / 2, y: geometry.size.height - 100) + .opacity(viewModel.isDraggingToSave ? min(viewModel.dragOffset / 150.0, 1.0) : 0.0) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToSave) + .animation(.easeOut(duration: 0.2), value: viewModel.dragOffset) + } + + private var statusTextView: some View { + VStack { + Group { + if viewModel.isListening && viewModel.isLongPressing { + CountdownTextView(countdown: viewModel.longPressCountdown, isVisible: viewModel.isLongPressing) + } else if viewModel.isListening { + Text("Listening...") + .font(.title) + .fontWeight(.bold) + } else if viewModel.isPlaybackMode { + VStack(spacing: 8) { + Text(viewModel.audioPostProcessingManager.isPlaying ? "Playing..." : + (viewModel.isDraggingToSave ? "Drag to save" : "Tap orb to play")) + .font(.title2) + .fontWeight(.medium) + + if viewModel.audioPostProcessingManager.duration > 0 && !viewModel.isDraggingToSave { + Text("\(Int(viewModel.audioPostProcessingManager.currentTime))s / \(Int(viewModel.audioPostProcessingManager.duration))s") + .font(.caption) + .foregroundColor(.white.opacity(0.7)) + } + } + } + } + .foregroundColor(.white) + .padding(.top, 50) + .transition(.opacity.animation(.easeInOut)) + Spacer() + } + } + + private func orbView(geometry: GeometryProxy) -> some View { + VStack { + ZStack { + AnimatedOrbView() + bokehEffectView + } + .frame(width: 200, height: 200) + .opacity(viewModel.isPlaybackMode ? (viewModel.audioPostProcessingManager.isPlaying ? 1.0 : 0.4) : 1.0) + .scaleEffect(viewModel.orbScaleEffect * viewModel.orbDragScale) + .animation(.easeInOut(duration: 0.5), value: viewModel.audioPostProcessingManager.isPlaying) + .animation(.interpolatingSpring(mass: 2, stiffness: 100, damping: 20), value: viewModel.animateOrb) + .animation(.easeInOut(duration: 0.2), value: viewModel.longPressScale) + .animation(.easeInOut(duration: 0.2), value: viewModel.orbDragScale) + .offset(y: viewModel.orbOffset(geometry: geometry) + viewModel.dragOffset) + .onTapGesture(count: 2) { + viewModel.handleDoubleTap { + heartbeatSoundManager.start() + heartbeatSoundManager.startRecording() + } + } + .onTapGesture(count: 1) { + viewModel.handleSingleTap(lastRecording: heartbeatSoundManager.lastRecording) + } + .modifier(GestureModifier( + isPlaybackMode: viewModel.isPlaybackMode, + geometry: geometry, + handleDragChange: { value in + viewModel.handleDragChange(value: value, geometry: geometry) + }, + handleDragEnd: { value in + viewModel.handleDragEnd(value: value, geometry: geometry) { + heartbeatSoundManager.saveRecording() + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + showTimeline = true + } + } + }, + handleLongPressChange: viewModel.handleLongPressChange, + handleLongPressComplete: { + viewModel.handleLongPressComplete { + heartbeatSoundManager.stopRecording() + heartbeatSoundManager.stop() + tutorialViewModel.showListeningTutorialIfNeeded() + } + } + )) + } + .frame(width: geometry.size.width, height: geometry.size.height) + } + + private var bokehEffectView: some View { + Group { + if viewModel.isListening { + BokehEffectView(amplitude: $heartbeatSoundManager.blinkAmplitude) + } else if viewModel.isPlaybackMode { + BokehEffectView(amplitude: .constant(viewModel.audioPostProcessingManager.isPlaying ? 0.8 : 0.2)) + .opacity(viewModel.audioPostProcessingManager.isPlaying ? 1.0 : 0.5) + .animation(.easeInOut(duration: 0.5), value: viewModel.audioPostProcessingManager.isPlaying) + } + } + .scaleEffect(x: viewModel.physicsController.scaleX, y: viewModel.physicsController.scaleY) + .offset(x: viewModel.physicsController.offsetX, y: viewModel.physicsController.offsetY) + .rotationEffect(.degrees(viewModel.physicsController.rotation)) + .onAppear { viewModel.physicsController.startPhysics() } + .frame(width: 18, height: 18) + } + + private var coachMarkView: some View { + Group { + if !viewModel.isListening && !viewModel.isPlaybackMode { + GeometryReader { proxy in + CoachMarkView() + .position(x: proxy.size.width / 2, y: proxy.size.height / 2 + 250) + } + .transition(.opacity) + } + } + } + + struct GestureModifier: ViewModifier { + let isPlaybackMode: Bool + let geometry: GeometryProxy + let handleDragChange: (SequenceGesture.Value) -> Void + let handleDragEnd: (SequenceGesture.Value) -> Void + let handleLongPressChange: (Bool) -> Void + let handleLongPressComplete: () -> Void + + func body(content: Content) -> some View { + if isPlaybackMode { + content.gesture( + LongPressGesture(minimumDuration: 0.2) + .sequenced(before: DragGesture()) + .onChanged { handleDragChange($0) } + .onEnded { handleDragEnd($0) } + ) + } else { + content.gesture( + LongPressGesture(minimumDuration: 3.0) + .onChanged { handleLongPressChange($0) } + .onEnded { _ in handleLongPressComplete() } + ) + } + } + } +} + +#Preview { + OrbLiveListenView( + heartbeatSoundManager: HeartbeatSoundManager(), + showTimeline: .constant(false) + ) + .modelContainer(for: SavedHeartbeat.self, inMemory: true) +} diff --git a/Tiny/Onboarding/Models/onboardingModel.swift b/Tiny/Features/Onboarding/Models/onboardingModel.swift similarity index 100% rename from Tiny/Onboarding/Models/onboardingModel.swift rename to Tiny/Features/Onboarding/Models/onboardingModel.swift diff --git a/Tiny/Onboarding/ViewModels/onboardingViewModels.swift b/Tiny/Features/Onboarding/ViewModels/onboardingViewModels.swift similarity index 100% rename from Tiny/Onboarding/ViewModels/onboardingViewModels.swift rename to Tiny/Features/Onboarding/ViewModels/onboardingViewModels.swift diff --git a/Tiny/Features/Onboarding/Views/onboardingView.swift b/Tiny/Features/Onboarding/Views/onboardingView.swift new file mode 100644 index 0000000..1d1c4d0 --- /dev/null +++ b/Tiny/Features/Onboarding/Views/onboardingView.swift @@ -0,0 +1,578 @@ +// +// OnBoardingView.swift +// tiny +// +// Created by Destu Cikal Ramdani on 27/10/25. +// + +import SwiftUI +import UIKit + +struct ScrollOffsetKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +struct OnBoardingView: View { + @Binding var hasShownOnboarding: Bool + @State private var scrollOffset: CGFloat = 0 + @State private var pathSize: CGSize = .zero + // ⚠️ Change lineFrame to use Global coordinates to correctly offset the heart + @State private var lineFrameGlobal: CGRect = .zero + + enum OnboardingPageType: CaseIterable, Identifiable { + case page0 + case page1 + case page2 + case page3 + case page4 + + var id: Self { self } + } + + private let pages = OnboardingPageType.allCases + + var body: some View { + GeometryReader { geometry in + ScrollView(.vertical) { + ZStack(alignment: .top) { + Color.clear + .frame(height: 0) + .background( + GeometryReader { geo in + let offset = -geo.frame(in: .named("scroll")).minY + Color.clear + .preference(key: ScrollOffsetKey.self, value: offset) + } + ) + // Purple blur background image + Image("bgPurpleOnboarding") + .resizable() + .scaledToFill() + .frame( + width: geometry.size.width, + height: geometry.size.height * CGFloat(pages.count), + alignment: .top // ← makes sure it pins to the top + ) + .clipped() + .ignoresSafeArea() + + // Line background image + Image("lineOnboarding") + .resizable() + .scaledToFit() + .frame( + width: geometry.size.width, + height: geometry.size.height * CGFloat(pages.count), + ) + .clipped() + .offset(y: 120) + + // 2. PATH DEFINITION AND FRAME CAPTURE + LinePath() + .stroke(.clear, lineWidth: 1) // Clear stroke + .offset(y: 375) // Use the same offset as the background line image + .frame( + width: geometry.size.width, + height: geometry.size.height * 4.42 + ) + .onAppear { + pathSize = CGSize(width: geometry.size.width, height: geometry.size.height * 4.42) + } + + // 3. YELLOW HEART MOVEMENT LOGIC + if pathSize != .zero { + let totalHeight = geometry.size.height * CGFloat(pages.count) + let travelHeight = totalHeight - geometry.size.height + let progress = min(max(scrollOffset / travelHeight, 0), 1) + + let pathRect = CGRect(origin: .zero, size: pathSize) + let heartSize: CGFloat = 40 + + // 1. Calculate the path's starting X-point (relative to its own bounding box) + let _: CGFloat = pathSize.width * 0.46853 + + // 2. The FollowEffect already translates the heart by pathStartXRelative when progress is 0. + // We need the external offset to cancel out that translation and apply the center correction. + + // 3. Calculate the required Horizontal Offset for Centering at X=pathStartXRelative: + // This value places the heart's center at the path's start X coordinate. + let xOffsetCorrection = -(heartSize / 2) + + // 4. Calculate the required Vertical Offset for the path's position (375): + // This value places the heart's center at the path's start Y coordinate (375). + let yOffsetCorrection = 375 - (heartSize / 2) + + Image("yellowHeart") + .resizable() + .frame(width: heartSize, height: heartSize) + .modifier( + FollowEffect( + pct: progress, + path: LinePath().path(in: pathRect), + rotate: false + ) + ) + // 🔥 The key is to shift the view so the initial center of the heart + // is placed at the path's visual start point (which is pathStartXRelative + // horizontally, and 375 vertically in the ZStack). + .offset( + x: xOffsetCorrection, // Uses the actual path start X point + y: yOffsetCorrection // Uses the fixed Y offset (375) + ) + } + + VStack(spacing: 0) { + ForEach(pages) { page in + pageView(for: page) + .frame( + width: geometry.size.width, + height: geometry.size.height + ) + } + } + } + } + .onPreferenceChange(ScrollOffsetKey.self) { value in + scrollOffset = value + } + .coordinateSpace(name: "scroll") + .scrollTargetBehavior(.paging) + .scrollIndicators(.hidden) + } + .ignoresSafeArea() + } + + @ViewBuilder + func pageView(for page: OnboardingPageType) -> some View { + switch page { + case .page0: + OnboardingPage0() + case .page1: + OnboardingPage1() + case .page2: + OnboardingPage2() + case .page3: + OnboardingPage3() + case .page4: + OnboardingPage4(hasShownOnboarding: $hasShownOnboarding) + } + } +} + +private struct OnboardingPage0: View { + var titleText: AttributedString { + var string = AttributedString("Hello lovely parents!") + if let range = string.range(of: "lovely") { + string[range].foregroundColor = Color("mainYellow") + } + return string + } + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .top) { + VStack { + Text(titleText) + .font(.title) + .fontWeight(.bold) + .padding() + + Text("You can listen to your baby's heartbeat live and record it to listen again later.") + .font(.body) + .fontWeight(.regular) + .multilineTextAlignment(.center) + .padding(.horizontal, 30) + } + .position(x: geo.size.width / 2, y: geo.size.height / 2 - 180) // 🔥 Shift up + } + .frame(width: geo.size.width, height: geo.size.height) + } + } +} + +private struct OnboardingPage1: View { + @State private var scanOffset: CGFloat = -40 // Start left + @State private var rotation: Double = -5 // Small tilt + + var titleText: AttributedString { + var string = AttributedString("What can you do with tiny?") + if let range = string.range(of: "tiny") { + string[range].foregroundColor = Color("mainYellow") + } + return string + } + + var body: some View { + ZStack { + + VStack(spacing: 16) { + ZStack { + VStack { + Image("handHoldingPhone") + .offset(x: scanOffset) + .rotationEffect(.degrees(rotation)) + .onAppear { + withAnimation( + .easeInOut(duration: 2.4) + .repeatForever(autoreverses: true) + ) { + scanOffset = 40 // move right + rotation = 5 // tilt to the right + } + } + + Image("stomach") + } + } + + Text(titleText) + .font(.title2) + .fontWeight(.bold) + .padding(.top, 20) + + Text("Connect your AirPods and let Tiny access your microphone to hear every little beat.") + .font(.body) + .fontWeight(.regular) + .multilineTextAlignment(.center) + .padding(.horizontal, 30) + } + } + } +} + +private struct OnboardingPage2: View { + var titleText: AttributedString { + var string = AttributedString("Feel the best experience") + if let range = string.range(of: "best") { + string[range].foregroundColor = Color("mainYellow") + } + return string + } + + var body: some View { + ZStack { + VStack { + HStack { + Image(systemName: "airpod.gen3.right") + .font(.system(size: 80)) + .rotationEffect(.degrees(-10)) + + Image(systemName: "airpod.gen3.left") + .font(.system(size: 80)) + .rotationEffect(.degrees(10)) + .offset(y: 10) + } + .mask( + LinearGradient( + gradient: Gradient(colors: [ + .white, + .white.opacity(0.3) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + + Text(titleText) + .font(.title2) + .fontWeight(.bold) + .padding() + + Text("Tiny will need access to your microphone so you can hear every tiny beat clearly.") + .font(.body) + .fontWeight(.regular) + .multilineTextAlignment(.center) + } + } + } +} + +private struct OnboardingPage3: View { + var titleText: AttributedString { + var string = AttributedString("Grow through every moment") + if let range = string.range(of: "moment") { + string[range].foregroundColor = Color("mainYellow") + } + return string + } + + var body: some View { + ZStack(alignment: .top) { + VStack { + Image("onboardingShareMood") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 180, height: 180) + Text(titleText) + .font(.title2) + .fontWeight(.bold) + .padding(5) + + Text("Share how you feel today and let love keep you both close.") + .font(.body) + .fontWeight(.regular) + .multilineTextAlignment(.center) + } + } + } +} + +private struct OnboardingPage4: View { + @StateObject private var manager = HeartbeatSoundManager() + @Binding var hasShownOnboarding: Bool // Add this line + @State private var showDeniedAlert = false + + var titleText: AttributedString { + var string = AttributedString("Hello lovely parents!") + if let range = string.range(of: "lovely") { + string[range].foregroundColor = Color("mainYellow") + } + return string + } + + var body: some View { + VStack { + Spacer() + + Button(action: { + manager.requestMicrophonePermission { granted in + if granted { + print("Permission granted") + hasShownOnboarding = true // Dismiss onboarding when permission is granted + } else { + showDeniedAlert = true + } + } + }, label: { + Text("Let's go") + .font(.headline) + .fontWeight(.semibold) + .padding(.vertical, 14) + .padding(.horizontal, 40) + .foregroundColor(.white) + .glassEffect() + }) + .padding(.top, 20) + .alert("Microphone Access Denied", isPresented: $showDeniedAlert) { + Button("OK", role: .cancel) { } + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + } message: { + Text("Please enable microphone access in Settings to use this feature.") + } + } + .padding(50) + } +} + +// MARK: - FollowEffect +struct FollowEffect: GeometryEffect { + var pct: CGFloat = 0 + let path: Path + var rotate = false + + var animatableData: CGFloat { + get { pct } + set { pct = newValue } + } + + func effectValue(size: CGSize) -> ProjectionTransform { + let pt1 = percentPoint(pct) + return ProjectionTransform(CGAffineTransform(translationX: pt1.x, y: pt1.y)) + } + + private func percentPoint(_ percent: CGFloat) -> CGPoint { + let pct = max(0, min(percent, 1)) + let varf = pct > 0.999 ? 0.999 : pct + let vart = pct + 0.001 + let vartp = path.trimmedPath(from: varf, to: vart) + return CGPoint(x: vartp.boundingRect.midX, y: vartp.boundingRect.midY) + } +} + +struct LinePath: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let width = rect.size.width + let height = rect.size.height + path.move(to: CGPoint(x: 0.46853*width, y: 0.00006*height)) + path.addCurve(to: CGPoint(x: 0.47069*width, y: 0.00003*height), control1: CGPoint(x: 0.46904*width, y: -0.00001*height), control2: CGPoint(x: 0.47001*width, y: -0.00002*height)) + path.addCurve(to: CGPoint(x: 0.50231*width, y: 0.00418*height), control1: CGPoint(x: 0.48713*width, y: 0.00131*height), control2: CGPoint(x: 0.49731*width, y: 0.0027*height)) + path.addCurve(to: CGPoint(x: 0.50295*width, y: 0.00887*height), control1: CGPoint(x: 0.50731*width, y: 0.00566*height), control2: CGPoint(x: 0.50707*width, y: 0.00722*height)) + path.addCurve(to: CGPoint(x: 0.44179*width, y: 0.01969*height), control1: CGPoint(x: 0.49474*width, y: 0.01214*height), control2: CGPoint(x: 0.47099*width, y: 0.01576*height)) + path.addCurve(to: CGPoint(x: 0.34699*width, y: 0.03253*height), control1: CGPoint(x: 0.41252*width, y: 0.02363*height), control2: CGPoint(x: 0.37773*width, y: 0.02791*height)) + path.addCurve(to: CGPoint(x: 0.27704*width, y: 0.0474*height), control1: CGPoint(x: 0.31628*width, y: 0.03715*height), control2: CGPoint(x: 0.28975*width, y: 0.04211*height)) + path.addCurve(to: CGPoint(x: 0.30016*width, y: 0.06937*height), control1: CGPoint(x: 0.25934*width, y: 0.05476*height), control2: CGPoint(x: 0.27196*width, y: 0.06212*height)) + path.addCurve(to: CGPoint(x: 0.41661*width, y: 0.09074*height), control1: CGPoint(x: 0.32837*width, y: 0.07663*height), control2: CGPoint(x: 0.37214*width, y: 0.08378*height)) + path.addCurve(to: CGPoint(x: 0.53703*width, y: 0.11092*height), control1: CGPoint(x: 0.46104*width, y: 0.09769*height), control2: CGPoint(x: 0.50621*width, y: 0.10445*height)) + path.addCurve(to: CGPoint(x: 0.57155*width, y: 0.12939*height), control1: CGPoint(x: 0.56786*width, y: 0.11738*height), control2: CGPoint(x: 0.5845*width, y: 0.12357*height)) + path.addCurve(to: CGPoint(x: 0.50718*width, y: 0.14064*height), control1: CGPoint(x: 0.56169*width, y: 0.13381*height), control2: CGPoint(x: 0.5381*width, y: 0.13731*height)) + path.addCurve(to: CGPoint(x: 0.39854*width, y: 0.15091*height), control1: CGPoint(x: 0.47632*width, y: 0.14397*height), control2: CGPoint(x: 0.43791*width, y: 0.14715*height)) + path.addCurve(to: CGPoint(x: 0.19655*width, y: 0.18689*height), control1: CGPoint(x: 0.31969*width, y: 0.15845*height), control2: CGPoint(x: 0.23594*width, y: 0.16841*height)) + path.addCurve(to: CGPoint(x: 0.27432*width, y: 0.21229*height), control1: CGPoint(x: 0.17535*width, y: 0.19684*height), control2: CGPoint(x: 0.21005*width, y: 0.2051*height)) + path.addCurve(to: CGPoint(x: 0.52911*width, y: 0.23126*height), control1: CGPoint(x: 0.33863*width, y: 0.21949*height), control2: CGPoint(x: 0.43245*width, y: 0.22561*height)) + path.addCurve(to: CGPoint(x: 0.8004*width, y: 0.2474*height), control1: CGPoint(x: 0.62568*width, y: 0.2369*height), control2: CGPoint(x: 0.72516*width, y: 0.24209*height)) + path.addCurve(to: CGPoint(x: 0.892*width, y: 0.25558*height), control1: CGPoint(x: 0.83804*width, y: 0.25006*height), control2: CGPoint(x: 0.86972*width, y: 0.25276*height)) + path.addCurve(to: CGPoint(x: 0.92725*width, y: 0.2645*height), control1: CGPoint(x: 0.91429*width, y: 0.2584*height), control2: CGPoint(x: 0.92725*width, y: 0.26135*height)) + path.addLine(to: CGPoint(x: 0.92725*width, y: 0.26451*height)) + path.addLine(to: CGPoint(x: 0.92723*width, y: 0.26452*height)) + path.addCurve(to: CGPoint(x: 0.87378*width, y: 0.27424*height), control1: CGPoint(x: 0.92175*width, y: 0.26799*height), control2: CGPoint(x: 0.90268*width, y: 0.2712*height)) + path.addCurve(to: CGPoint(x: 0.76081*width, y: 0.28288*height), control1: CGPoint(x: 0.84487*width, y: 0.27727*height), control2: CGPoint(x: 0.80603*width, y: 0.28012*height)) + path.addCurve(to: CGPoint(x: 0.44007*width, y: 0.29887*height), control1: CGPoint(x: 0.67039*width, y: 0.2884*height), control2: CGPoint(x: 0.55413*width, y: 0.29354*height)) + path.addCurve(to: CGPoint(x: 0.13195*width, y: 0.31605*height), control1: CGPoint(x: 0.32594*width, y: 0.30421*height), control2: CGPoint(x: 0.214*width, y: 0.30974*height)) + path.addCurve(to: CGPoint(x: 0.00348*width, y: 0.33778*height), control1: CGPoint(x: 0.04987*width, y: 0.32236*height), control2: CGPoint(x: -0.00189*width, y: 0.32941*height)) + path.addCurve(to: CGPoint(x: 0.18482*width, y: 0.36315*height), control1: CGPoint(x: 0.01182*width, y: 0.3508*height), control2: CGPoint(x: 0.08341*width, y: 0.35885*height)) + path.addCurve(to: CGPoint(x: 0.54332*width, y: 0.36601*height), control1: CGPoint(x: 0.28585*width, y: 0.36744*height), control2: CGPoint(x: 0.41645*width, y: 0.368*height)) + path.addCurve(to: CGPoint(x: 0.68115*width, y: 0.32467*height), control1: CGPoint(x: 0.49289*width, y: 0.34814*height), control2: CGPoint(x: 0.57308*width, y: 0.3324*height)) + path.addCurve(to: CGPoint(x: 0.85226*width, y: 0.31983*height), control1: CGPoint(x: 0.73537*width, y: 0.32079*height), control2: CGPoint(x: 0.79673*width, y: 0.31892*height)) + path.addCurve(to: CGPoint(x: 0.98787*width, y: 0.3316*height), control1: CGPoint(x: 0.90787*width, y: 0.32074*height), control2: CGPoint(x: 0.9574*width, y: 0.32442*height)) + path.addCurve(to: CGPoint(x: 0.98315*width, y: 0.34301*height), control1: CGPoint(x: 1.00435*width, y: 0.33549*height), control2: CGPoint(x: 1.0012*width, y: 0.33935*height)) + path.addCurve(to: CGPoint(x: 0.88929*width, y: 0.35322*height), control1: CGPoint(x: 0.96512*width, y: 0.34667*height), control2: CGPoint(x: 0.93226*width, y: 0.35013*height)) + path.addCurve(to: CGPoint(x: 0.5473*width, y: 0.36627*height), control1: CGPoint(x: 0.80372*width, y: 0.35939*height), control2: CGPoint(x: 0.67768*width, y: 0.36417*height)) + path.addCurve(to: CGPoint(x: 0.64725*width, y: 0.40604*height), control1: CGPoint(x: 0.55372*width, y: 0.36847*height), control2: CGPoint(x: 0.60625*width, y: 0.38638*height)) + path.addCurve(to: CGPoint(x: 0.69367*width, y: 0.43609*height), control1: CGPoint(x: 0.66849*width, y: 0.41623*height), control2: CGPoint(x: 0.68664*width, y: 0.42689*height)) + path.addCurve(to: CGPoint(x: 0.69486*width, y: 0.44855*height), control1: CGPoint(x: 0.69718*width, y: 0.44069*height), control2: CGPoint(x: 0.69792*width, y: 0.44492*height)) + path.addCurve(to: CGPoint(x: 0.67319*width, y: 0.45739*height), control1: CGPoint(x: 0.6918*width, y: 0.45217*height), control2: CGPoint(x: 0.68495*width, y: 0.45521*height)) + path.addCurve(to: CGPoint(x: 0.52745*width, y: 0.47867*height), control1: CGPoint(x: 0.63464*width, y: 0.46454*height), control2: CGPoint(x: 0.58318*width, y: 0.47159*height)) + path.addCurve(to: CGPoint(x: 0.35601*width, y: 0.50017*height), control1: CGPoint(x: 0.47168*width, y: 0.48575*height), control2: CGPoint(x: 0.41174*width, y: 0.49286*height)) + path.addCurve(to: CGPoint(x: 0.14242*width, y: 0.54736*height), control1: CGPoint(x: 0.24458*width, y: 0.51478*height), control2: CGPoint(x: 0.15059*width, y: 0.53012*height)) + path.addCurve(to: CGPoint(x: 0.21033*width, y: 0.57804*height), control1: CGPoint(x: 0.13818*width, y: 0.55631*height), control2: CGPoint(x: 0.16684*width, y: 0.56673*height)) + path.addCurve(to: CGPoint(x: 0.36668*width, y: 0.61392*height), control1: CGPoint(x: 0.25381*width, y: 0.58934*height), control2: CGPoint(x: 0.312*width, y: 0.6015*height)) + path.addCurve(to: CGPoint(x: 0.50167*width, y: 0.65131*height), control1: CGPoint(x: 0.42133*width, y: 0.62633*height), control2: CGPoint(x: 0.47245*width, y: 0.639*height)) + path.addCurve(to: CGPoint(x: 0.51389*width, y: 0.68327*height), control1: CGPoint(x: 0.52798*width, y: 0.6624*height), control2: CGPoint(x: 0.53655*width, y: 0.67321*height)) + path.addCurve(to: CGPoint(x: 0.51389*width, y: 0.68335*height), control1: CGPoint(x: 0.51394*width, y: 0.6833*height), control2: CGPoint(x: 0.51395*width, y: 0.68333*height)) + path.addCurve(to: CGPoint(x: 0.51242*width, y: 0.68391*height), control1: CGPoint(x: 0.5134*width, y: 0.68354*height), control2: CGPoint(x: 0.51291*width, y: 0.68372*height)) + path.addCurve(to: CGPoint(x: 0.50566*width, y: 0.68643*height), control1: CGPoint(x: 0.5104*width, y: 0.68476*height), control2: CGPoint(x: 0.50814*width, y: 0.6856*height)) + path.addCurve(to: CGPoint(x: 0.30485*width, y: 0.7433*height), control1: CGPoint(x: 0.47286*width, y: 0.69846*height), control2: CGPoint(x: 0.42891*width, y: 0.71124*height)) + path.addCurve(to: CGPoint(x: 0.30724*width, y: 0.76876*height), control1: CGPoint(x: 0.26818*width, y: 0.75277*height), control2: CGPoint(x: 0.27555*width, y: 0.76114*height)) + path.addCurve(to: CGPoint(x: 0.45591*width, y: 0.78974*height), control1: CGPoint(x: 0.33897*width, y: 0.77638*height), control2: CGPoint(x: 0.39507*width, y: 0.78326*height)) + path.addCurve(to: CGPoint(x: 0.63259*width, y: 0.80829*height), control1: CGPoint(x: 0.51667*width, y: 0.7962*height), control2: CGPoint(x: 0.5823*width, y: 0.80228*height)) + path.addCurve(to: CGPoint(x: 0.71827*width, y: 0.82651*height), control1: CGPoint(x: 0.68289*width, y: 0.81428*height), control2: CGPoint(x: 0.71827*width, y: 0.82025*height)) + path.addLine(to: CGPoint(x: 0.71827*width, y: 0.82653*height)) + path.addLine(to: CGPoint(x: 0.71825*width, y: 0.82654*height)) + path.addCurve(to: CGPoint(x: 0.59979*width, y: 0.84619*height), control1: CGPoint(x: 0.70733*width, y: 0.83344*height), control2: CGPoint(x: 0.66158*width, y: 0.83988*height)) + path.addCurve(to: CGPoint(x: 0.38391*width, y: 0.86506*height), control1: CGPoint(x: 0.53798*width, y: 0.85249*height), control2: CGPoint(x: 0.45985*width, y: 0.85868*height)) + path.addCurve(to: CGPoint(x: 0.18058*width, y: 0.8851*height), control1: CGPoint(x: 0.30791*width, y: 0.87144*height), control2: CGPoint(x: 0.23407*width, y: 0.87802*height)) + path.addCurve(to: CGPoint(x: 0.09946*width, y: 0.90817*height), control1: CGPoint(x: 0.1271*width, y: 0.89219*height), control2: CGPoint(x: 0.09408*width, y: 0.89977*height)) + path.addCurve(to: CGPoint(x: 0.14653*width, y: 0.92401*height), control1: CGPoint(x: 0.10364*width, y: 0.91469*height), control2: CGPoint(x: 0.12018*width, y: 0.91992*height)) + path.addCurve(to: CGPoint(x: 0.25278*width, y: 0.93315*height), control1: CGPoint(x: 0.17288*width, y: 0.92811*height), control2: CGPoint(x: 0.2091*width, y: 0.9311*height)) + path.addCurve(to: CGPoint(x: 0.58316*width, y: 0.9356*height), control1: CGPoint(x: 0.3398*width, y: 0.93723*height), control2: CGPoint(x: 0.45638*width, y: 0.93759*height)) + path.addCurve(to: CGPoint(x: 0.5835*width, y: 0.91403*height), control1: CGPoint(x: 0.55802*width, y: 0.92672*height), control2: CGPoint(x: 0.5618*width, y: 0.91955*height)) + path.addCurve(to: CGPoint(x: 0.69177*width, y: 0.90237*height), control1: CGPoint(x: 0.60535*width, y: 0.90847*height), control2: CGPoint(x: 0.64528*width, y: 0.9046*height)) + path.addCurve(to: CGPoint(x: 0.95794*width, y: 0.90809*height), control1: CGPoint(x: 0.78459*width, y: 0.89791*height), control2: CGPoint(x: 0.90399*width, y: 0.89995*height)) + path.addCurve(to: CGPoint(x: 0.97546*width, y: 0.91778*height), control1: CGPoint(x: 0.98102*width, y: 0.91157*height), control2: CGPoint(x: 0.98577*width, y: 0.91481*height)) + path.addCurve(to: CGPoint(x: 0.90416*width, y: 0.92571*height), control1: CGPoint(x: 0.9652*width, y: 0.92073*height), control2: CGPoint(x: 0.9401*width, y: 0.92338*height)) + path.addCurve(to: CGPoint(x: 0.58712*width, y: 0.93586*height), control1: CGPoint(x: 0.83261*width, y: 0.93034*height), control2: CGPoint(x: 0.71731*width, y: 0.93378*height)) + path.addCurve(to: CGPoint(x: 0.62889*width, y: 0.96235*height), control1: CGPoint(x: 0.59228*width, y: 0.93771*height), control2: CGPoint(x: 0.62294*width, y: 0.9492*height)) + path.addCurve(to: CGPoint(x: 0.51023*width, y: 0.99981*height), control1: CGPoint(x: 0.63508*width, y: 0.97602*height), control2: CGPoint(x: 0.61461*width, y: 0.99154*height)) + path.addCurve(to: CGPoint(x: 0.50807*width, y: 0.99977*height), control1: CGPoint(x: 0.50955*width, y: 0.99986*height), control2: CGPoint(x: 0.50858*width, y: 0.99985*height)) + path.addCurve(to: CGPoint(x: 0.50836*width, y: 0.99955*height), control1: CGPoint(x: 0.50755*width, y: 0.9997*height), control2: CGPoint(x: 0.50768*width, y: 0.9996*height)) + path.addCurve(to: CGPoint(x: 0.6258*width, y: 0.96236*height), control1: CGPoint(x: 0.61141*width, y: 0.99139*height), control2: CGPoint(x: 0.63198*width, y: 0.97602*height)) + path.addCurve(to: CGPoint(x: 0.58404*width, y: 0.93591*height), control1: CGPoint(x: 0.61982*width, y: 0.94915*height), control2: CGPoint(x: 0.5888*width, y: 0.9376*height)) + path.addCurve(to: CGPoint(x: 0.25151*width, y: 0.93344*height), control1: CGPoint(x: 0.45688*width, y: 0.93792*height), control2: CGPoint(x: 0.33942*width, y: 0.93757*height)) + path.addCurve(to: CGPoint(x: 0.14396*width, y: 0.92419*height), control1: CGPoint(x: 0.20737*width, y: 0.93137*height), control2: CGPoint(x: 0.17067*width, y: 0.92835*height)) + path.addCurve(to: CGPoint(x: 0.09637*width, y: 0.90819*height), control1: CGPoint(x: 0.11725*width, y: 0.92004*height), control2: CGPoint(x: 0.10058*width, y: 0.91476*height)) + path.addCurve(to: CGPoint(x: 0.17815*width, y: 0.8849*height), control1: CGPoint(x: 0.09092*width, y: 0.89968*height), control2: CGPoint(x: 0.12441*width, y: 0.89202*height)) + path.addCurve(to: CGPoint(x: 0.38197*width, y: 0.86481*height), control1: CGPoint(x: 0.23188*width, y: 0.87778*height), control2: CGPoint(x: 0.30597*width, y: 0.87119*height)) + path.addCurve(to: CGPoint(x: 0.59762*width, y: 0.84596*height), control1: CGPoint(x: 0.45802*width, y: 0.85842*height), control2: CGPoint(x: 0.53595*width, y: 0.85225*height)) + path.addCurve(to: CGPoint(x: 0.71517*width, y: 0.8265*height), control1: CGPoint(x: 0.65926*width, y: 0.83967*height), control2: CGPoint(x: 0.70438*width, y: 0.83329*height)) + path.addCurve(to: CGPoint(x: 0.63026*width, y: 0.8085*height), control1: CGPoint(x: 0.71512*width, y: 0.82037*height), control2: CGPoint(x: 0.68044*width, y: 0.81448*height)) + path.addCurve(to: CGPoint(x: 0.4537*width, y: 0.78996*height), control1: CGPoint(x: 0.58002*width, y: 0.8025*height), control2: CGPoint(x: 0.5147*width, y: 0.79645*height)) + path.addCurve(to: CGPoint(x: 0.3044*width, y: 0.76889*height), control1: CGPoint(x: 0.3928*width, y: 0.78348*height), control2: CGPoint(x: 0.33636*width, y: 0.77657*height)) + path.addCurve(to: CGPoint(x: 0.30198*width, y: 0.74317*height), control1: CGPoint(x: 0.27241*width, y: 0.7612*height), control2: CGPoint(x: 0.26497*width, y: 0.75274*height)) + path.addCurve(to: CGPoint(x: 0.50221*width, y: 0.68652*height), control1: CGPoint(x: 0.42543*width, y: 0.71128*height), control2: CGPoint(x: 0.4695*width, y: 0.69848*height)) + path.addCurve(to: CGPoint(x: 0.50227*width, y: 0.68649*height), control1: CGPoint(x: 0.50222*width, y: 0.68651*height), control2: CGPoint(x: 0.50224*width, y: 0.6865*height)) + path.addCurve(to: CGPoint(x: 0.50271*width, y: 0.68634*height), control1: CGPoint(x: 0.50242*width, y: 0.68644*height), control2: CGPoint(x: 0.50256*width, y: 0.68639*height)) + path.addCurve(to: CGPoint(x: 0.50938*width, y: 0.68385*height), control1: CGPoint(x: 0.50498*width, y: 0.68551*height), control2: CGPoint(x: 0.50719*width, y: 0.68468*height)) + path.addCurve(to: CGPoint(x: 0.49867*width, y: 0.65139*height), control1: CGPoint(x: 0.53375*width, y: 0.67366*height), control2: CGPoint(x: 0.52548*width, y: 0.66269*height)) + path.addCurve(to: CGPoint(x: 0.36386*width, y: 0.61405*height), control1: CGPoint(x: 0.46952*width, y: 0.63911*height), control2: CGPoint(x: 0.4185*width, y: 0.62647*height)) + path.addCurve(to: CGPoint(x: 0.20746*width, y: 0.57816*height), control1: CGPoint(x: 0.30925*width, y: 0.60165*height), control2: CGPoint(x: 0.25097*width, y: 0.58947*height)) + path.addCurve(to: CGPoint(x: 0.13933*width, y: 0.54735*height), control1: CGPoint(x: 0.16394*width, y: 0.56685*height), control2: CGPoint(x: 0.13505*width, y: 0.55637*height)) + path.addCurve(to: CGPoint(x: 0.35358*width, y: 0.49997*height), control1: CGPoint(x: 0.14756*width, y: 0.53*height), control2: CGPoint(x: 0.24211*width, y: 0.51459*height)) + path.addCurve(to: CGPoint(x: 0.52506*width, y: 0.47846*height), control1: CGPoint(x: 0.40931*width, y: 0.49266*height), control2: CGPoint(x: 0.46941*width, y: 0.48553*height)) + path.addCurve(to: CGPoint(x: 0.67048*width, y: 0.45724*height), control1: CGPoint(x: 0.58076*width, y: 0.47139*height), control2: CGPoint(x: 0.63208*width, y: 0.46436*height)) + path.addCurve(to: CGPoint(x: 0.69178*width, y: 0.44852*height), control1: CGPoint(x: 0.68193*width, y: 0.45511*height), control2: CGPoint(x: 0.68874*width, y: 0.45213*height)) + path.addCurve(to: CGPoint(x: 0.69058*width, y: 0.43611*height), control1: CGPoint(x: 0.69481*width, y: 0.44492*height), control2: CGPoint(x: 0.69409*width, y: 0.4407*height)) + path.addCurve(to: CGPoint(x: 0.64422*width, y: 0.40611*height), control1: CGPoint(x: 0.68357*width, y: 0.42694*height), control2: CGPoint(x: 0.66545*width, y: 0.41629*height)) + path.addCurve(to: CGPoint(x: 0.54421*width, y: 0.36632*height), control1: CGPoint(x: 0.60298*width, y: 0.38633*height), control2: CGPoint(x: 0.55003*width, y: 0.36832*height)) + path.addCurve(to: CGPoint(x: 0.18365*width, y: 0.36345*height), control1: CGPoint(x: 0.41689*width, y: 0.36832*height), control2: CGPoint(x: 0.2855*width, y: 0.36777*height)) + path.addCurve(to: CGPoint(x: 0.00039*width, y: 0.3378*height), control1: CGPoint(x: 0.08134*width, y: 0.35911*height), control2: CGPoint(x: 0.00882*width, y: 0.35096*height)) + path.addCurve(to: CGPoint(x: 0.13011*width, y: 0.31579*height), control1: CGPoint(x: -0.00508*width, y: 0.32926*height), control2: CGPoint(x: 0.04779*width, y: 0.32212*height)) + path.addCurve(to: CGPoint(x: 0.4388*width, y: 0.29858*height), control1: CGPoint(x: 0.21247*width, y: 0.30946*height), control2: CGPoint(x: 0.3247*width, y: 0.30392*height)) + path.addCurve(to: CGPoint(x: 0.75925*width, y: 0.28261*height), control1: CGPoint(x: 0.55298*width, y: 0.29324*height), control2: CGPoint(x: 0.66902*width, y: 0.28811*height)) + path.addCurve(to: CGPoint(x: 0.87158*width, y: 0.27401*height), control1: CGPoint(x: 0.80436*width, y: 0.27985*height), control2: CGPoint(x: 0.84294*width, y: 0.27701*height)) + path.addCurve(to: CGPoint(x: 0.92415*width, y: 0.26448*height), control1: CGPoint(x: 0.90018*width, y: 0.27101*height), control2: CGPoint(x: 0.91878*width, y: 0.26786*height)) + path.addCurve(to: CGPoint(x: 0.88961*width, y: 0.25578*height), control1: CGPoint(x: 0.92412*width, y: 0.26144*height), control2: CGPoint(x: 0.91159*width, y: 0.25856*height)) + path.addCurve(to: CGPoint(x: 0.79866*width, y: 0.24767*height), control1: CGPoint(x: 0.8676*width, y: 0.253*height), control2: CGPoint(x: 0.8362*width, y: 0.25032*height)) + path.addCurve(to: CGPoint(x: 0.52759*width, y: 0.23154*height), control1: CGPoint(x: 0.72356*width, y: 0.24236*height), control2: CGPoint(x: 0.62437*width, y: 0.2372*height)) + path.addCurve(to: CGPoint(x: 0.27205*width, y: 0.21251*height), control1: CGPoint(x: 0.43092*width, y: 0.22589*height), control2: CGPoint(x: 0.33673*width, y: 0.21975*height)) + path.addCurve(to: CGPoint(x: 0.19353*width, y: 0.18682*height), control1: CGPoint(x: 0.20732*width, y: 0.20527*height), control2: CGPoint(x: 0.17204*width, y: 0.19691*height)) + path.addCurve(to: CGPoint(x: 0.39644*width, y: 0.15067*height), control1: CGPoint(x: 0.2331*width, y: 0.16825*height), control2: CGPoint(x: 0.3173*width, y: 0.15824*height)) + path.addCurve(to: CGPoint(x: 0.50495*width, y: 0.14042*height), control1: CGPoint(x: 0.43607*width, y: 0.14689*height), control2: CGPoint(x: 0.47416*width, y: 0.14374*height)) + path.addCurve(to: CGPoint(x: 0.56854*width, y: 0.12931*height), control1: CGPoint(x: 0.53567*width, y: 0.13711*height), control2: CGPoint(x: 0.55885*width, y: 0.13366*height)) + path.addCurve(to: CGPoint(x: 0.53426*width, y: 0.11106*height), control1: CGPoint(x: 0.58123*width, y: 0.12361*height), control2: CGPoint(x: 0.56498*width, y: 0.1175*height)) + path.addCurve(to: CGPoint(x: 0.41403*width, y: 0.09092*height), control1: CGPoint(x: 0.50355*width, y: 0.10462*height), control2: CGPoint(x: 0.45853*width, y: 0.09788*height)) + path.addCurve(to: CGPoint(x: 0.29729*width, y: 0.06949*height), control1: CGPoint(x: 0.36956*width, y: 0.08396*height), control2: CGPoint(x: 0.32563*width, y: 0.07679*height)) + path.addCurve(to: CGPoint(x: 0.27404*width, y: 0.04732*height), control1: CGPoint(x: 0.26894*width, y: 0.0622*height), control2: CGPoint(x: 0.25613*width, y: 0.05477*height)) + path.addCurve(to: CGPoint(x: 0.34445*width, y: 0.03235*height), control1: CGPoint(x: 0.28687*width, y: 0.04198*height), control2: CGPoint(x: 0.31364*width, y: 0.03698*height)) + path.addCurve(to: CGPoint(x: 0.43934*width, y: 0.01949*height), control1: CGPoint(x: 0.37524*width, y: 0.02772*height), control2: CGPoint(x: 0.41019*width, y: 0.02342*height)) + path.addCurve(to: CGPoint(x: 0.49996*width, y: 0.00879*height), control1: CGPoint(x: 0.46858*width, y: 0.01555*height), control2: CGPoint(x: 0.49193*width, y: 0.01199*height)) + path.addCurve(to: CGPoint(x: 0.49939*width, y: 0.00428*height), control1: CGPoint(x: 0.50397*width, y: 0.00719*height), control2: CGPoint(x: 0.50414*width, y: 0.00569*height)) + path.addCurve(to: CGPoint(x: 0.46884*width, y: 0.00029*height), control1: CGPoint(x: 0.49464*width, y: 0.00288*height), control2: CGPoint(x: 0.48491*width, y: 0.00154*height)) + path.addCurve(to: CGPoint(x: 0.46853*width, y: 0.00006*height), control1: CGPoint(x: 0.46815*width, y: 0.00024*height), control2: CGPoint(x: 0.46802*width, y: 0.00014*height)) + path.closeSubpath() + path.move(to: CGPoint(x: 0.95539*width, y: 0.90827*height)) + path.addCurve(to: CGPoint(x: 0.69307*width, y: 0.90266*height), control1: CGPoint(x: 0.90252*width, y: 0.9003*height), control2: CGPoint(x: 0.78492*width, y: 0.89825*height)) + path.addCurve(to: CGPoint(x: 0.58637*width, y: 0.91415*height), control1: CGPoint(x: 0.64721*width, y: 0.90486*height), control2: CGPoint(x: 0.60789*width, y: 0.90867*height)) + path.addCurve(to: CGPoint(x: 0.58625*width, y: 0.93555*height), control1: CGPoint(x: 0.56496*width, y: 0.91959*height), control2: CGPoint(x: 0.56112*width, y: 0.9267*height)) + path.addCurve(to: CGPoint(x: 0.90253*width, y: 0.92543*height), control1: CGPoint(x: 0.71648*width, y: 0.93347*height), control2: CGPoint(x: 0.83144*width, y: 0.93004*height)) + path.addCurve(to: CGPoint(x: 0.97255*width, y: 0.91767*height), control1: CGPoint(x: 0.9382*width, y: 0.92312*height), control2: CGPoint(x: 0.96262*width, y: 0.92052*height)) + path.addCurve(to: CGPoint(x: 0.95539*width, y: 0.90827*height), control1: CGPoint(x: 0.98244*width, y: 0.91482*height), control2: CGPoint(x: 0.97804*width, y: 0.91169*height)) + path.closeSubpath() + path.move(to: CGPoint(x: 0.85178*width, y: 0.32015*height)) + path.addCurve(to: CGPoint(x: 0.6829*width, y: 0.32493*height), control1: CGPoint(x: 0.79722*width, y: 0.31926*height), control2: CGPoint(x: 0.73665*width, y: 0.32109*height)) + path.addCurve(to: CGPoint(x: 0.54641*width, y: 0.36596*height), control1: CGPoint(x: 0.57569*width, y: 0.3326*height), control2: CGPoint(x: 0.49617*width, y: 0.34822*height)) + path.addCurve(to: CGPoint(x: 0.88752*width, y: 0.35296*height), control1: CGPoint(x: 0.6766*width, y: 0.36387*height), control2: CGPoint(x: 0.80235*width, y: 0.3591*height)) + path.addCurve(to: CGPoint(x: 0.98039*width, y: 0.34286*height), control1: CGPoint(x: 0.93026*width, y: 0.34988*height), control2: CGPoint(x: 0.96268*width, y: 0.34646*height)) + path.addCurve(to: CGPoint(x: 0.98504*width, y: 0.33173*height), control1: CGPoint(x: 0.99807*width, y: 0.33928*height), control2: CGPoint(x: 1.00109*width, y: 0.33552*height)) + path.addCurve(to: CGPoint(x: 0.85178*width, y: 0.32015*height), control1: CGPoint(x: 0.95495*width, y: 0.32464*height), control2: CGPoint(x: 0.90625*width, y: 0.32104*height)) + path.closeSubpath() + return path + } +} + +#Preview { + OnBoardingView(hasShownOnboarding: .constant(false)) + .preferredColorScheme(.dark) +} + +#Preview("Page 0 Preview") { + OnboardingPage0() + .preferredColorScheme(.dark) +} + +#Preview("Page 1 Preview") { + OnboardingPage1() + .preferredColorScheme(.dark) +} + +#Preview("Page 2 Preview") { + OnboardingPage2() + .preferredColorScheme(.dark) +} + +#Preview("Page 3 Preview") { + OnboardingPage3() + .preferredColorScheme(.dark) +} + +#Preview("Page 4 Preview") { + OnboardingPage4(hasShownOnboarding: .constant(false)) + .preferredColorScheme(.dark) +} diff --git a/Tiny/Features/Timeline/Models/TimelineModel.swift b/Tiny/Features/Timeline/Models/TimelineModel.swift new file mode 100644 index 0000000..bcfb347 --- /dev/null +++ b/Tiny/Features/Timeline/Models/TimelineModel.swift @@ -0,0 +1,48 @@ +// +// TimelineModels.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 21/11/25. +// + +import Foundation + +import SwiftUI + +// MARK: - Data Model +struct WeekSection: Identifiable, Equatable { + let id = UUID() + let weekNumber: Int + let recordings: [Recording] +} + +// MARK: - Shared Helper +struct TimelineLayout { + static func calculateX(yCoor: CGFloat, width: CGFloat, period: CGFloat, amplitude: CGFloat) -> CGFloat { + let centerX = width / 2 + let angle = (yCoor / period) * .pi * 2 + return centerX + sin(angle) * amplitude + } +} + +// MARK: - Shared Shape +struct ContinuousWave: Shape { + var totalHeight: CGFloat + var period: CGFloat + var amplitude: CGFloat + + func path(in rect: CGRect) -> Path { + var path = Path() + let centerX = rect.width / 2 + let startPoint = CGPoint(x: centerX, y: 0) + path.move(to: startPoint) + + // Draw sine wave with a step of 5 pixels + for yCoord in stride(from: 0, through: totalHeight, by: 5) { + let angle = (yCoord / period) * .pi * 2 + let xCoord = centerX + sin(angle) * amplitude + path.addLine(to: CGPoint(x: xCoord, y: yCoord)) + } + return path + } +} diff --git a/Tiny/Features/Timeline/Views/MainTimelineListView.swift b/Tiny/Features/Timeline/Views/MainTimelineListView.swift new file mode 100644 index 0000000..8a4400e --- /dev/null +++ b/Tiny/Features/Timeline/Views/MainTimelineListView.swift @@ -0,0 +1,103 @@ +// +// MainTimelineListView.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 21/11/25. +// + +import SwiftUI + +struct MainTimelineListView: View { + let groupedData: [WeekSection] + @Binding var selectedWeek: WeekSection? + var animation: Namespace.ID + + // Configuration + private let itemSpacing: CGFloat = 160 + private let wavePeriod: CGFloat = 600 + private let topPadding: CGFloat = 150 + private let bottomPadding: CGFloat = 200 + + var body: some View { + GeometryReader { geometry in + let totalItems = groupedData.count + let contentHeight = max( + geometry.size.height, + topPadding + (CGFloat(totalItems) * itemSpacing) + bottomPadding + ) + + ScrollView(showsIndicators: false) { + // Reader to scroll to bottom if needed (optional) + ScrollViewReader { proxy in + ZStack(alignment: .top) { + // 1. Wavy Line + ContinuousWave( + totalHeight: contentHeight, + period: wavePeriod, + amplitude: geometry.size.width * 0.35 + ) + .stroke( + LinearGradient( + stops: [ + .init(color: .clear, location: 0.0), + .init(color: .white.opacity(0.2), location: 0.1), + .init(color: .white.opacity(0.3), location: 1.0) + ], + startPoint: .top, + endPoint: .bottom + ), + style: StrokeStyle(lineWidth: 2, lineCap: .round) + ) + .frame(width: geometry.size.width, height: contentHeight) + + // 2. Week Orbs + // Iterate directly: Index 0 (Earliest Week) -> Top + ForEach(Array(groupedData.enumerated()), id: \.element.id) { index, week in + + // Simple linear progression: 0 is Top, Max is Bottom + let yPos = topPadding + (CGFloat(index) * itemSpacing) + + let xPos = TimelineLayout.calculateX( + yCoor: yPos, + width: geometry.size.width, + period: wavePeriod, + amplitude: geometry.size.width * 0.35 + ) + + VStack(spacing: 8) { + // The Orb + ZStack { + AnimatedOrbView(size: 20) + .shadow(color: .orange.opacity(0.4), radius: 15) + } + .matchedGeometryEffect(id: "orb_\(week.weekNumber)", in: animation) + .onTapGesture { + withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { + selectedWeek = week + } + } + + // Label + Text("Week \(week.weekNumber)") + .font(.headline) + .foregroundColor(.white) + .padding(6) + .matchedGeometryEffect(id: "label_\(week.weekNumber)", in: animation) + } + .frame(width: 120, height: 120) + .position(x: xPos, y: yPos) + .id(week.id) // Useful for auto-scrolling + } + } + .frame(width: geometry.size.width, height: contentHeight) + .onAppear { + // Optional: Auto-scroll to the latest week (bottom) + if let last = groupedData.last { + proxy.scrollTo(last.id, anchor: .center) + } + } + } + } + } + } +} diff --git a/Tiny/Timeline/Views/OrbWeekView.swift b/Tiny/Features/Timeline/Views/OrbWeekView.swift similarity index 100% rename from Tiny/Timeline/Views/OrbWeekView.swift rename to Tiny/Features/Timeline/Views/OrbWeekView.swift diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift new file mode 100644 index 0000000..3c8ca1f --- /dev/null +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -0,0 +1,122 @@ +// +// PregnancyTimelineView.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 18/11/25. +// + +import SwiftUI + +struct PregnancyTimelineView: View { + @ObservedObject var heartbeatSoundManager: HeartbeatSoundManager + // ⬇️ NEW: Binding to control close + @Binding var showTimeline: Bool + let onSelectRecording: (Recording) -> Void + + @Namespace private var animation + @State private var selectedWeek: WeekSection? + @State private var groupedData: [WeekSection] = [] + + var body: some View { + ZStack { + LinearGradient(colors: [Color(red: 0.05, green: 0.05, blue: 0.15), Color.black], startPoint: .top, endPoint: .bottom) + .ignoresSafeArea() + + ZStack { + if let week = selectedWeek { + TimelineDetailView(week: week, animation: animation, onSelectRecording: onSelectRecording) + .transition(.opacity) + } else { + MainTimelineListView(groupedData: groupedData, selectedWeek: $selectedWeek, animation: animation) + .transition(.opacity) + } + } + + navigationButtons + } + .onAppear(perform: groupRecordings) + } + + private var navigationButtons: some View { + VStack { + if selectedWeek != nil { + // Back Button (Detail -> List) + HStack { + Button { + withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { selectedWeek = nil } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + .frame(width: 50, height: 50) + .clipShape(Circle()) + } + .glassEffect(.clear) + .matchedGeometryEffect(id: "navButton", in: animation) + .padding(.leading, 20) + .padding(.top, 0) + Spacer() + } + Spacer() + } else { + // ⬇️ Book Button (List -> Close to Orb) + Spacer() + Button { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + showTimeline = false + } + } label: { + Image(systemName: "book.fill").font(.system(size: 28)).foregroundColor(.white).frame(width: 77, height: 77).clipShape(Circle()) + } + .glassEffect(.clear) + .matchedGeometryEffect(id: "navButton", in: animation) + .padding(.bottom, 50) + } + } + .ignoresSafeArea(.all, edges: .bottom) + } + + private func groupRecordings() { + let raw = heartbeatSoundManager.savedRecordings + let calendar = Calendar.current + + let grouped = Dictionary(grouping: raw) { recording -> Int in + return calendar.component(.weekOfYear, from: recording.createdAt) + } + + self.groupedData = grouped.map { + WeekSection(weekNumber: $0.key, recordings: $0.value.sorted(by: { $0.createdAt > $1.createdAt })) + }.sorted(by: { $0.weekNumber < $1.weekNumber }) + } +} + +#Preview { + let mockManager = HeartbeatSoundManager() + + let now = Date() + let week1Date = now + let week2Date = Calendar.current.date(byAdding: .day, value: -7, to: now)! // 1 week ago + let week3Date = Calendar.current.date(byAdding: .day, value: -21, to: now)! // 3 weeks ago + + mockManager.savedRecordings = [ + // Week A (Current Week) + Recording(fileURL: URL(fileURLWithPath: "my-baby-heartbeat.caf"), createdAt: week1Date), + Recording(fileURL: URL(fileURLWithPath: "morning-check.caf"), createdAt: week1Date.addingTimeInterval(-100)), + + // Week B (Last Week) + Recording(fileURL: URL(fileURLWithPath: "late-night-kick.caf"), createdAt: week2Date), + + // Week C (3 Weeks Ago) + Recording(fileURL: URL(fileURLWithPath: "first-time.caf"), createdAt: week3Date), + Recording(fileURL: URL(fileURLWithPath: "doctor-visit.caf"), createdAt: week3Date.addingTimeInterval(-50)) + ] + + return PregnancyTimelineView( + heartbeatSoundManager: mockManager, + showTimeline: .constant(true), + onSelectRecording: { recording in + print("Selected: \(recording.fileURL.lastPathComponent)") + } + ) + .preferredColorScheme(.dark) +} diff --git a/Tiny/Timeline/Views/SaveButtonView.swift b/Tiny/Features/Timeline/Views/SaveButtonView.swift similarity index 95% rename from Tiny/Timeline/Views/SaveButtonView.swift rename to Tiny/Features/Timeline/Views/SaveButtonView.swift index 3cca8f3..3e3dc37 100644 --- a/Tiny/Timeline/Views/SaveButtonView.swift +++ b/Tiny/Features/Timeline/Views/SaveButtonView.swift @@ -25,4 +25,5 @@ struct SaveButtonView: View { Color.black SaveButtonView() } + .ignoresSafeArea() } diff --git a/Tiny/Features/Timeline/Views/TimelineDetailView.swift b/Tiny/Features/Timeline/Views/TimelineDetailView.swift new file mode 100644 index 0000000..a16610d --- /dev/null +++ b/Tiny/Features/Timeline/Views/TimelineDetailView.swift @@ -0,0 +1,177 @@ +// +// TimelineDetailView.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 21/11/25. +// + +import SwiftUI + +struct TimelineDetailView: View { + let week: WeekSection + var animation: Namespace.ID // Passed from parent + let onSelectRecording: (Recording) -> Void + + var body: some View { + GeometryReader { geometry in + VStack(spacing: 0) { + // 1. Header Area (Title + Hero Orb) + headerView + + // 2. The List of Recordings (Glowing Dots) + recordingsScrollView(geometry: geometry) + } + } + } + + private var headerView: some View { + VStack(spacing: 50) { + ZStack { + // Title + Text("Week \(week.weekNumber)") + .font(.system(size: 28, weight: .bold)) + .foregroundColor(.white) + .matchedGeometryEffect(id: "label_\(week.weekNumber)", in: animation) + .padding(.top, 10) + } + .frame(maxWidth: .infinity) +// .frame(height: 60) + + // The "Hero" Orb (Animated from previous screen) + ZStack { + AnimatedOrbView(size: 120) + .shadow(color: .orange.opacity(0.6), radius: 30) + } + .matchedGeometryEffect(id: "orb_\(week.weekNumber)", in: animation) + .frame(height: 140) + .padding(.bottom, 20) + } + } + + private func recordingsScrollView(geometry: GeometryProxy) -> some View { + let recordings = week.recordings + let recSpacing: CGFloat = 100 + let recHeight = max(geometry.size.height - 300, CGFloat(recordings.count) * recSpacing + 200) + + return ScrollView(showsIndicators: false) { + ZStack(alignment: .top) { + // Tighter Wavy Path for details + ContinuousWave( + totalHeight: recHeight, + period: 400, // Faster wave + amplitude: 60 // Smaller width + ) + .stroke( + Color.white.opacity(0.15), + style: StrokeStyle(lineWidth: 1, lineCap: .round) + ) + .frame(width: geometry.size.width, height: recHeight) + + // Glowing Dots (Recordings) + ForEach(Array(recordings.enumerated()), id: \.element.id) { index, recording in + let yPos: CGFloat = 40 + (CGFloat(index) * recSpacing) + let xPos = TimelineLayout.calculateX( + yCoor: yPos, + width: geometry.size.width, + period: 400, + amplitude: 60 + ) + + HStack(spacing: 15) { + // Label Left or Right based on X position + if xPos > geometry.size.width / 2 { + recordingLabel(for: recording) + glowingDot + .onTapGesture { onSelectRecording(recording) } + } else { + glowingDot + .onTapGesture { onSelectRecording(recording) } + recordingLabel(for: recording) + } + } + .frame(width: 300, height: 60) + .position(x: xPos, y: yPos) + } + } + .frame(width: geometry.size.width, height: recHeight) + } + } + + // MARK: - Components + var glowingDot: some View { + ZStack { + Circle().fill(Color.white).frame(width: 8, height: 8) + Circle().stroke(Color.white.opacity(0.5), lineWidth: 1).frame(width: 16, height: 16) + Circle().fill(Color.white.opacity(0.2)).frame(width: 24, height: 24).blur(radius: 4) + } + } + + func recordingLabel(for recording: Recording) -> some View { + let dateName = recording.fileURL.deletingPathExtension().lastPathComponent + let text = formatTimestamp(dateName) + + return Text(text) + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + .padding(6) + .background(Color.black.opacity(0.3)) + .cornerRadius(4) + } + + private func formatTimestamp(_ raw: String) -> String { + let components = raw.split(separator: "-") + if let last = components.last, let timeSecond = TimeInterval(last) { + let date = Date(timeIntervalSince1970: timeSecond) + let formatter = DateFormatter() + formatter.dateFormat = "MMM d, h:mm a" + return formatter.string(from: date) + } + return raw + } +} + +#Preview { + struct PreviewWrapper: View { + @Namespace var animation + + let mockWeek = WeekSection( + weekNumber: 24, + recordings: [ + Recording( + fileURL: URL(fileURLWithPath: "morning-kick.caf"), + createdAt: Date() + ), + Recording( + fileURL: URL(fileURLWithPath: "hiccups.caf"), + createdAt: Date().addingTimeInterval(-3600) // 1 hour ago + ), + Recording( + fileURL: URL(fileURLWithPath: "bedtime.caf"), + createdAt: Date().addingTimeInterval(-7200) // 2 hours ago + ) + ] + ) + + var body: some View { + ZStack { + LinearGradient( + colors: [Color(red: 0.05, green: 0.05, blue: 0.15), Color.black], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + + TimelineDetailView( + week: mockWeek, + animation: animation, + onSelectRecording: { recording in + print("Selected: \(recording.fileURL.lastPathComponent)") + } + ) + } + } + } + + return PreviewWrapper() + .preferredColorScheme(.dark) +} diff --git a/Tiny/Features/Tutorial/ViewModels/TutorialViewModel.swift b/Tiny/Features/Tutorial/ViewModels/TutorialViewModel.swift new file mode 100644 index 0000000..8f5d986 --- /dev/null +++ b/Tiny/Features/Tutorial/ViewModels/TutorialViewModel.swift @@ -0,0 +1,36 @@ +// +// TutorialViewModel.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 25/11/25. +// +import Foundation +internal import Combine + +class TutorialViewModel: ObservableObject { + @Published var activeTutorial: TutorialContext? + + func showInitialTutorialIfNeeded() { + if !UserDefaults.standard.bool(forKey: "hasShownInitialTutorial") { + activeTutorial = .initial + } + } + + func showListeningTutorialIfNeeded() { + if !UserDefaults.standard.bool(forKey: "hasShownListeningTutorial") { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { + self.activeTutorial = .listening + } + } + } + + func dismissTutorial(context: TutorialContext) { + switch context { + case .initial: + UserDefaults.standard.set(true, forKey: "hasShownInitialTutorial") + case .listening: + UserDefaults.standard.set(true, forKey: "hasShownListeningTutorial") + } + activeTutorial = nil + } +} diff --git a/Tiny/Views/TutorialOverlay.swift b/Tiny/Features/Tutorial/Views/TutorialOverlay.swift similarity index 90% rename from Tiny/Views/TutorialOverlay.swift rename to Tiny/Features/Tutorial/Views/TutorialOverlay.swift index 78e517a..8a6c3a9 100644 --- a/Tiny/Views/TutorialOverlay.swift +++ b/Tiny/Features/Tutorial/Views/TutorialOverlay.swift @@ -12,9 +12,9 @@ enum TutorialContext { } struct TutorialOverlay: View { - @Binding var activeTutorial: TutorialContext? + @ObservedObject var viewModel: TutorialViewModel let context: TutorialContext - + var body: some View { ZStack { Color.black.opacity(0.9) @@ -28,16 +28,10 @@ struct TutorialOverlay: View { } } .onTapGesture { - switch context { - case .initial: - UserDefaults.standard.set(true, forKey: "hasShownInitialTutorial") - case .listening: - UserDefaults.standard.set(true, forKey: "hasShownListeningTutorial") - } - activeTutorial = nil + viewModel.dismissTutorial(context: context) } } - + private var initialTutorialView: some View { VStack(spacing: 24) { VStack(spacing: 2) { @@ -132,5 +126,7 @@ struct TutorialOverlay: View { } #Preview { - TutorialOverlay(activeTutorial: .constant(.initial), context: .listening) + let viewModel = TutorialViewModel() + viewModel.activeTutorial = .initial + return TutorialOverlay(viewModel: viewModel, context: .listening) } diff --git a/Tiny/Home/Models/homeModel.swift b/Tiny/Home/Models/homeModel.swift deleted file mode 100644 index 256e856..0000000 --- a/Tiny/Home/Models/homeModel.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// homeModel.swift -// Tiny -// -// Created by Tm Revanza Narendra Pradipta on 29/10/25. -// - -import Foundation - -struct Profile: Identifiable, Codable, Equatable { - var id: String - var avatar: String - var name: String - - init(name: String, avatar: String) { - self.name = name - self.avatar = avatar - self.id = UUID().uuidString - } -} - -struct PregnancyAge: Codable, Equatable { - var ageWeeks: Int - var ageDays: Int -} - -struct HomeData: Codable, Equatable { - let name: String - let profile: Profile - let pregnancyAge: PregnancyAge - - init(name: String, pregnancyAge: PregnancyAge) { - self.name = name - self.profile = Profile(name: name, avatar: "") - self.pregnancyAge = pregnancyAge - - } -} diff --git a/Tiny/Home/ViewModels/homeViewModel.swift b/Tiny/Home/ViewModels/homeViewModel.swift deleted file mode 100644 index 3258543..0000000 --- a/Tiny/Home/ViewModels/homeViewModel.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// homeViewModel.swift -// Tiny -// -// Created by Tm Revanza Narendra Pradipta on 29/10/25. -// - -import Foundation -internal import Combine - -@MainActor -final class HomeViewModel: ObservableObject { - @Published private var homeData: HomeData - - init(homeData: HomeData) { - self.homeData = homeData - } - - var displayName: String { - homeData.name - } - var displayAgeWeeks: Int { - homeData.pregnancyAge.ageWeeks - } - var displayAgeDays: Int { - homeData.pregnancyAge.ageDays - } -} diff --git a/Tiny/Home/Views/HomeView.swift b/Tiny/Home/Views/HomeView.swift deleted file mode 100644 index 173f485..0000000 --- a/Tiny/Home/Views/HomeView.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// HomeView.swift -// Tiny -// -// Created by Tm Revanza Narendra Pradipta on 30/10/25. -// - -import SwiftUI - -struct HomeView: View { - @StateObject var homeVM: HomeViewModel - - var body: some View { - VStack(spacing: 40) { - HStack { - Text("Hello, Mrs. \(homeVM.displayName)") - .font(.title) - .bold() - .foregroundColor(Color(hex: "141414")) - - Spacer() - - Image(systemName: "person.circle.fill") - .font(.title) - } - - PregnancyAgeView(ageWeeks: homeVM.displayAgeWeeks, ageDays: homeVM.displayAgeDays) - - VStack(alignment: .leading, spacing: 20) { - Text("Today, I want to") - .font(.title3) - .bold() - - FeatureCardGroup() - } - .frame(maxWidth: .infinity) - - } - .padding(.horizontal, 16) - .padding(.top, 10) - - Spacer() - } -} - -#Preview { - let age = PregnancyAge(ageWeeks: 12, ageDays: 4) - let homeData = HomeData(name: "Mil", pregnancyAge: age) - HomeView(homeVM: .init(homeData: homeData)) -} diff --git a/Tiny/Library/LibraryDetailView.swift b/Tiny/Library/LibraryDetailView.swift deleted file mode 100644 index 673ca54..0000000 --- a/Tiny/Library/LibraryDetailView.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// LibraryDetailView.swift -// Tiny -// - -import SwiftUI - -struct LibraryDetailView: View { - let library: LibraryModel - - var body: some View { - VStack(spacing: 16) { - Text(library.name) - .font(.largeTitle.bold()) - - Text("Week \(library.week)") - .font(.headline) - .foregroundColor(.secondary) - - Text("Contains \(library.clipCount) clips") - .font(.subheadline) - - Spacer() - } - .padding() - .navigationTitle(library.name) - .navigationBarTitleDisplayMode(.inline) - } -} - -#Preview { - LibraryDetailView( - library: LibraryModel( - imageURL: ["librarySample1", "librarySample2", "librarySample3"], - id: UUID().uuidString, - name: "Library One", - week: 3, - clipCount: 4 - ) - ) -} diff --git a/Tiny/Library/LibraryModel.swift b/Tiny/Library/LibraryModel.swift deleted file mode 100644 index 2a6ea12..0000000 --- a/Tiny/Library/LibraryModel.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// LibraryModel.swift -// Tiny -// -// Created by Destu Cikal Ramdani on 02/11/25. -// - -import Foundation - -struct LibraryModel: Codable, Hashable, Identifiable { - let imageURL: [String] - let id: String - let name: String - let week: Int - let clipCount: Int -} - -extension LibraryModel { - static let dummyData: [LibraryModel] = [ - LibraryModel( - imageURL: ["librarySample1", "librarySample2", "librarySample3"], // from Assets.xcassets - id: UUID().uuidString, - name: "Week 12 Recordings", - week: 12, - clipCount: 4 - ), - LibraryModel( - imageURL: ["librarySample4", "librarySample5"], - id: UUID().uuidString, - name: "Week 13 Recordings", - week: 13, - clipCount: 2 - ), - LibraryModel( - imageURL: ["librarySample6"], - id: UUID().uuidString, - name: "Week 14 Recordings", - week: 14, - clipCount: 5 - ) - ] -} diff --git a/Tiny/Library/LibraryView.swift b/Tiny/Library/LibraryView.swift deleted file mode 100644 index bed4d10..0000000 --- a/Tiny/Library/LibraryView.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// LibraryView.swift -// Tiny -// -// Created by Destu Cikal Ramdani on 02/11/25. -// - -import SwiftUI - -struct LibraryView: View { - @State private var searchText: String = "" - @State private var selectedFilter: FilterOption = .all - @State private var path: [LibraryModel] = [] - private let libraries = LibraryModel.dummyData - - private let columns = [ - GridItem(.flexible(), spacing: 16), - GridItem(.flexible(), spacing: 16) - ] - - enum FilterOption: String, CaseIterable { - case all = "All" - case mostRecent = "Most Recent" - case oldest = "Oldest" - case mostClips = "Most Clips" - } - - var filteredLibraries: [LibraryModel] { - var result = libraries.filter { library in - searchText.isEmpty || library.name.localizedCaseInsensitiveContains(searchText) - } - - // Apply sorting based on filter - switch selectedFilter { - case .all: - break // Keep original order - case .mostRecent: - result.sort { $0.week > $1.week } - case .oldest: - result.sort { $0.week < $1.week } - case .mostClips: - result.sort { $0.clipCount > $1.clipCount } - } - - return result - } - - var body: some View { - NavigationStack(path: $path) { - VStack(spacing: 0) { - // Search bar and Filter in HStack - HStack(spacing: 12) { - // Search bar - HStack { - Image(systemName: "magnifyingglass") - .foregroundColor(.gray) - - TextField("Search library", text: $searchText) - .textFieldStyle(.plain) - - if !searchText.isEmpty { - Button(action: { - searchText = "" - }, label: { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.gray) - }) - } - } - .padding(10) - .background(Color(.systemGray6)) - .cornerRadius(10) - - // Filter button - Menu { - ForEach(FilterOption.allCases, id: \.self) { option in - Button(action: { - selectedFilter = option - }, label: { - HStack { - Text(option.rawValue) - if selectedFilter == option { - Image(systemName: "checkmark") - } - } - }) - } - } label: { - HStack(spacing: 4) { - Image(systemName: "line.3.horizontal.decrease.circle") - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(Color(.systemGray6)) - .cornerRadius(10) - } - } - .padding(.horizontal) - .padding(.top, 8) - .padding(.bottom, 12) - - // Content - ScrollView { - LazyVGrid(columns: columns, spacing: 16) { - ForEach(filteredLibraries) { library in - FolderShapeButton(library: library) { - path.append(library) - } - } - } - .padding() - } - } - .navigationTitle("My Library") - .navigationBarTitleDisplayMode(.large) - .navigationDestination(for: LibraryModel.self) { library in - LibraryDetailView(library: library) - } - } - } -} - -#Preview { - LibraryView() -} diff --git a/Tiny/LiveListen/Models/liveListenModel.swift b/Tiny/LiveListen/Models/liveListenModel.swift deleted file mode 100644 index 2a0d19d..0000000 --- a/Tiny/LiveListen/Models/liveListenModel.swift +++ /dev/null @@ -1,6 +0,0 @@ -// -// Swift.swift -// tiny -// -// Created by Destu Cikal Ramdani on 27/10/25. -// diff --git a/Tiny/LiveListen/ViewModels/liveListenViewModel.swift b/Tiny/LiveListen/ViewModels/liveListenViewModel.swift deleted file mode 100644 index 2a0d19d..0000000 --- a/Tiny/LiveListen/ViewModels/liveListenViewModel.swift +++ /dev/null @@ -1,6 +0,0 @@ -// -// Swift.swift -// tiny -// -// Created by Destu Cikal Ramdani on 27/10/25. -// diff --git a/Tiny/LiveListen/Views/TestingViewLiveListen.swift b/Tiny/LiveListen/Views/TestingViewLiveListen.swift deleted file mode 100644 index a574673..0000000 --- a/Tiny/LiveListen/Views/TestingViewLiveListen.swift +++ /dev/null @@ -1,179 +0,0 @@ -// -// TestingViewLiveListen.swift -// Tiny -// -// Created by Benedictus Yogatama Favian Satyajati on 30/10/25. -// - -import SwiftUI - -struct TestingLiveListenView: View { - @StateObject private var manager = HeartbeatSoundManager() - @State private var showShareSheet = false - - var body: some View { - NavigationView { - VStack(spacing: 20) { - HStack { - Circle().fill(manager.isRunning ? Color.green : Color.red).frame(width: 20, height: 20) - Text(manager.isRunning ? "Listening" : "Stopped").font(.headline) - } - - // Amplitude display - VStack(spacing: 10) { - Text("Signal Strength") - .font(.caption) - .foregroundColor(.secondary) - Text(String(format: "%.3f", manager.amplitudeVal)) - .font(.system(size: 30, weight: .semibold, design: .monospaced)) - .onChange(of: manager.amplitudeVal) { _, newValue in - print("UI received amplitude update: \(newValue)") - } - - // Visual amplitude bar - GeometryReader { geometry in - ZStack(alignment: .leading) { - Rectangle() - .fill(Color.gray.opacity(0.2)) - Rectangle() - .fill(Color.blue) - .frame(width: geometry.size.width * CGFloat(min(manager.amplitudeVal * 10, 1.0))) - } - } - .frame(height: 20) - .cornerRadius(10) - } - .padding() - .background(Color.secondary.opacity(0.1)) - .cornerRadius(15) - - // Gain control (only show when not playing) - if !manager.isPlaying { - VStack(spacing: 10) { - Text("Amplification: \(String(format: "%.1f", manager.gainVal))x") - .font(.headline) - - Slider(value: Binding( - get: { manager.gainVal }, - set: { manager.updateGain($0) } - ), in: 1...50, step: 1.0) // Changed from 1...20 to 1...50 - .accentColor(.blue) - - HStack { - Text("1x") - .font(.caption) - Spacer() - Text("50x") // Update to match new range - .font(.caption) - } - .foregroundColor(.secondary) - } - .padding() - .background(Color.secondary.opacity(0.1)) - .cornerRadius(15) - } - - // Last recording playback - if let recording = manager.lastRecording { - VStack(spacing: 10) { - Text("Last Recording") - .font(.headline) - Text(recording.fileURL.lastPathComponent) - .font(.caption) - .lineLimit(1) - - Button(action: { - manager.togglePlayback(recording: recording) - }, label: { - Label( - manager.isPlayingPlayback ? "Stop Playback" : "Play Recording", - systemImage: manager.isPlayingPlayback ? "stop.fill" : "play.fill" - ) - .font(.headline) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding() - .background(Color.orange) - .cornerRadius(15) - }) - - Button(action: { - self.showShareSheet = true - }, label: { - Label("Share Recording", systemImage: "square.and.arrow.up") - .font(.headline) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding() - .background(Color.accentColor) - .cornerRadius(15) - }) - .sheet(isPresented: $showShareSheet) { - ShareSheet(activityItems: [recording.fileURL]) - } - } - .padding() - .background(Color.secondary.opacity(0.1)) - .cornerRadius(15) - } - - Spacer() - - VStack(spacing: 15) { - // Recording control button - Button(action: { - if manager.isRecording { - manager.stopRecording() - } else { - manager.startRecording() - } - }, label: { - Label(manager.isRecording ? "Stop Recording" : "Start Recording", systemImage: "mic.circle.fill") - .font(.headline) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding() - .background(manager.isRecording ? Color.yellow.opacity(0.8) : Color.blue) - .cornerRadius(15) - }) - .disabled(!manager.isRunning) - - // Main control buttons - HStack(spacing: 20) { - Button(action: { - manager.start() - }, label: { - Label("Start", systemImage: "play.fill") - .font(.headline) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding() - .background(Color.green) - .cornerRadius(15) - }) - .disabled(manager.isRunning) - - Button(action: { - manager.stop() - }, label: { - Label("Stop", systemImage: "stop.fill") - .font(.headline) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding() - .background(Color.red) - .cornerRadius(15) - }) - .disabled(!manager.isRunning) - } - } - } - .padding() - .navigationBarHidden(true) - } - } -} - -// #Preview { -// TestingViewLiveListen() -// } diff --git a/Tiny/LiveListen/Views/liveListenViews.swift b/Tiny/LiveListen/Views/liveListenViews.swift deleted file mode 100644 index 2a0d19d..0000000 --- a/Tiny/LiveListen/Views/liveListenViews.swift +++ /dev/null @@ -1,6 +0,0 @@ -// -// Swift.swift -// tiny -// -// Created by Destu Cikal Ramdani on 27/10/25. -// diff --git a/Tiny/Managers/HeartbeatDetector.swift b/Tiny/Managers/HeartbeatDetector.swift deleted file mode 100644 index 62b4f0c..0000000 --- a/Tiny/Managers/HeartbeatDetector.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// HeartbeatDetector.swift -// Tiny -// -// Created by Benedictus Yogatama Favian Satyajati on 30/10/25. -// - -import Foundation - -class HeartbeatDetector { - private var heartbeatData: [HeartbeatData] = [] - - func detectHeartbeat(from fftData: [Float]) -> HeartbeatData? { - guard !fftData.isEmpty else { return nil } - - let s1Range = 20...50 - let s2Range = 50...80 - - let s1Amplitude = calculateAverageAmplitude(in: s1Range, from: fftData) - let s2Amplitude = calculateAverageAmplitude(in: s2Range, from: fftData) - - let confidence = calculateHeartbeatConfidence(s1Amplitude: s1Amplitude, s2Amplitude: s2Amplitude) - - if confidence > 0.3 { - let bpm = estimateBPM() - let heartbeat = HeartbeatData( - timestamp: Date(), - bpm: bpm, - s1Amplitude: s1Amplitude, - s2Amplitude: s2Amplitude, - confidence: confidence - ) - - // Track heartbeat for BPM estimation - heartbeatData.append(heartbeat) - if heartbeatData.count > 10 { - heartbeatData.removeFirst() - } - - return heartbeat - } - - return nil - } - - private func calculateAverageAmplitude(in range: ClosedRange, from fftData: [Float]) -> Float { - guard fftData.count > range.upperBound else { return 0.0 } - - let startIndex = max(0, range.lowerBound) - let endIndex = min(fftData.count, range.upperBound) - - let slice = fftData[startIndex.. Float { - let ratio = s2Amplitude / max(s1Amplitude, 0.001) - let idealRatio: Float = 0.6 - let ratioScore = 1.0 - abs(ratio - idealRatio) - - let amplitudeScore = min(1.0, (s1Amplitude + s2Amplitude) / 2.0) - - return (ratioScore * 0.7 + amplitudeScore * 0.3) - } - - private func estimateBPM() -> Double { - guard heartbeatData.count >= 2 else { return 0.0 } - - let recentData = Array(heartbeatData.suffix(5)) - guard recentData.count >= 2 else { return 0.0 } - - let timeDifferences = zip(recentData.dropFirst(), recentData.dropLast()) - .map { newer, older in - newer.timestamp.timeIntervalSince(older.timestamp) - } - - let averageInterval = timeDifferences.reduce(0, +) / Double(timeDifferences.count) - - return averageInterval > 0 ? 60.0 / averageInterval : 0.0 - } -} diff --git a/Tiny/Onboarding/Views/onboardingView.swift b/Tiny/Onboarding/Views/onboardingView.swift deleted file mode 100644 index 2b5ca84..0000000 --- a/Tiny/Onboarding/Views/onboardingView.swift +++ /dev/null @@ -1,173 +0,0 @@ -// -// onBoardingView.swift -// tiny -// -// Created by Destu Cikal Ramdani on 27/10/25. -// - -import SwiftUI -import UIKit - -struct OnBoardingView: View { - @Binding var hasShownOnboarding: Bool - - var body: some View { - ZStack { - Image("background") - .resizable() - .ignoresSafeArea() - - TabView { - OnboardingPage1() - .ignoresSafeArea() - OnboardingPage2(hasShownOnboarding: $hasShownOnboarding) // Pass it to page 2 - .ignoresSafeArea() - } - .tabViewStyle(.page) - } - .ignoresSafeArea() - } -} - -private struct OnboardingPage1: View { - @State private var scanOffset: CGFloat = -40 // Start left - @State private var rotation: Double = -5 // Small tilt - - var titleText: AttributedString { - var string = AttributedString("What can you do with tiny?") - if let range = string.range(of: "tiny") { - string[range].foregroundColor = Color("mainYellow") - } - return string - } - - var body: some View { - ZStack { - Image("bgOnboarding1") - .scaledToFill() - .offset(y: 130) - - VStack(spacing: 16) { - - ZStack { - VStack { - Image("handHoldingPhone") - .offset(x: scanOffset) - .rotationEffect(.degrees(rotation)) - .onAppear { - withAnimation( - .easeInOut(duration: 2.4) - .repeatForever(autoreverses: true) - ) { - scanOffset = 40 // move right - rotation = 5 // tilt to the right - } - } - - Image("stomach") - } - } - - Text(titleText) - .font(.title) - .fontWeight(.bold) - .padding(.top, 20) - - Text("You can listen to your baby's heartbeat live and record it to listen again later") - .font(.body) - .fontWeight(.medium) - .multilineTextAlignment(.center) - .padding(.horizontal, 30) - } - } - } -} - -private struct OnboardingPage2: View { - @Binding var hasShownOnboarding: Bool // Add this line - @StateObject private var manager = HeartbeatSoundManager() - @State private var showDeniedAlert = false - - var titleText: AttributedString { - var string = AttributedString("Feel the best experience") - if let range = string.range(of: "best") { - string[range].foregroundColor = Color("mainYellow") - } - return string - } - - var body: some View { - ZStack { - Image("bgOnboarding2") - .scaledToFill() - .offset(y: -340) - - VStack { - HStack { - Image(systemName: "airpod.gen3.right") - .font(.system(size: 80)) - .rotationEffect(.degrees(-10)) - - Image(systemName: "airpod.gen3.left") - .font(.system(size: 80)) - .rotationEffect(.degrees(10)) - .offset(y: 10) - } - .mask( - LinearGradient( - gradient: Gradient(colors: [ - .white, - .white.opacity(0.3) - ]), - startPoint: .top, - endPoint: .bottom - ) - ) - - Text(titleText) - .font(.title) - .fontWeight(.bold) - .padding() - - Text("Tiny will need access to your microphone so you can hear every tiny beat clearly") - .font(.body) - .fontWeight(.medium) - .multilineTextAlignment(.center) - - Button(action: { - manager.requestMicrophonePermission { granted in - if granted { - print("Permission granted") - hasShownOnboarding = true // Dismiss onboarding when permission is granted - } else { - showDeniedAlert = true - } - } - }, label: { - Text("Let's go") - .font(.headline) - .fontWeight(.semibold) - .padding(.vertical, 14) - .padding(.horizontal, 40) - .foregroundColor(.white) - .glassEffect() - }) - .padding(.top, 20) - .alert("Microphone Access Denied", isPresented: $showDeniedAlert) { - Button("OK", role: .cancel) { } - Button("Open Settings") { - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - } - } message: { - Text("Please enable microphone access in Settings to use this feature.") - } - } - } - } -} - -#Preview { - OnBoardingView(hasShownOnboarding: .constant(false)) -} diff --git a/Tiny/Playback/Models/playbackModel.swift b/Tiny/Playback/Models/playbackModel.swift deleted file mode 100644 index 2a0d19d..0000000 --- a/Tiny/Playback/Models/playbackModel.swift +++ /dev/null @@ -1,6 +0,0 @@ -// -// Swift.swift -// tiny -// -// Created by Destu Cikal Ramdani on 27/10/25. -// diff --git a/Tiny/Playback/ViewModels/playbackViewModel.swift b/Tiny/Playback/ViewModels/playbackViewModel.swift deleted file mode 100644 index 2a0d19d..0000000 --- a/Tiny/Playback/ViewModels/playbackViewModel.swift +++ /dev/null @@ -1,6 +0,0 @@ -// -// Swift.swift -// tiny -// -// Created by Destu Cikal Ramdani on 27/10/25. -// diff --git a/Tiny/Playback/Views/playbackView.swift b/Tiny/Playback/Views/playbackView.swift deleted file mode 100644 index 2a0d19d..0000000 --- a/Tiny/Playback/Views/playbackView.swift +++ /dev/null @@ -1,6 +0,0 @@ -// -// Swift.swift -// tiny -// -// Created by Destu Cikal Ramdani on 27/10/25. -// diff --git a/Tiny/Profile/Models/profileModel.swift b/Tiny/Profile/Models/profileModel.swift deleted file mode 100644 index 2a0d19d..0000000 --- a/Tiny/Profile/Models/profileModel.swift +++ /dev/null @@ -1,6 +0,0 @@ -// -// Swift.swift -// tiny -// -// Created by Destu Cikal Ramdani on 27/10/25. -// diff --git a/Tiny/Profile/ViewModels/profileViewModel.swift b/Tiny/Profile/ViewModels/profileViewModel.swift deleted file mode 100644 index 2a0d19d..0000000 --- a/Tiny/Profile/ViewModels/profileViewModel.swift +++ /dev/null @@ -1,6 +0,0 @@ -// -// Swift.swift -// tiny -// -// Created by Destu Cikal Ramdani on 27/10/25. -// diff --git a/Tiny/Profile/Views/profileView.swift b/Tiny/Profile/Views/profileView.swift deleted file mode 100644 index 2a0d19d..0000000 --- a/Tiny/Profile/Views/profileView.swift +++ /dev/null @@ -1,6 +0,0 @@ -// -// Swift.swift -// tiny -// -// Created by Destu Cikal Ramdani on 27/10/25. -// diff --git a/Tiny/Resource/onboarding1.gif b/Tiny/Resource/onboarding1.gif deleted file mode 100644 index 1d0eeed..0000000 Binary files a/Tiny/Resource/onboarding1.gif and /dev/null differ diff --git a/Tiny/Assets.xcassets/AccentColor.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/AccentColor.colorset/Contents.json rename to Tiny/Resources/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Tiny/Assets.xcassets/AppIcon.appiconset/1024.png b/Tiny/Resources/Assets.xcassets/AppIcon.appiconset/1024.png similarity index 100% rename from Tiny/Assets.xcassets/AppIcon.appiconset/1024.png rename to Tiny/Resources/Assets.xcassets/AppIcon.appiconset/1024.png diff --git a/Tiny/Assets.xcassets/AppIcon.appiconset/Contents.json b/Tiny/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/AppIcon.appiconset/Contents.json rename to Tiny/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/Tiny/Assets.xcassets/Bar.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/Bar.colorset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/Bar.colorset/Contents.json rename to Tiny/Resources/Assets.xcassets/Bar.colorset/Contents.json diff --git a/Tiny/Assets.xcassets/Body.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/Body.colorset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/Body.colorset/Contents.json rename to Tiny/Resources/Assets.xcassets/Body.colorset/Contents.json diff --git a/Tiny/Assets.xcassets/Contents.json b/Tiny/Resources/Assets.xcassets/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/Contents.json rename to Tiny/Resources/Assets.xcassets/Contents.json diff --git a/Tiny/Assets.xcassets/Heartbeat.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/Heartbeat.imageset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/Heartbeat.imageset/Contents.json rename to Tiny/Resources/Assets.xcassets/Heartbeat.imageset/Contents.json diff --git a/Tiny/Assets.xcassets/Heartbeat.imageset/Group 20.svg b/Tiny/Resources/Assets.xcassets/Heartbeat.imageset/Group 20.svg similarity index 100% rename from Tiny/Assets.xcassets/Heartbeat.imageset/Group 20.svg rename to Tiny/Resources/Assets.xcassets/Heartbeat.imageset/Group 20.svg diff --git a/Tiny/Assets.xcassets/background.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/background.imageset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/background.imageset/Contents.json rename to Tiny/Resources/Assets.xcassets/background.imageset/Contents.json diff --git a/Tiny/Assets.xcassets/background.imageset/Illustration35 4.png b/Tiny/Resources/Assets.xcassets/background.imageset/Illustration35 4.png similarity index 100% rename from Tiny/Assets.xcassets/background.imageset/Illustration35 4.png rename to Tiny/Resources/Assets.xcassets/background.imageset/Illustration35 4.png diff --git a/Tiny/Assets.xcassets/backgroundDarkDummy.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/backgroundDarkDummy.imageset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/backgroundDarkDummy.imageset/Contents.json rename to Tiny/Resources/Assets.xcassets/backgroundDarkDummy.imageset/Contents.json diff --git a/Tiny/Assets.xcassets/backgroundDarkDummy.imageset/background-dark 1.png b/Tiny/Resources/Assets.xcassets/backgroundDarkDummy.imageset/background-dark 1.png similarity index 100% rename from Tiny/Assets.xcassets/backgroundDarkDummy.imageset/background-dark 1.png rename to Tiny/Resources/Assets.xcassets/backgroundDarkDummy.imageset/background-dark 1.png diff --git a/Tiny/Assets.xcassets/backgroundDarkDummy.imageset/background-dark 2.png b/Tiny/Resources/Assets.xcassets/backgroundDarkDummy.imageset/background-dark 2.png similarity index 100% rename from Tiny/Assets.xcassets/backgroundDarkDummy.imageset/background-dark 2.png rename to Tiny/Resources/Assets.xcassets/backgroundDarkDummy.imageset/background-dark 2.png diff --git a/Tiny/Assets.xcassets/backgroundDarkDummy.imageset/background-dark.png b/Tiny/Resources/Assets.xcassets/backgroundDarkDummy.imageset/background-dark.png similarity index 100% rename from Tiny/Assets.xcassets/backgroundDarkDummy.imageset/background-dark.png rename to Tiny/Resources/Assets.xcassets/backgroundDarkDummy.imageset/background-dark.png diff --git a/Tiny/Resources/Assets.xcassets/backgroundPurple.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/backgroundPurple.imageset/Contents.json new file mode 100644 index 0000000..c35df9a --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/backgroundPurple.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "backgroundPurple.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/backgroundPurple.imageset/backgroundPurple.png b/Tiny/Resources/Assets.xcassets/backgroundPurple.imageset/backgroundPurple.png new file mode 100644 index 0000000..785f725 Binary files /dev/null and b/Tiny/Resources/Assets.xcassets/backgroundPurple.imageset/backgroundPurple.png differ diff --git a/Tiny/Assets.xcassets/bgOnboarding1.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/bgOnboarding1.imageset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/bgOnboarding1.imageset/Contents.json rename to Tiny/Resources/Assets.xcassets/bgOnboarding1.imageset/Contents.json diff --git a/Tiny/Assets.xcassets/bgOnboarding1.imageset/bgOnboarding1.png b/Tiny/Resources/Assets.xcassets/bgOnboarding1.imageset/bgOnboarding1.png similarity index 100% rename from Tiny/Assets.xcassets/bgOnboarding1.imageset/bgOnboarding1.png rename to Tiny/Resources/Assets.xcassets/bgOnboarding1.imageset/bgOnboarding1.png diff --git a/Tiny/Assets.xcassets/bgOnboarding2.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/bgOnboarding2.imageset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/bgOnboarding2.imageset/Contents.json rename to Tiny/Resources/Assets.xcassets/bgOnboarding2.imageset/Contents.json diff --git a/Tiny/Assets.xcassets/bgOnboarding2.imageset/bgOnboarding2.png b/Tiny/Resources/Assets.xcassets/bgOnboarding2.imageset/bgOnboarding2.png similarity index 100% rename from Tiny/Assets.xcassets/bgOnboarding2.imageset/bgOnboarding2.png rename to Tiny/Resources/Assets.xcassets/bgOnboarding2.imageset/bgOnboarding2.png diff --git a/Tiny/Resources/Assets.xcassets/bgPurpleOnboarding.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/bgPurpleOnboarding.imageset/Contents.json new file mode 100644 index 0000000..75794b4 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/bgPurpleOnboarding.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bgPurpleOnboarding.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/bgPurpleOnboarding.imageset/bgPurpleOnboarding.png b/Tiny/Resources/Assets.xcassets/bgPurpleOnboarding.imageset/bgPurpleOnboarding.png new file mode 100644 index 0000000..7f83b8d Binary files /dev/null and b/Tiny/Resources/Assets.xcassets/bgPurpleOnboarding.imageset/bgPurpleOnboarding.png differ diff --git a/Tiny/Resources/Assets.xcassets/bgSplashScreen.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/bgSplashScreen.imageset/Contents.json new file mode 100644 index 0000000..eae289d --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/bgSplashScreen.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "bgSplashScreen.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/bgSplashScreen.imageset/bgSplashScreen.png b/Tiny/Resources/Assets.xcassets/bgSplashScreen.imageset/bgSplashScreen.png new file mode 100644 index 0000000..1b51c28 Binary files /dev/null and b/Tiny/Resources/Assets.xcassets/bgSplashScreen.imageset/bgSplashScreen.png differ diff --git a/Tiny/Assets.xcassets/bodyDetail.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/bodyDetail.colorset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/bodyDetail.colorset/Contents.json rename to Tiny/Resources/Assets.xcassets/bodyDetail.colorset/Contents.json diff --git a/Tiny/Assets.xcassets/folderBackground.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/folderBackground.colorset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/folderBackground.colorset/Contents.json rename to Tiny/Resources/Assets.xcassets/folderBackground.colorset/Contents.json diff --git a/Tiny/Assets.xcassets/gradientEnd.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/gradientEnd.colorset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/gradientEnd.colorset/Contents.json rename to Tiny/Resources/Assets.xcassets/gradientEnd.colorset/Contents.json diff --git a/Tiny/Assets.xcassets/gradientStart.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/gradientStart.colorset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/gradientStart.colorset/Contents.json rename to Tiny/Resources/Assets.xcassets/gradientStart.colorset/Contents.json diff --git a/Tiny/Assets.xcassets/handHoldingPhone.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/handHoldingPhone.imageset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/handHoldingPhone.imageset/Contents.json rename to Tiny/Resources/Assets.xcassets/handHoldingPhone.imageset/Contents.json diff --git a/Tiny/Assets.xcassets/handHoldingPhone.imageset/handHoldingPhone.png b/Tiny/Resources/Assets.xcassets/handHoldingPhone.imageset/handHoldingPhone.png similarity index 100% rename from Tiny/Assets.xcassets/handHoldingPhone.imageset/handHoldingPhone.png rename to Tiny/Resources/Assets.xcassets/handHoldingPhone.imageset/handHoldingPhone.png diff --git a/Tiny/Assets.xcassets/heartSearch.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/heartSearch.imageset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/heartSearch.imageset/Contents.json rename to Tiny/Resources/Assets.xcassets/heartSearch.imageset/Contents.json diff --git a/Tiny/Assets.xcassets/heartSearch.imageset/heartAnimationListening.svg b/Tiny/Resources/Assets.xcassets/heartSearch.imageset/heartAnimationListening.svg similarity index 100% rename from Tiny/Assets.xcassets/heartSearch.imageset/heartAnimationListening.svg rename to Tiny/Resources/Assets.xcassets/heartSearch.imageset/heartAnimationListening.svg diff --git a/Tiny/Assets.xcassets/librarySample1.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/librarySample1.imageset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/librarySample1.imageset/Contents.json rename to Tiny/Resources/Assets.xcassets/librarySample1.imageset/Contents.json diff --git a/Tiny/Assets.xcassets/librarySample1.imageset/librarySample1.jpg b/Tiny/Resources/Assets.xcassets/librarySample1.imageset/librarySample1.jpg similarity index 100% rename from Tiny/Assets.xcassets/librarySample1.imageset/librarySample1.jpg rename to Tiny/Resources/Assets.xcassets/librarySample1.imageset/librarySample1.jpg diff --git a/Tiny/Assets.xcassets/librarySample2.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/librarySample2.imageset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/librarySample2.imageset/Contents.json rename to Tiny/Resources/Assets.xcassets/librarySample2.imageset/Contents.json diff --git a/Tiny/Assets.xcassets/librarySample2.imageset/librarySample2.jpg b/Tiny/Resources/Assets.xcassets/librarySample2.imageset/librarySample2.jpg similarity index 100% rename from Tiny/Assets.xcassets/librarySample2.imageset/librarySample2.jpg rename to Tiny/Resources/Assets.xcassets/librarySample2.imageset/librarySample2.jpg diff --git a/Tiny/Assets.xcassets/librarySample3.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/librarySample3.imageset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/librarySample3.imageset/Contents.json rename to Tiny/Resources/Assets.xcassets/librarySample3.imageset/Contents.json diff --git a/Tiny/Assets.xcassets/librarySample3.imageset/librarySample3.jpeg b/Tiny/Resources/Assets.xcassets/librarySample3.imageset/librarySample3.jpeg similarity index 100% rename from Tiny/Assets.xcassets/librarySample3.imageset/librarySample3.jpeg rename to Tiny/Resources/Assets.xcassets/librarySample3.imageset/librarySample3.jpeg diff --git a/Tiny/Assets.xcassets/librarySample4.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/librarySample4.imageset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/librarySample4.imageset/Contents.json rename to Tiny/Resources/Assets.xcassets/librarySample4.imageset/Contents.json diff --git a/Tiny/Assets.xcassets/librarySample4.imageset/librarySample4.jpg b/Tiny/Resources/Assets.xcassets/librarySample4.imageset/librarySample4.jpg similarity index 100% rename from Tiny/Assets.xcassets/librarySample4.imageset/librarySample4.jpg rename to Tiny/Resources/Assets.xcassets/librarySample4.imageset/librarySample4.jpg diff --git a/Tiny/Assets.xcassets/librarySample5.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/librarySample5.imageset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/librarySample5.imageset/Contents.json rename to Tiny/Resources/Assets.xcassets/librarySample5.imageset/Contents.json diff --git a/Tiny/Assets.xcassets/librarySample5.imageset/librarySample5.jpg b/Tiny/Resources/Assets.xcassets/librarySample5.imageset/librarySample5.jpg similarity index 100% rename from Tiny/Assets.xcassets/librarySample5.imageset/librarySample5.jpg rename to Tiny/Resources/Assets.xcassets/librarySample5.imageset/librarySample5.jpg diff --git a/Tiny/Assets.xcassets/librarySample6.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/librarySample6.imageset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/librarySample6.imageset/Contents.json rename to Tiny/Resources/Assets.xcassets/librarySample6.imageset/Contents.json diff --git a/Tiny/Assets.xcassets/librarySample6.imageset/librarySample6.jpg b/Tiny/Resources/Assets.xcassets/librarySample6.imageset/librarySample6.jpg similarity index 100% rename from Tiny/Assets.xcassets/librarySample6.imageset/librarySample6.jpg rename to Tiny/Resources/Assets.xcassets/librarySample6.imageset/librarySample6.jpg diff --git a/Tiny/Resources/Assets.xcassets/lineOnboarding.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/lineOnboarding.imageset/Contents.json new file mode 100644 index 0000000..6fb030d --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/lineOnboarding.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "linePath.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/lineOnboarding.imageset/linePath.png b/Tiny/Resources/Assets.xcassets/lineOnboarding.imageset/linePath.png new file mode 100644 index 0000000..1e3d37a Binary files /dev/null and b/Tiny/Resources/Assets.xcassets/lineOnboarding.imageset/linePath.png differ diff --git a/Tiny/Assets.xcassets/mainYellow.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/mainYellow.colorset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/mainYellow.colorset/Contents.json rename to Tiny/Resources/Assets.xcassets/mainYellow.colorset/Contents.json diff --git a/Tiny/Resources/Assets.xcassets/onboardingShareMood.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/onboardingShareMood.imageset/Contents.json new file mode 100644 index 0000000..2a497c7 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/onboardingShareMood.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "on boarding share mood.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/onboardingShareMood.imageset/on boarding share mood.png b/Tiny/Resources/Assets.xcassets/onboardingShareMood.imageset/on boarding share mood.png new file mode 100644 index 0000000..1901a11 Binary files /dev/null and b/Tiny/Resources/Assets.xcassets/onboardingShareMood.imageset/on boarding share mood.png differ diff --git a/Tiny/Assets.xcassets/orbLightYellow.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/orbLightYellow.colorset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/orbLightYellow.colorset/Contents.json rename to Tiny/Resources/Assets.xcassets/orbLightYellow.colorset/Contents.json diff --git a/Tiny/Assets.xcassets/orbOrange.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/orbOrange.colorset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/orbOrange.colorset/Contents.json rename to Tiny/Resources/Assets.xcassets/orbOrange.colorset/Contents.json diff --git a/Tiny/Assets.xcassets/soundMemo.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/soundMemo.imageset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/soundMemo.imageset/Contents.json rename to Tiny/Resources/Assets.xcassets/soundMemo.imageset/Contents.json diff --git "a/Tiny/Assets.xcassets/soundMemo.imageset/\364\201\203\250.svg" "b/Tiny/Resources/Assets.xcassets/soundMemo.imageset/\364\201\203\250.svg" similarity index 100% rename from "Tiny/Assets.xcassets/soundMemo.imageset/\364\201\203\250.svg" rename to "Tiny/Resources/Assets.xcassets/soundMemo.imageset/\364\201\203\250.svg" diff --git a/Tiny/Assets.xcassets/stomach.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/stomach.imageset/Contents.json similarity index 100% rename from Tiny/Assets.xcassets/stomach.imageset/Contents.json rename to Tiny/Resources/Assets.xcassets/stomach.imageset/Contents.json diff --git a/Tiny/Assets.xcassets/stomach.imageset/stomach.png b/Tiny/Resources/Assets.xcassets/stomach.imageset/stomach.png similarity index 100% rename from Tiny/Assets.xcassets/stomach.imageset/stomach.png rename to Tiny/Resources/Assets.xcassets/stomach.imageset/stomach.png diff --git a/Tiny/Resources/Assets.xcassets/titleSplashScreen.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/titleSplashScreen.imageset/Contents.json new file mode 100644 index 0000000..08d35ab --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/titleSplashScreen.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "titleSplash.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/titleSplashScreen.imageset/titleSplash.png b/Tiny/Resources/Assets.xcassets/titleSplashScreen.imageset/titleSplash.png new file mode 100644 index 0000000..d1c2d96 Binary files /dev/null and b/Tiny/Resources/Assets.xcassets/titleSplashScreen.imageset/titleSplash.png differ diff --git a/Tiny/Resources/Assets.xcassets/yellowHeart.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/yellowHeart.imageset/Contents.json new file mode 100644 index 0000000..6023737 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/yellowHeart.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "yellowHeart.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/yellowHeart.imageset/yellowHeart.png b/Tiny/Resources/Assets.xcassets/yellowHeart.imageset/yellowHeart.png new file mode 100644 index 0000000..7025b9d Binary files /dev/null and b/Tiny/Resources/Assets.xcassets/yellowHeart.imageset/yellowHeart.png differ diff --git a/Tiny/Localizable.xcstrings b/Tiny/Resources/Localizable.xcstrings similarity index 90% rename from Tiny/Localizable.xcstrings rename to Tiny/Resources/Localizable.xcstrings index 039f8c3..cb42ba6 100644 --- a/Tiny/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -99,10 +99,6 @@ "comment" : "A label describing the gain control slider.", "isCommentAutoGenerated" : true }, - "Amplification: %@x" : { - "comment" : "A slider that allows the user to adjust the amplification of the audio being captured. The value displayed in the label is the current amplification factor. The argument is the string “%.1f”.", - "isCommentAutoGenerated" : true - }, "Analysis Tab" : { "comment" : "A picker that allows the user to switch between different tabs in the heartbeat analysis view.", "isCommentAutoGenerated" : true @@ -158,12 +154,12 @@ "comment" : "A button label that, when tapped, will direct the user to configure Bluetooth on their device.", "isCommentAutoGenerated" : true }, - "Connected Device" : { - "comment" : "A label displayed next to the name of the currently connected audio device.", + "Connect your AirPods and let Tiny access your microphone to hear every little beat." : { + "comment" : "A description of how to connect AirPods to Tiny.", "isCommentAutoGenerated" : true }, - "Contains %lld clips" : { - "comment" : "A subheading indicating how many clips are in the library. The argument is the count of clips in the library.", + "Connected Device" : { + "comment" : "A label displayed next to the name of the currently connected audio device.", "isCommentAutoGenerated" : true }, "Continue" : { @@ -247,14 +243,6 @@ "comment" : "The title of the view.", "isCommentAutoGenerated" : true }, - "Heartbeat Timeline" : { - "comment" : "The title of the view that displays a user's heartbeat timeline.", - "isCommentAutoGenerated" : true - }, - "Hello, Mrs. %@" : { - "comment" : "A title at the top of the home view, followed by a user's name and a profile icon.", - "isCommentAutoGenerated" : true - }, "Here's how to control your session" : { "comment" : "A subheading displayed below the main title of the initial tutorial.", "isCommentAutoGenerated" : true @@ -314,20 +302,12 @@ "comment" : "The title of an alert that appears when microphone access is denied.", "isCommentAutoGenerated" : true }, - "My Library" : { - "comment" : "The title of the user's library view.", - "isCommentAutoGenerated" : true - }, "No heartbeat detected yet" : { "comment" : "A message displayed when no heartbeat data is available.", "isCommentAutoGenerated" : true }, "No recording available. Record in Orb mode first." : { - }, - "No saved recordings yet" : { - "comment" : "A message displayed when a user has not yet saved any heartbeat recordings.", - "isCommentAutoGenerated" : true }, "Noise Gate" : { "comment" : "A setting that controls the sensitivity of the noise gate.", @@ -360,10 +340,6 @@ "comment" : "A label displayed next to a play/pause button in the tutorial overlay.", "isCommentAutoGenerated" : true }, - "Play Recording" : { - "comment" : "A button label that starts or pauses audio playback.", - "isCommentAutoGenerated" : true - }, "Playing with EQ" : { }, @@ -437,10 +413,6 @@ "comment" : "A caption displayed underneath a coach mark in the tutorial overlay.", "isCommentAutoGenerated" : true }, - "Search library" : { - "comment" : "A placeholder text for a search bar in the library view.", - "isCommentAutoGenerated" : true - }, "Sensitive" : { "comment" : "A label displayed next to the slider in the advanced settings of the live listen view.", "isCommentAutoGenerated" : true @@ -449,8 +421,8 @@ "comment" : "A button label that indicates sharing content.", "isCommentAutoGenerated" : true }, - "Share Recording" : { - "comment" : "A button label that says \"Share Recording\".", + "Share how you feel today and let love keep you both close." : { + "comment" : "A description of the feature that allows users to share their moods.", "isCommentAutoGenerated" : true }, "Share Report" : { @@ -477,10 +449,6 @@ "comment" : "A section header for the signal quality trends in the heartbeat analysis tab.", "isCommentAutoGenerated" : true }, - "Signal Strength" : { - "comment" : "A label describing the signal strength displayed in the Amplitude section.", - "isCommentAutoGenerated" : true - }, "Silent" : { "comment" : "A label displayed next to the left end of a slider in the \"Noise Gate\" section of the Enhanced Live Listen view.", "isCommentAutoGenerated" : true @@ -517,10 +485,6 @@ "comment" : "A button label that stops a recording and saves it to the user's library.", "isCommentAutoGenerated" : true }, - "Stop Playback" : { - "comment" : "A button label that stops audio playback.", - "isCommentAutoGenerated" : true - }, "Stop Recording" : { "comment" : "A button label that stops recording audio.", "isCommentAutoGenerated" : true @@ -560,12 +524,8 @@ "comment" : "A section header that describes the time range selection feature.", "isCommentAutoGenerated" : true }, - "Tiny will need access to your microphone so you can hear every tiny beat clearly" : { - "comment" : "A text displayed below the button that asks for microphone permission, explaining why it is needed.", - "isCommentAutoGenerated" : true - }, - "Today, I want to" : { - "comment" : "A heading above a group of feature cards.", + "Tiny will need access to your microphone so you can hear every tiny beat clearly." : { + "comment" : "A description under the title of the second onboarding page.", "isCommentAutoGenerated" : true }, "Variability" : { @@ -576,8 +536,8 @@ "comment" : "A label inside the bottom pocket of a folder, showing the current week.", "isCommentAutoGenerated" : true }, - "You can listen to your baby's heartbeat live and record it to listen again later" : { - "comment" : "A description of the functionality of listening to a baby's heartbeat live and recording it.", + "You can listen to your baby's heartbeat live and record it to listen again later." : { + "comment" : "A description of the live and recorded heartbeat features.", "isCommentAutoGenerated" : true }, "Your recording will be saved automatically in your library." : { diff --git a/Tiny/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Timeline/Views/PregnancyTimelineView.swift deleted file mode 100644 index 1e7c0ff..0000000 --- a/Tiny/Timeline/Views/PregnancyTimelineView.swift +++ /dev/null @@ -1,217 +0,0 @@ -// -// TimelineView.swift -// Tiny -// -// Created by Tm Revanza Narendra Pradipta on 18/11/25. -// - -import SwiftUI - -struct PregnancyTimelineView: View { - @ObservedObject var heartbeatSoundManager: HeartbeatSoundManager - let onSelectRecording: (Recording) -> Void - let onClose: () -> Void - - @State private var isExpanded = false - @State private var showDates = false - - private static let dateFormatter: DateFormatter = { - let dateFormat = DateFormatter() - dateFormat.dateStyle = .medium - dateFormat.timeStyle = .short - return dateFormat - }() - - var body: some View { - ZStack { - // Background - LinearGradient( - colors: [Color(red: 0.05, green: 0.05, blue: 0.15), Color.black], - startPoint: .top, - endPoint: .bottom - ) - .ignoresSafeArea() - - // Title - VStack { - if isExpanded { - Text("Heartbeat Timeline") - .font(.system(size: 28, weight: .bold)) - .foregroundColor(.white) - .transition(.opacity) - .padding(.top, 80) - } else { - Spacer().frame(height: 40) - } - Spacer() - } - - GeometryReader { geometry in - ZStack { - // Wavy timeline path - WavePath() - .stroke( - Color.white.opacity(0.25), - style: StrokeStyle( - lineWidth: 2, - lineCap: .round, - lineJoin: .round, - dash: [6, 8] - ) - ) - .frame(width: geometry.size.width, height: geometry.size.height) - - let recordings = heartbeatSoundManager.savedRecordings - let total = recordings.count - - if total == 0 { - VStack { - Spacer() - Text("No saved recordings yet") - .font(.subheadline) - .foregroundColor(.white.opacity(0.6)) - .padding(.bottom, 140) - } - .frame(width: geometry.size.width, height: geometry.size.height) - } else { - // Orbs along the wavy path - ForEach(recordings.indices, id: \.self) { index in - let recording = recordings[index] - let point = wavePoint( - for: index, - total: total, - in: geometry.size - ) - - VStack(spacing: 4) { - AnimatedOrbView(size: 40) - .onTapGesture { - onSelectRecording(recording) - } - - Text(label(for: recording)) - .font(.caption2) - .foregroundColor(.white.opacity(0.8)) - .lineLimit(1) - .minimumScaleFactor(0.7) - } - .position(point) - .transition(.scale.combined(with: .opacity)) - } - } - } - } - - // Bottom morphing book/back button (glass) - GeometryReader { geometry in - Button { - if !isExpanded { - withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { - isExpanded = true - } - withAnimation(.spring(response: 0.6, dampingFraction: 0.8).delay(0.3)) { - showDates = true - } - } else { - withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { - isExpanded = false - showDates = false - } - onClose() - } - } label: { - ZStack { - // book icon when collapsed - Image(systemName: "book.fill") - .font(.system(size: 28)) - .foregroundColor(.white) - .opacity(isExpanded ? 0 : 1) - .scaleEffect(isExpanded ? 0.5 : 1) - - // chevron when expanded - Image(systemName: "chevron.left") - .font(.system(size: 24, weight: .medium)) - .foregroundColor(.white) - .opacity(isExpanded ? 1 : 0) - .scaleEffect(isExpanded ? 1 : 0.5) - } - .frame(width: isExpanded ? 50 : 77, height: isExpanded ? 50 : 77) - .clipShape(Circle()) - } - .glassEffect(.clear) - .position( - x: isExpanded ? 45 : geometry.size.width / 2, - y: isExpanded ? 85 : geometry.size.height - 100 - ) - } - } - .animation(.easeInOut(duration: 0.4), value: isExpanded) - } - - // Position along the same wave as the path, from bottom to top - private func wavePoint(for index: Int, total: Int, in size: CGSize) -> CGPoint { - guard total > 1 else { - return CGPoint(x: size.width * 0.5, y: size.height * 0.75) - } - - // 0 = bottom, 1 = top - let top = CGFloat(index) / CGFloat(max(total - 1, 1)) - - let yStart = size.height * 0.75 - let yEnd = size.height * 0.25 - let yCoordinate = yStart + (yEnd - yStart) * top - - let centerX = size.width * 0.5 - let amplitude = size.width * 0.25 - let xCoordinate = centerX + sin(top * .pi * 1.5) * amplitude - - return CGPoint(x: xCoordinate, y: yCoordinate) - } - - private func label(for recording: Recording) -> String { - // Try to extract unix timestamp from filename "saved-heartbeat-.caf" - let name = recording.fileURL.deletingPathExtension().lastPathComponent - - let components = name.split(separator: "-") - if let last = components.last, - let timeSecond = TimeInterval(last) { - let date = Date(timeIntervalSince1970: timeSecond) - return Self.dateFormatter.string(from: date) - } else { - // Fallback: show the raw file name - return name - } - } -} - -// Wavy timeline path (matches `wavePoint`) -struct WavePath: Shape { - func path(in rect: CGRect) -> Path { - var path = Path() - - let centerX = rect.width * 0.5 - let amplitude = rect.width * 0.25 - let yStart = rect.height * 0.75 - let yEnd = rect.height * 0.25 - let steps = 60 - - path.move(to: CGPoint(x: centerX, y: yStart)) - - for step in 1...steps { - let top = CGFloat(step) / CGFloat(steps) - let yCoor = yStart + (yEnd - yStart) * top - let xCoor = centerX + sin(top * .pi * 1.5) * amplitude - path.addLine(to: CGPoint(x: xCoor, y: yCoor)) - } - - return path - } -} - -#Preview { - PregnancyTimelineView( - heartbeatSoundManager: HeartbeatSoundManager(), - onSelectRecording: { _ in }, - onClose: {} - ) -} diff --git a/Tiny/Views/OrbLiveListenView.swift b/Tiny/Views/OrbLiveListenView.swift deleted file mode 100644 index 752820a..0000000 --- a/Tiny/Views/OrbLiveListenView.swift +++ /dev/null @@ -1,489 +0,0 @@ -import SwiftUI - -struct OrbLiveListenView: View { - @State private var activeTutorial: TutorialContext? - @StateObject var heartbeatSoundManager = HeartbeatSoundManager() - @StateObject private var audioPostProcessingManager = AudioPostProcessingManager() - @StateObject private var physicsController = OrbPhysicsController() - @State private var isListening = false - @State private var animateOrb = false - @State private var showShareSheet = false - @State private var isPlaybackMode = false - - // Long press countdown states - @State private var isLongPressing = false - @State private var longPressCountdown = 3 - @State private var longPressTimer: Timer? - @State private var longPressScale: CGFloat = 1.0 - - // Long press to drag states - @State private var dragOffset: CGFloat = 0 - @State private var isDraggingToSave = false - @State private var saveButtonScale: CGFloat = 1.0 - @State private var orbDragScale: CGFloat = 1.0 - - // NEW: - @State private var showTimeline = false - @State private var canSaveCurrentRecording = false - - var body: some View { - GeometryReader { geometry in - ZStack { - backgroundView - topButtonsView - statusTextView - orbView(geometry: geometry) - saveButtonView(geometry: geometry) - coachMarkView - - if let context = activeTutorial { - TutorialOverlay(activeTutorial: $activeTutorial, context: context) - } - - // ⬇️ Overlay: PregnancyTimelineView with fade - if showTimeline { - PregnancyTimelineView( - heartbeatSoundManager: heartbeatSoundManager, - onSelectRecording: { recording in - handleSelectRecordingFromTimeline(recording) - }, - onClose: { - withAnimation(.easeInOut(duration: 0.4)) { - showTimeline = false - } - } - ) - .transition(.opacity) - .ignoresSafeArea() - .zIndex(2) - } - } - .animation(.easeInOut(duration: 0.4), value: showTimeline) - .sheet(isPresented: $showShareSheet) { - if let lastRecordingURL = heartbeatSoundManager.lastRecording?.fileURL { - ShareSheet(activityItems: [lastRecordingURL]) - } - } - .preferredColorScheme(.dark) - .onAppear(perform: showInitialTutorialIfNeeded) - } - } -} - -// MARK: - View Components -extension OrbLiveListenView { - - private var backgroundView: some View { - ZStack { - Color.black.ignoresSafeArea() - Image("background") - .resizable() - .scaleEffect(isListening ? 1.2 : 1.0) - .animation(.easeInOut(duration: 1.2), value: isListening) - .ignoresSafeArea() - } - } - - private var topButtonsView: some View { - VStack { - HStack { - if isPlaybackMode { - - Button(action: handleBackButton, label: { - Image(systemName: "chevron.left") - .font(.title) - .foregroundColor(.white) - }) - .transition(.opacity.animation(.easeInOut)) - } - - Spacer() - - Button(action: { showShareSheet = true }, label: { - Image(systemName: "square.and.arrow.up") - .font(.title) - .foregroundColor(.white) - }) - .disabled(heartbeatSoundManager.lastRecording == nil) - } - .padding() - Spacer() - } - } - - private var statusTextView: some View { - VStack { - Group { - if isListening && isLongPressing { - CountdownTextView(countdown: longPressCountdown, isVisible: isLongPressing) - } else if isListening { - Text("Listening...") - .font(.title) - .fontWeight(.bold) - } else if isPlaybackMode { - VStack(spacing: 8) { - // CHANGED: Added drag instruction - Text(audioPostProcessingManager.isPlaying ? "Playing..." : (isDraggingToSave ? "Drag to save" : "Tap orb to play")) - .font(.title2) - .fontWeight(.medium) - - // CHANGED: Hide duration when dragging - if audioPostProcessingManager.duration > 0 && !isDraggingToSave { - Text("\(Int(audioPostProcessingManager.currentTime))s / \(Int(audioPostProcessingManager.duration))s") - .font(.caption) - .foregroundColor(.white.opacity(0.7)) - } - } - } - } - .foregroundColor(.white) - .padding(.top, 50) - .transition(.opacity.animation(.easeInOut)) - - Spacer() - } - } - - private func orbView(geometry: GeometryProxy) -> some View { - VStack { - ZStack { - AnimatedOrbView() - bokehEffectView - } - .frame(width: 200, height: 200) - .opacity(isPlaybackMode ? (audioPostProcessingManager.isPlaying ? 1.0 : 0.4) : 1.0) - .scaleEffect(orbScaleEffect * orbDragScale) // CHANGED: Added orbDragScale - .animation(.easeInOut(duration: 0.5), value: audioPostProcessingManager.isPlaying) - .animation(.interpolatingSpring(mass: 2, stiffness: 100, damping: 20), value: animateOrb) - .animation(.easeInOut(duration: 0.2), value: longPressScale) - .animation(.easeInOut(duration: 0.2), value: orbDragScale) // CHANGED: Added animation for orbDragScale - .offset(y: orbOffset(geometry: geometry) + dragOffset) // CHANGED: Added dragOffset - .onTapGesture(count: 2, perform: handleDoubleTap) - .onTapGesture(count: 1, perform: handleSingleTap) - .modifier(GestureModifier( - isPlaybackMode: isPlaybackMode, - geometry: geometry, - handleDragChange: handleDragChange, - handleDragEnd: handleDragEnd, - handleLongPressChange: handleLongPressChange, - handleLongPressComplete: handleLongPressComplete - )) - } - .frame(width: geometry.size.width, height: geometry.size.height) - } - - private var bokehEffectView: some View { - Group { - if isListening { - BokehEffectView(amplitude: $heartbeatSoundManager.blinkAmplitude) - } else if isPlaybackMode { - BokehEffectView(amplitude: .constant(audioPostProcessingManager.isPlaying ? 0.8 : 0.2)) - .opacity(audioPostProcessingManager.isPlaying ? 1.0 : 0.5) - .animation(.easeInOut(duration: 0.5), value: audioPostProcessingManager.isPlaying) - } - } - .scaleEffect(x: physicsController.scaleX, y: physicsController.scaleY) - .offset(x: physicsController.offsetX, y: physicsController.offsetY) - .rotationEffect(.degrees(physicsController.rotation)) - .onAppear { physicsController.startPhysics() } - .frame(width: 18, height: 18) - } - - // CHANGED: Updated save button to fade in based on drag progress - private func saveButtonView(geometry: GeometryProxy) -> some View { - Button {} label: { - Image(systemName: "book.fill") - .font(.system(size: 28)) - .foregroundColor(.white) - .frame(width: 77, height: 77) - .clipShape(Circle()) - } - .glassEffect(.clear) - .scaleEffect(saveButtonScale) - .animation(.easeInOut(duration: 0.2), value: saveButtonScale) - .position(x: geometry.size.width / 2, y: geometry.size.height - 100) - .opacity(isDraggingToSave ? min(dragOffset / 100, 1.0) : 0.0) - .animation(.easeInOut(duration: 0.2), value: isDraggingToSave) - .animation(.easeInOut(duration: 0.2), value: dragOffset) - } - - private var coachMarkView: some View { - Group { - if !isListening && !isPlaybackMode { - GeometryReader { proxy in - CoachMarkView() - .position(x: proxy.size.width / 2, y: proxy.size.height / 2 + 250) - } - .transition(.opacity) - } - } - } -} - -// MARK: - Computed Properties -extension OrbLiveListenView { - - private var orbScaleEffect: CGFloat { - if isListening { - return isLongPressing ? (animateOrb ? 1.6 : 1.1) * longPressScale : (animateOrb ? 1.5 : 1.0) - } else if isPlaybackMode { - return audioPostProcessingManager.isPlaying ? 1.3 : 0.8 - } - return 1.0 - } - - private func orbOffset(geometry: GeometryProxy) -> CGFloat { - isListening ? geometry.size.height / 2 - 150 : 0 - } -} - -// MARK: - Drag Gesture Actions (NEW SECTION) -extension OrbLiveListenView { - - private func handleDragChange(value: SequenceGesture.Value, geometry: GeometryProxy) { - guard canSaveCurrentRecording else { return } // NEW - switch value { - case .second(true, let drag): - isDraggingToSave = true - - // Only allow downward drag - let translation = max(0, drag?.translation.height ?? 0) - dragOffset = translation - - // Calculate how far down the orb is dragged - let maxDragDistance = geometry.size.height / 2 - let dragProgress = min(translation / maxDragDistance, 1.0) - - // Shrink orb as it's dragged down (from 1.0 to 0.5) - withAnimation(.easeInOut(duration: 0.2)) { - orbDragScale = 1.0 - (dragProgress * 0.5) - - // Grow save button as orb gets closer (from 1.0 to 1.5) - saveButtonScale = 1.0 + (dragProgress * 0.5) - } - - default: - break - } - } - - private func handleDragEnd(value: SequenceGesture.Value, geometry: GeometryProxy) { - guard canSaveCurrentRecording else { return } // NEW - switch value { - case .second(true, let drag): - let translation = drag?.translation.height ?? 0 - let saveThreshold = geometry.size.height / 3 - - if translation > saveThreshold { - // User dragged far enough - save the recording - handleSaveRecording() - } else { - // User didn't drag far enough or dragged back up - cancel - resetDragState() - } - - default: - resetDragState() - } - } - - private func resetDragState() { - withAnimation(.easeInOut(duration: 0.3)) { - dragOffset = 0 - orbDragScale = 1.0 - saveButtonScale = 1.0 - isDraggingToSave = false - } - } - - private func handleSaveRecording() { - guard canSaveCurrentRecording else { return } - withAnimation(.interpolatingSpring(mass: 1, stiffness: 200, damping: 15)) { - saveButtonScale = 1.8 - orbDragScale = 0.3 - } - - // Call your save function here - heartbeatSoundManager.saveRecording() - canSaveCurrentRecording = false - - // After the little save animation, reset + fade into timeline - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - resetDragState() - withAnimation(.easeInOut(duration: 0.5)) { - showTimeline = true - } - } - } -} - -// MARK: - Recording Actions -extension OrbLiveListenView { - - private func handleDoubleTap() { - guard !isLongPressing, !isListening, !isPlaybackMode else { return } - - withAnimation(.interpolatingSpring(mass: 2, stiffness: 100, damping: 20)) { - animateOrb = true - isListening = true - } - - heartbeatSoundManager.start() - heartbeatSoundManager.startRecording() - - } - - private func handleSingleTap() { - // CHANGED: Added guard for isDraggingToSave - guard isPlaybackMode, !isListening, !isLongPressing, !isDraggingToSave else { return } - guard let lastRecording = heartbeatSoundManager.lastRecording else { return } - - if audioPostProcessingManager.isPlaying { - audioPostProcessingManager.pause() - } else if audioPostProcessingManager.currentTime > 0 && audioPostProcessingManager.duration > 0 { - audioPostProcessingManager.resume() - } else { - audioPostProcessingManager.loadAndPlay(fileURL: lastRecording.fileURL) - } - } - - private func handleBackButton() { - audioPostProcessingManager.stop() - withAnimation(.interpolatingSpring(mass: 2, stiffness: 100, damping: 20)) { - isPlaybackMode = false - animateOrb = false - } - } - - private func handleLongPressChange(pressing: Bool) { - guard isListening else { return } - if pressing { - startLongPressCountdown() - } else { - cancelLongPressCountdown() - } - } - - private func startLongPressCountdown() { - isLongPressing = true - longPressCountdown = 3 - longPressScale = 1.0 - - var tickCount = 0 - let totalTicks = 30 - let scaleIncrement = 0.15 / 30 - - longPressTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in - tickCount += 1 - - if tickCount % 10 == 0 { - withAnimation(.easeInOut(duration: 0.2)) { longPressCountdown -= 1 } - } - - withAnimation(.linear(duration: 0.1)) { longPressScale += scaleIncrement } - - if tickCount >= totalTicks { timer.invalidate() } - } - } - - private func cancelLongPressCountdown() { - isLongPressing = false - longPressCountdown = 3 - longPressScale = 1.0 - longPressTimer?.invalidate() - longPressTimer = nil - } - - private func handleLongPressComplete() { - cancelLongPressCountdown() - - withAnimation(.interpolatingSpring(mass: 2, stiffness: 100, damping: 20)) { - isListening = false - animateOrb = false - isPlaybackMode = true - canSaveCurrentRecording = true - } - - heartbeatSoundManager.stopRecording() - heartbeatSoundManager.stop() - - showListeningTutorialIfNeeded() - } - - // MARK: - Tutorial Logic - private func showInitialTutorialIfNeeded() { - if !UserDefaults.standard.bool(forKey: "hasShownInitialTutorial") { - activeTutorial = .initial - } - } - - private func showListeningTutorialIfNeeded() { - if !UserDefaults.standard.bool(forKey: "hasShownListeningTutorial") { - // Use a delay to allow the listening UI to appear first - DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { - activeTutorial = .listening - } - } - } - - private func handleSelectRecordingFromTimeline(_ recording: Recording) { - // Update lastRecording so the playback logic can reuse it - heartbeatSoundManager.lastRecording = recording - - // Make sure we're not listening - isListening = false - - // Close the timeline with a fade and go into playback mode - withAnimation(.easeInOut(duration: 0.4)) { - showTimeline = false - isPlaybackMode = true - canSaveCurrentRecording = false - animateOrb = true - } - - // Start playback using your existing post-processing manager - audioPostProcessingManager.stop() - audioPostProcessingManager.loadAndPlay(fileURL: recording.fileURL) - } - - // MARK: - Gesture Modifier (NO CHANGES) - struct GestureModifier: ViewModifier { - let isPlaybackMode: Bool - let geometry: GeometryProxy - let handleDragChange: (SequenceGesture.Value, GeometryProxy) -> Void - let handleDragEnd: (SequenceGesture.Value, GeometryProxy) -> Void - let handleLongPressChange: (Bool) -> Void - let handleLongPressComplete: () -> Void - - func body(content: Content) -> some View { - if isPlaybackMode { - content - .gesture( - LongPressGesture(minimumDuration: 0.5) - .sequenced(before: DragGesture()) - .onChanged { value in - handleDragChange(value, geometry) - } - .onEnded { value in - handleDragEnd(value, geometry) - } - ) - } else { - content - .gesture( - LongPressGesture(minimumDuration: 3.0) - .onChanged { pressing in - handleLongPressChange(pressing) - } - .onEnded { _ in - handleLongPressComplete() - } - ) - } - } - } -} - -#Preview { - OrbLiveListenView() -} diff --git a/Tiny/tinyApp.swift b/Tiny/tinyApp.swift deleted file mode 100644 index ea293a4..0000000 --- a/Tiny/tinyApp.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// tinyApp.swift -// tiny -// -// Created by Destu Cikal Ramdani on 25/10/25. -// - -import SwiftUI - -@main -struct TinyApp: App { - @StateObject var heartbeatSoundManager = HeartbeatSoundManager() - - var body: some Scene { - WindowGroup { - ContentView() - .environmentObject(heartbeatSoundManager) - } - } -}