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)
- }
- }
-}