From 9a0bbe995269627dec6f5cdc9abdca098dfc1912 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Mon, 1 Dec 2025 01:38:11 +0700 Subject: [PATCH 1/3] feat: add tutorial view --- .../Components/Tutorial/TutorialView.swift | 345 ++++++++++++++++++ ...rdingModel.swift => OnboardingModel.swift} | 0 ...Models.swift => OnboardingViewModel.swift} | 0 ...oardingView.swift => OnboardingView.swift} | 0 Tiny/Features/Profile/Views/ProfileView.swift | 9 +- Tiny/Resources/Localizable.xcstrings | 22 +- 6 files changed, 364 insertions(+), 12 deletions(-) create mode 100644 Tiny/Core/Components/Tutorial/TutorialView.swift rename Tiny/Features/Onboarding/Models/{onboardingModel.swift => OnboardingModel.swift} (100%) rename Tiny/Features/Onboarding/ViewModels/{onboardingViewModels.swift => OnboardingViewModel.swift} (100%) rename Tiny/Features/Onboarding/Views/{onboardingView.swift => OnboardingView.swift} (100%) diff --git a/Tiny/Core/Components/Tutorial/TutorialView.swift b/Tiny/Core/Components/Tutorial/TutorialView.swift new file mode 100644 index 0000000..e2748d4 --- /dev/null +++ b/Tiny/Core/Components/Tutorial/TutorialView.swift @@ -0,0 +1,345 @@ +// +// TutorialView.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 01/12/25. +// + +import SwiftUI + +// --------------------------------------------------------- +// MARK: - Highlighted Word Component +// --------------------------------------------------------- +struct HighlightedWordText: View { + let fullText: String + let highlight: String + let highlightColor: Color + + var body: some View { + Text(makeAttributed()) + } + + private func makeAttributed() -> AttributedString { + var attributed = AttributedString(fullText) + + if let range = attributed.range(of: highlight) { + attributed[range].foregroundColor = highlightColor + } + return attributed + } +} + +// --------------------------------------------------------- +// MARK: MAIN TUTORIAL VIEW +// --------------------------------------------------------- +struct TutorialView: View { + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + TutorialPage1() + TutorialPage2() + TutorialPage3() + TutorialPage4() + } + .padding(.bottom, 40) + } + .background( + GeometryReader { geo in + Image("bgPurpleOnboarding") + .resizable() + .scaledToFill() + .frame(width: geo.size.width, + height: geo.size.height) + .clipped() // prevents blank leftover area + } + .ignoresSafeArea() + ) + } +} + +// --------------------------------------------------------- +// MARK: TUTORIAL PAGE 1 +// --------------------------------------------------------- +struct TutorialPage1: View { + @State private var phoneOffset: CGFloat = -40 + @State private var phoneRotation: Double = -5 + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + + Text("Tiny") + .font(.title) + .fontWeight(.bold) + .padding(.horizontal, 30) + + Text("your gentle guide") + .font(.subheadline) + .fontWeight(.medium) + .padding(.horizontal, 30) + .padding(.bottom, 20) + + // Title + HighlightedWordText( + fullText: "What can you do with Tiny", + highlight: "Tiny", + highlightColor: Color("mainYellow") + ) + .font(.title2) + .fontWeight(.bold) + .padding(.horizontal, 30) + + Text("Connect your AirPods and let Tiny access your microphone to hear every little beat.") + .font(.body) + .padding(.horizontal, 30) + + HStack { + Spacer() + ZStack { + VStack { + Image("handHoldingPhone") + .offset(x: phoneOffset) + .rotationEffect(.degrees(phoneRotation)) + .onAppear { + withAnimation(.easeInOut(duration: 2.4).repeatForever(autoreverses: true)) { + phoneOffset = 40 + phoneRotation = 5 + } + } + + Image("stomach") + } + } + Spacer() + } + .padding(.top, 20) + } + .padding(.vertical, 80) + } +} + +// --------------------------------------------------------- +// MARK: TUTORIAL PAGE 2 +// --------------------------------------------------------- +struct TutorialPage2: View { + var body: some View { + VStack(alignment: .leading, spacing: 16) { + + HighlightedWordText( + fullText: "Feel the best experience", + highlight: "best", + highlightColor: Color("mainYellow") + ) + .font(.title2) + .fontWeight(.bold) + .padding(.horizontal, 30) + + Text("Tiny will need access to your microphone so you can hear every tiny beat clearly.") + .font(.body) + .padding(.horizontal, 30) + + HStack { + Spacer() + + 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 + ) + ) + + Spacer() + } + .padding(.top, 20) + } + .padding(.vertical, 80) + } +} + +// --------------------------------------------------------- +// MARK: TUTORIAL PAGE 3 +// --------------------------------------------------------- +struct TutorialPage3: View { + var body: some View { + VStack(alignment: .leading, spacing: 16) { + + HighlightedWordText( + fullText: "Grow through every moment", + highlight: "moment", + highlightColor: Color("mainYellow") + ) + .font(.title2) + .fontWeight(.bold) + .padding(.horizontal, 30) + + Text("Share how you feel today and let love keep you both close.") + .font(.body) + .padding(.horizontal, 30) + + HStack { + Spacer() + + Image("onboardingShareMood") + .resizable() + .scaledToFit() + .frame(height: 180) + .padding(.top, 20) + + Spacer() + } + } + .padding(.vertical, 80) + } +} + +// --------------------------------------------------------- +// MARK: TUTORIAL PAGE 4 +// --------------------------------------------------------- +struct TutorialPage4: View { + + var body: some View { + VStack(alignment: .leading, spacing: 32) { + + // SECTION 1 - full white title + HighlightedWordText( + fullText: "To hear every little beat clearly:", + highlight: "", + highlightColor: Color("mainYellow") + ) + .font(.title3) + .fontWeight(.bold) + + section( + title: "", + bullets: [ + "Allow Tiny to access your microphone", + "Connect your AirPods / TWS", + "Remove your phone case", + "Make sure nothing blocks your iPhone’s mic", + "Place the phone directly on skin", + "Find a quiet room", + "Tiny works offline — no internet needed" + ] + ) + + // SECTION 2 - only "companion" yellow + VStack(alignment: .leading, spacing: 12) { + + HighlightedWordText( + fullText: "Sweet companion for bonding", + highlight: "companion", + highlightColor: Color("mainYellow") + ) + .font(.title3) + .fontWeight(.bold) + + Text("Every pregnancy is beautifully different, so results may vary. Be gentle with yourself if it doesn’t work right away.") + .font(.body) + + section( + title: "", + bullets: [ + "Baby’s position may not align with the mic", + "Skin thickness varies with every pregnancy", + "Sometimes it’s the device or technology", + "Early weeks: heartbeat may still be too faint" + ] + ) + } + + // SECTION 3 - full white title + section( + title: "To hear every little beat clearly:", + bullets: [ + "Baby’s position is unpredictable", + "Skin thickness varies", + "Sometimes it’s simply the device & technology", + "Early weeks: heartbeat may still be too faint" + ] + ) + + // SECTION 4 - only "Navigate" yellow + VStack(alignment: .leading, spacing: 16) { + + HighlightedWordText( + fullText: "Navigate between features", + highlight: "Navigate", + highlightColor: Color("mainYellow") + ) + .font(.title3) + .fontWeight(.bold) + + Text("Control your screen with a simple gesture:") + .font(.body) + + section( + title: "LIVE LISTEN", + bullets: [ + "Start session → Double-tap the sphere", + "Stop session → Press & hold the sphere" + ] + ) + + section( + title: "PLAYBACK", + bullets: [ + "Play recording → Tap the sphere", + "Pause / stop → Tap again" + ] + ) + + section( + title: "SAVE RECORDING", + bullets: [ + "Hold + drag down → Delete recording", + "Hold + swipe up → Save recording" + ] + ) + } + } + .padding(.horizontal, 30) + .padding(.vertical, 40) + } + + // ----------------------------------------------------- + // MARK: UNIVERSAL BULLET SECTION + // ----------------------------------------------------- + func section(title: String, bullets: [String]) -> some View { + VStack(alignment: .leading, spacing: 12) { + + if !title.isEmpty { + Text(title) + .font(.headline) + } + + VStack(alignment: .leading, spacing: 8) { + ForEach(bullets, id: \.self) { bullet in + HStack(alignment: .top, spacing: 8) { + Image(systemName: "circle.fill") + .font(.system(size: 6)) + .padding(.top, 6) + + Text(bullet) + .font(.body) + .multilineTextAlignment(.leading) + } + } + } + } + } +} + +#Preview { + TutorialView() + .preferredColorScheme(.dark) +} diff --git a/Tiny/Features/Onboarding/Models/onboardingModel.swift b/Tiny/Features/Onboarding/Models/OnboardingModel.swift similarity index 100% rename from Tiny/Features/Onboarding/Models/onboardingModel.swift rename to Tiny/Features/Onboarding/Models/OnboardingModel.swift diff --git a/Tiny/Features/Onboarding/ViewModels/onboardingViewModels.swift b/Tiny/Features/Onboarding/ViewModels/OnboardingViewModel.swift similarity index 100% rename from Tiny/Features/Onboarding/ViewModels/onboardingViewModels.swift rename to Tiny/Features/Onboarding/ViewModels/OnboardingViewModel.swift diff --git a/Tiny/Features/Onboarding/Views/onboardingView.swift b/Tiny/Features/Onboarding/Views/OnboardingView.swift similarity index 100% rename from Tiny/Features/Onboarding/Views/onboardingView.swift rename to Tiny/Features/Onboarding/Views/OnboardingView.swift diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift index c28f0de..d75aa44 100644 --- a/Tiny/Features/Profile/Views/ProfileView.swift +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -27,8 +27,7 @@ struct ProfileView: View { var body: some View { ZStack { - Image(themeManager.selectedBackground.imageName) - + Image(themeManager.selectedBackground.imageName) .resizable() .scaledToFill() .ignoresSafeArea() @@ -80,6 +79,7 @@ struct ProfileView: View { NavigationLink { ProfilePhotoDetailView(viewModel: viewModel) + .environmentObject(authService) } label: { profileImageView(size: size) } @@ -94,6 +94,7 @@ struct ProfileView: View { } .frame(height: 260) } + .listRowBackground(Color.clear) } private func profileImageView(size: CGFloat) -> some View { @@ -140,7 +141,7 @@ struct ProfileView: View { Label("Theme", systemImage: "paintpalette.fill") .foregroundStyle(.white) } - NavigationLink(destination: TutorialDummy()) { + NavigationLink(destination: TutorialView()) { Label("Tutorial", systemImage: "book.fill") .foregroundStyle(.white) } @@ -518,6 +519,6 @@ struct TutorialDummy: View { ProfileView() .environmentObject(AuthenticationService()) // <-- mock .environmentObject(HeartbeatSyncManager()) - + .environmentObject(ThemeManager()) .preferredColorScheme(.dark) } diff --git a/Tiny/Resources/Localizable.xcstrings b/Tiny/Resources/Localizable.xcstrings index 2622d11..dac1a8e 100644 --- a/Tiny/Resources/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -197,6 +197,9 @@ "Continue" : { "comment" : "A button label that says \"Continue\" when a user pauses listening to a podcast.", "isCommentAutoGenerated" : true + }, + "Control your screen with a simple gesture:" : { + }, "Copied!" : { "comment" : "A confirmation message displayed when the room code is successfully copied to the clipboard.", @@ -239,14 +242,6 @@ }, "Drag up to delete" : { - }, - "Dummy Theme View" : { - "comment" : "A placeholder view representing a theme-related screen.", - "isCommentAutoGenerated" : true - }, - "Dummy Tutorial View" : { - "comment" : "A placeholder view for a tutorial screen.", - "isCommentAutoGenerated" : true }, "Edit Profile" : { "comment" : "The title of the view that allows users to edit their profile information.", @@ -279,6 +274,9 @@ "EQ Test" : { "comment" : "A tab label for the audio processing test view.", "isCommentAutoGenerated" : true + }, + "Every pregnancy is beautifully different, so results may vary. Be gentle with yourself if it doesn’t work right away." : { + }, "Export CSV" : { "comment" : "A button to export the processed heartbeat data to a CSV file.", @@ -718,6 +716,10 @@ "comment" : "A section header that describes the time range selection feature.", "isCommentAutoGenerated" : true }, + "Tiny" : { + "comment" : "A title displayed at the top of the page.", + "isCommentAutoGenerated" : true + }, "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 @@ -758,6 +760,10 @@ "comment" : "A description of what happens when they save their name edit.", "isCommentAutoGenerated" : true }, + "your gentle guide" : { + "comment" : "A description of what Tiny is.", + "isCommentAutoGenerated" : true + }, "Your recording is deleted." : { "comment" : "A message displayed after successfully deleting a recording.", "isCommentAutoGenerated" : true From 95ef91c42e4448bbcbf149ad5ec3ff455e0ecdc8 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Mon, 1 Dec 2025 02:04:24 +0700 Subject: [PATCH 2/3] fix: tabview indicator apppear in profile --- .../LiveListen/Views/HeartbeatMainView.swift | 48 ++++++++++++------- Tiny/Resources/Localizable.xcstrings | 8 ++++ 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift index 1bc5f4a..6636127 100644 --- a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift +++ b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift @@ -61,23 +61,7 @@ struct HeartbeatMainView: View { .ignoresSafeArea() // Page indicator dots - VStack { - Spacer() - HStack(spacing: 8) { - // Timeline dot (page 0) - Circle() - .fill(viewModel.currentPage == 0 ? Color.white : Color.white.opacity(0.3)) - .frame(width: 8, height: 8) - .animation(.easeInOut(duration: 0.2), value: viewModel.currentPage) - - // Orb dot (page 1) - Circle() - .fill(viewModel.currentPage == 1 ? Color.white : Color.white.opacity(0.3)) - .frame(width: 8, height: 8) - .animation(.easeInOut(duration: 0.2), value: viewModel.currentPage) - } - .padding(.bottom, 20) - } + PageIndicators(viewModel: viewModel, manager: viewModel.heartbeatSoundManager) // SavedRecordingPlaybackView overlay if let recording = viewModel.selectedRecording { @@ -152,6 +136,36 @@ struct HeartbeatMainView: View { } } +private struct PageIndicators: View { + @ObservedObject var viewModel: HeartbeatMainViewModel + @ObservedObject var manager: HeartbeatSoundManager + + var body: some View { + if manager.isRecording || manager.isPlayingPlayback || viewModel.selectedRecording != nil || !viewModel.allowTabViewSwipe { + EmptyView() + } else { + VStack { + Spacer() + HStack(spacing: 8) { + // Timeline dot (page 0) + Circle() + .fill(viewModel.currentPage == 0 ? Color.white : Color.white.opacity(0.3)) + .frame(width: 8, height: 8) + .animation(.easeInOut(duration: 0.2), value: viewModel.currentPage) + + // Orb dot (page 1) + Circle() + .fill(viewModel.currentPage == 1 ? Color.white : Color.white.opacity(0.3)) + .frame(width: 8, height: 8) + .animation(.easeInOut(duration: 0.2), value: viewModel.currentPage) + } + .padding(.bottom, 20) + } + .transition(.opacity) + } + } +} + #Preview { HeartbeatMainView() .modelContainer(for: SavedHeartbeat.self, inMemory: true) diff --git a/Tiny/Resources/Localizable.xcstrings b/Tiny/Resources/Localizable.xcstrings index dac1a8e..03eec97 100644 --- a/Tiny/Resources/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -242,6 +242,14 @@ }, "Drag up to delete" : { + }, + "Dummy Theme View" : { + "comment" : "A placeholder view for a theme-related screen.", + "isCommentAutoGenerated" : true + }, + "Dummy Tutorial View" : { + "comment" : "A placeholder text indicating a view that will be replaced with a tutorial view.", + "isCommentAutoGenerated" : true }, "Edit Profile" : { "comment" : "The title of the view that allows users to edit their profile information.", From 4476abc6377189fc105869564c48b4e2c0140290 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Mon, 1 Dec 2025 02:43:18 +0700 Subject: [PATCH 3/3] fix: scroll in tutorialView --- Tiny/Core/Components/Tutorial/TutorialView.swift | 15 ++++++--------- .../LiveListen/Views/HeartbeatMainView.swift | 5 +++-- Tiny/Features/Profile/Views/ProfileView.swift | 2 +- .../Timeline/Views/PregnancyTimelineView.swift | 6 +++--- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/Tiny/Core/Components/Tutorial/TutorialView.swift b/Tiny/Core/Components/Tutorial/TutorialView.swift index e2748d4..d38de65 100644 --- a/Tiny/Core/Components/Tutorial/TutorialView.swift +++ b/Tiny/Core/Components/Tutorial/TutorialView.swift @@ -44,16 +44,13 @@ struct TutorialView: View { .padding(.bottom, 40) } .background( - GeometryReader { geo in - Image("bgPurpleOnboarding") - .resizable() - .scaledToFill() - .frame(width: geo.size.width, - height: geo.size.height) - .clipped() // prevents blank leftover area - } - .ignoresSafeArea() + Image("bgPurpleOnboarding") + .resizable() + .scaledToFill() + .ignoresSafeArea() ) + .navigationTitle("Tutorial") + .navigationBarTitleDisplayMode(.inline) } } diff --git a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift index 6636127..d25f32a 100644 --- a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift +++ b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift @@ -55,8 +55,9 @@ struct HeartbeatMainView: View { } .tabViewStyle(.page(indexDisplayMode: .never)) .highPriorityGesture( - // Block TabView swipe when navigation is active - !viewModel.allowTabViewSwipe ? DragGesture() : nil + // Block TabView swipe only on Orb View (page 1) when requested (e.g. recording/dragging) + // On Timeline View (page 0), we allow gestures to pass through so vertical scrolling (Profile/Tutorial) works + (viewModel.currentPage == 1 && !viewModel.allowTabViewSwipe) ? DragGesture() : nil ) .ignoresSafeArea() diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift index d75aa44..83e9b44 100644 --- a/Tiny/Features/Profile/Views/ProfileView.swift +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -27,7 +27,7 @@ struct ProfileView: View { var body: some View { ZStack { - Image(themeManager.selectedBackground.imageName) + Image(themeManager.selectedBackground.imageName) .resizable() .scaledToFill() .ignoresSafeArea() diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index c641bcf..075eda7 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -102,9 +102,6 @@ struct PregnancyTimelineView: View { .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) } .opacity(isFirstTimeVisit ? (animationController.profileVisible ? 1.0 : 0.0) : 1.0) - .navigationDestination(isPresented: $isProfilePresented) { - ProfileView() - } } } .padding(.horizontal, 20) @@ -114,6 +111,9 @@ struct PregnancyTimelineView: View { } } } + .navigationDestination(isPresented: $isProfilePresented) { + ProfileView() + } .onAppear(perform: groupRecordings) } .onAppear {