diff --git a/Tiny/Components/CoachMarkView.swift b/Tiny/Components/CoachMarkView.swift index 2458b0f..926539f 100644 --- a/Tiny/Components/CoachMarkView.swift +++ b/Tiny/Components/CoachMarkView.swift @@ -1,64 +1,299 @@ import SwiftUI +enum CoachMarkAnimationType { + case doubleTap + case singleTap + case hold + case holdAndDrag +} + struct CoachMarkView: View { - @State private var iconState = 0 // 0 = idle, 1 = tap1, 2 = tap2 + @State private var iconState = 0 // 0 = idle, 1 = tap1, 2 = tap2, 3 = holding, 4 = dragging + @State private var dragOffset: CGSize = .zero + + // Customization properties + let animationType: CoachMarkAnimationType + let showText: Bool + let customText: String? + + // Size customization properties + let iconSize: CGFloat + let textSize: Font + let spacing: CGFloat + let dragLineWidth: CGFloat + let dragLineXOffset: CGFloat + + // Initializer with default values + init( + animationType: CoachMarkAnimationType = .doubleTap, + showText: Bool = true, + customText: String? = nil, + iconSize: CGFloat = 90, + textSize: Font = .headline, + spacing: CGFloat = 10, + dragLineWidth: CGFloat? = nil, + dragLineXOffset: CGFloat? = nil + ) { + self.animationType = animationType + self.showText = showText + self.customText = customText + self.iconSize = iconSize + self.textSize = textSize + self.spacing = spacing + // Set default line properties based on iconSize if not provided + self.dragLineWidth = dragLineWidth ?? (iconSize / 9) + self.dragLineXOffset = dragLineXOffset ?? (iconSize * 0.44) + } var body: some View { - VStack(spacing: 10) { - - // --- FIX: Use a ZStack for a stable frame --- - // This prevents the layout from shifting when the icon changes, - // as the ZStack's size is calculated to fit both icons. + VStack(spacing: spacing) { + // Icon animation area ZStack { + // Base layer to define the frame and contain static icons Image(systemName: "hand.point.up.fill") - // Show this icon only when state is 0 .opacity(iconState == 0 ? 1 : 0) Image(systemName: "hand.tap.fill") - // Show this icon when state is 1 or 2 - .opacity(iconState == 1 || iconState == 2 ? 1 : 0) + .opacity((iconState == 1 || iconState == 2) && (animationType == .doubleTap || animationType == .singleTap) ? 1 : 0) + + // NOTE: The 'hold' icon for holdAndDrag is now handled in the overlay + Image(systemName: "hand.tap.fill") + .opacity(iconState == 3 && animationType == .hold ? 1 : 0) } - .font(.system(size: 90)) + .font(.system(size: iconSize)) + .frame(width: iconSize * 1.5, height: iconSize * 2) // Define a fixed frame to contain the animation + .overlay(dragAnimationOverlay) // Apply the drag animation in an overlay .foregroundColor(.white.opacity(0.8)) - // Apply the animation to the opacity changes .animation(.easeInOut(duration: 0.15), value: iconState) - - Text("Tap Twice to Start") - .font(.headline) - .foregroundColor(.white.opacity(0.7)) + .animation(.easeInOut(duration: 0.3), value: dragOffset) + + // Customizable text + if showText { + Text(displayText) + .font(textSize) + .foregroundColor(.white.opacity(0.7)) + .transition(.opacity) + } } + .allowsHitTesting(false) // Make the entire view non-interactive .task { - // Start the animation loop when the view appears - // The `try?` handles the cancellation error when the view disappears - try? await runDoubleTapLoop() + try? await runAnimationLoop() } } + + // The drag animation is now in an overlay, so it won't affect the layout. + private var dragAnimationOverlay: some View { + Group { + if animationType == .holdAndDrag && (iconState == 3 || iconState == 4) { + ZStack { + // --- Dragging State Components (Visible only when iconState is 4) --- - // MARK: - Double Tap Animation Loop - func runDoubleTapLoop() async throws { - // Loop indefinitely - while true { - // --- Tap 1 --- - iconState = 1 - try await Task.sleep(for: .milliseconds(200)) + // Drag line + Path { path in + // Y-offset to place the line under the finger + let yOffset = iconSize * 0.45 + + // Apply the horizontal offset to the start and end points + let startPoint = CGPoint(x: dragLineXOffset, y: yOffset) + let endPoint = CGPoint(x: dragOffset.width + dragLineXOffset, y: dragOffset.height + yOffset) - // --- Lift Up --- - iconState = 0 - try await Task.sleep(for: .milliseconds(150)) + path.move(to: startPoint) + path.addLine(to: endPoint) + } + // Use the new dynamic properties for line style + .stroke(.white.opacity(0.6), style: StrokeStyle(lineWidth: dragLineWidth, lineCap: .round, lineJoin: .round)) + .opacity(iconState == 4 ? 1 : 0) // Only visible during drag - // --- Tap 2 --- - iconState = 2 - try await Task.sleep(for: .milliseconds(200)) + // Faded hand at the start position + if abs(dragOffset.height) > iconSize * 0.3 { + Image(systemName: "hand.tap.fill") + .font(.system(size: iconSize)) + .opacity(iconState == 4 ? 0.2 : 0) // Only visible during drag + } - // --- Rest --- - iconState = 0 - try await Task.sleep(for: .milliseconds(800)) + // --- Holding and Dragging Hand (Always visible in this overlay) --- + // This hand is visible during the hold (3) and moves during the drag (4) + Image(systemName: "hand.tap.fill") + .font(.system(size: iconSize)) + .offset(dragOffset) // This is (0,0) during hold, and changes during drag + } + } else { + EmptyView() + } + } + } + + // Computed property for display text + private var displayText: String { + if let customText = customText { + return customText + } + + switch animationType { + case .doubleTap: + return "Tap Twice to Start" + case .singleTap: + return "Tap to Play" + case .hold: + return "Hold to Stop" + case .holdAndDrag: + return "Hold then drag the sphere" + } + } + + // MARK: - Animation Loop + func runAnimationLoop() async throws { + while true { + switch animationType { + case .doubleTap: + try await runDoubleTapAnimation() + case .singleTap: + try await runSingleTapAnimation() + case .hold: + try await runHoldAnimation() + case .holdAndDrag: + try await runHoldAndDragAnimation() + } + } + } + + // MARK: - Single Tap Animation + private func runSingleTapAnimation() async throws { + iconState = 1 + try await Task.sleep(for: .milliseconds(250)) + iconState = 0 + try await Task.sleep(for: .milliseconds(1000)) + } + + // MARK: - Double Tap Animation + private func runDoubleTapAnimation() async throws { + iconState = 1 + try await Task.sleep(for: .milliseconds(200)) + iconState = 0 + try await Task.sleep(for: .milliseconds(150)) + iconState = 2 + try await Task.sleep(for: .milliseconds(200)) + iconState = 0 + try await Task.sleep(for: .milliseconds(800)) + } + + // MARK: - Hold Animation + private func runHoldAnimation() async throws { + iconState = 3 + try await Task.sleep(for: .milliseconds(1500)) + iconState = 0 + try await Task.sleep(for: .milliseconds(1000)) + } + + // MARK: - Hold and Drag Animation + private func runHoldAndDragAnimation() async throws { + iconState = 3 + dragOffset = .zero + try await Task.sleep(for: .milliseconds(1000)) + + iconState = 4 + let dragSteps = 15 + // Make drag distance proportional to the icon size for correct scaling + let totalDragDistance = iconSize * 0.67 + + for step in 1...dragSteps { + let progress = Double(step) / Double(dragSteps) + let easedProgress = easeInOutQuad(progress) + // Revert to vertical-only drag + dragOffset = CGSize(width: 0, height: totalDragDistance * easedProgress) + try await Task.sleep(for: .milliseconds(40)) } + + try await Task.sleep(for: .milliseconds(300)) + + iconState = 0 + dragOffset = .zero + try await Task.sleep(for: .milliseconds(1200)) } + + // Easing function for smooth animation + private func easeInOutQuad(_ time: Double) -> Double { + return time < 0.5 ? 2 * time * time : -1 + (4 - 2 * time) * time + } +} + +// MARK: - Convenience Initializers +extension CoachMarkView { + // Small size preset + static func small( + animationType: CoachMarkAnimationType = .doubleTap, + showText: Bool = true, + customText: String? = nil + ) -> CoachMarkView { + CoachMarkView( + animationType: animationType, + showText: showText, + customText: customText, + iconSize: 60, + textSize: .caption, + spacing: 8 + ) + } + + // Medium size preset (default) + static func medium( + animationType: CoachMarkAnimationType = .doubleTap, + showText: Bool = true, + customText: String? = nil + ) -> CoachMarkView { + CoachMarkView( + animationType: animationType, + showText: showText, + customText: customText, + iconSize: 90, + textSize: .headline, + spacing: 10 + ) + } + + // Large size preset + static func large( + animationType: CoachMarkAnimationType = .doubleTap, + showText: Bool = true, + customText: String? = nil + ) -> CoachMarkView { + CoachMarkView( + animationType: animationType, + showText: showText, + customText: customText, + iconSize: 120, + textSize: .title2, + spacing: 15 + ) + } +} + +#Preview("Single Tap") { + CoachMarkView.medium(animationType: .singleTap, showText: true) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) +} + +#Preview("Double Tap") { + CoachMarkView.medium(animationType: .doubleTap, showText: true) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) +} + +#Preview("Hold") { + CoachMarkView.medium(animationType: .hold, showText: true) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) +} + +#Preview("Hold and Drag") { + CoachMarkView.medium(animationType: .holdAndDrag, showText: true) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) } -#Preview { - CoachMarkView() +#Preview("Large Hold and Drag") { + CoachMarkView.large(animationType: .holdAndDrag, showText: true, customText: "Save or Delete") .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.black) } diff --git a/Tiny/Localizable.xcstrings b/Tiny/Localizable.xcstrings index 9256a23..1180cc4 100644 --- a/Tiny/Localizable.xcstrings +++ b/Tiny/Localizable.xcstrings @@ -208,10 +208,18 @@ "comment" : "A label displayed above the filter mode selection options in the heartbeat analysis view.", "isCommentAutoGenerated" : true }, + "Finish session" : { + "comment" : "A label describing the action to finish a session.", + "isCommentAutoGenerated" : true + }, "Frequency Spectrum" : { "comment" : "A title for the frequency spectrum visualization.", "isCommentAutoGenerated" : true }, + "Hear Baby's Heartbeat" : { + "comment" : "The title of the tutorial overlay.", + "isCommentAutoGenerated" : true + }, "Heart Rate" : { "comment" : "A title for the section displaying the user's heart rate.", "isCommentAutoGenerated" : true @@ -240,10 +248,18 @@ "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 description of how to control a user's session.", + "isCommentAutoGenerated" : true + }, "High Quality Detections" : { "comment" : "A label describing the number of high-quality heartbeat detections.", "isCommentAutoGenerated" : true }, + "Hold then drag the sphere" : { + "comment" : "A description of an action to perform when a user holds and drags a sphere.", + "isCommentAutoGenerated" : true + }, "Hold to stop" : { "comment" : "A label displayed in the center of the countdown view, instructing the user to hold their finger to stop the countdown.", "isCommentAutoGenerated" : true @@ -316,6 +332,10 @@ }, "Play Last Recording with EQ" : { + }, + "Play or Pause" : { + "comment" : "A label describing the action of playing or pausing a recording.", + "isCommentAutoGenerated" : true }, "Play Recording" : { "comment" : "A button label that starts or pauses audio playback.", @@ -331,6 +351,10 @@ "comment" : "A title that describes the user's pregnancy age.", "isCommentAutoGenerated" : true }, + "Press and hold the sphere" : { + "comment" : "A description of how to finish a session by holding a sphere.", + "isCommentAutoGenerated" : true + }, "Proximity Gain" : { "comment" : "A label displayed next to the value of the proximity gain slider.", "isCommentAutoGenerated" : true @@ -350,6 +374,10 @@ "comment" : "A text label displaying the name of the most recently recorded audio file.", "isCommentAutoGenerated" : true }, + "Replay Your Recording" : { + "comment" : "A label for a view that instructs the user to replay their recording.", + "isCommentAutoGenerated" : true + }, "S1 (Lub) Average" : { "comment" : "A label for the average of the \"Lub\" component of the S1 heartbeat sound.", "isCommentAutoGenerated" : true @@ -378,6 +406,10 @@ "comment" : "A label for the number of samples taken in the current session.", "isCommentAutoGenerated" : true }, + "Save or Delete" : { + "comment" : "A label describing an action that saves or deletes a recording.", + "isCommentAutoGenerated" : true + }, "Search library" : { "comment" : "A placeholder text for a search bar in the library view.", "isCommentAutoGenerated" : true @@ -442,6 +474,10 @@ "comment" : "A button to start recording audio.", "isCommentAutoGenerated" : true }, + "Start session" : { + "comment" : "A label describing the action to start a session.", + "isCommentAutoGenerated" : true + }, "Statistics" : { "comment" : "A label displayed above the statistics section of the view.", "isCommentAutoGenerated" : true @@ -474,8 +510,20 @@ "comment" : "A text displayed when the user is not listening to audio and is not in playback mode. It instructs the user to tap the orb to play audio.", "isCommentAutoGenerated" : true }, - "Tap Twice to Start" : { - "comment" : "A text label instructing the user to tap twice to start a feature.", + "Tap the screen" : { + "comment" : "A description of tapping the screen to play or pause a recording.", + "isCommentAutoGenerated" : true + }, + "Tap to Begin" : { + "comment" : "A call-to-action label displayed below the tutorial instructions.", + "isCommentAutoGenerated" : true + }, + "Tap to Continue" : { + "comment" : "A label displayed below the last step of a tutorial. It instructs the user to continue the tutorial.", + "isCommentAutoGenerated" : true + }, + "Tap twice" : { + "comment" : "A description of how to start a session.", "isCommentAutoGenerated" : true }, "Time" : { diff --git a/Tiny/Views/OrbLiveListenView.swift b/Tiny/Views/OrbLiveListenView.swift index 04e2d73..b17259b 100644 --- a/Tiny/Views/OrbLiveListenView.swift +++ b/Tiny/Views/OrbLiveListenView.swift @@ -1,6 +1,7 @@ import SwiftUI struct OrbLiveListenView: View { + @State private var activeTutorial: TutorialContext? @StateObject var heartbeatSoundManager = HeartbeatSoundManager() @StateObject private var audioPostProcessingManager = AudioPostProcessingManager() @StateObject private var physicsController = OrbPhysicsController() @@ -23,6 +24,10 @@ struct OrbLiveListenView: View { statusTextView orbView(geometry: geometry) coachMarkView + + if let context = activeTutorial { + TutorialOverlay(activeTutorial: $activeTutorial, context: context) + } } .sheet(isPresented: $showShareSheet) { if let lastRecordingURL = heartbeatSoundManager.lastRecording?.fileURL { @@ -30,6 +35,7 @@ struct OrbLiveListenView: View { } } .preferredColorScheme(.dark) + .onAppear(perform: showInitialTutorialIfNeeded) } } } @@ -187,6 +193,8 @@ extension OrbLiveListenView { heartbeatSoundManager.start() heartbeatSoundManager.startRecording() + + showListeningTutorialIfNeeded() } private func handleSingleTap() { @@ -261,6 +269,22 @@ extension OrbLiveListenView { heartbeatSoundManager.stopRecording() heartbeatSoundManager.stop() } + + // 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 + } + } + } } #Preview { diff --git a/Tiny/Views/TutorialOverlay.swift b/Tiny/Views/TutorialOverlay.swift new file mode 100644 index 0000000..78e517a --- /dev/null +++ b/Tiny/Views/TutorialOverlay.swift @@ -0,0 +1,136 @@ +// +// TutorialOverlay.swift +// Tiny +// +// Created by Benedictus Yogatama Favian Satyajati on 19/11/25. +// + +import SwiftUI + +enum TutorialContext { + case initial, listening +} + +struct TutorialOverlay: View { + @Binding var activeTutorial: TutorialContext? + let context: TutorialContext + + var body: some View { + ZStack { + Color.black.opacity(0.9) + .ignoresSafeArea() + + switch context { + case .initial: + initialTutorialView + case .listening: + listeningTutorialView + } + } + .onTapGesture { + switch context { + case .initial: + UserDefaults.standard.set(true, forKey: "hasShownInitialTutorial") + case .listening: + UserDefaults.standard.set(true, forKey: "hasShownListeningTutorial") + } + activeTutorial = nil + } + } + + private var initialTutorialView: some View { + VStack(spacing: 24) { + VStack(spacing: 2) { + Text("Hear Baby's Heartbeat") + .font(.body) + .fontWeight(.bold) + Text("Here's how to control your session") + .font(.subheadline) + } + .foregroundColor(.white) + + VStack(alignment: .leading, spacing: -28) { + HStack(spacing: 0) { + CoachMarkView.small( + animationType: .doubleTap, + showText: false + ) + VStack(alignment: .leading) { + Text("Start session") + .font(.headline) + .fontWeight(.bold) + Text("Tap twice") + } + .foregroundColor(.white) + } + HStack(spacing: 0) { + CoachMarkView.small( + animationType: .hold, + showText: false + ) + VStack(alignment: .leading) { + Text("Finish session") + .font(.headline) + .fontWeight(.bold) + Text("Press and hold the sphere") + } + .foregroundColor(.white) + } + } + Text("Tap to Begin") + .font(.subheadline) + .fontWeight(.bold) + .foregroundColor(.white) + } + } + + private var listeningTutorialView: some View { + VStack(spacing: 24) { + VStack(spacing: 2) { + Text("Replay Your Recording") + .font(.body) + .fontWeight(.bold) + Text("Here's how to control your session") + .font(.subheadline) + } + .foregroundColor(.white) + + VStack(alignment: .leading, spacing: -28) { + HStack(spacing: 0) { + CoachMarkView.small( + animationType: .singleTap, + showText: false + ) + VStack(alignment: .leading) { + Text("Play or Pause") + .font(.headline) + .fontWeight(.bold) + Text("Tap the screen") + } + .foregroundColor(.white) + } + HStack(spacing: 0) { + CoachMarkView.small( + animationType: .holdAndDrag, + showText: false + ) + VStack(alignment: .leading) { + Text("Save or Delete") + .font(.headline) + .fontWeight(.bold) + Text("Hold then drag the sphere") + } + .foregroundColor(.white) + } + } + Text("Tap to Continue") + .font(.subheadline) + .fontWeight(.bold) + .foregroundColor(.white) + } + } +} + +#Preview { + TutorialOverlay(activeTutorial: .constant(.initial), context: .listening) +}