From 850679f5649389397d53ab651355d226533772c9 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Wed, 26 Nov 2025 23:59:41 +0700 Subject: [PATCH 01/44] feat: add profile screen --- .../Profile/ViewModels/ProfileViewModel.swift | 119 +++++ Tiny/Features/Profile/Views/ProfileView.swift | 477 ++++++++++++++++++ .../mainViolet.colorset/Contents.json | 38 ++ .../rowProfileGrey.colorset/Contents.json | 38 ++ 4 files changed, 672 insertions(+) create mode 100644 Tiny/Features/Profile/ViewModels/ProfileViewModel.swift create mode 100644 Tiny/Features/Profile/Views/ProfileView.swift create mode 100644 Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json create mode 100644 Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json diff --git a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift new file mode 100644 index 0000000..71f2b09 --- /dev/null +++ b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift @@ -0,0 +1,119 @@ +// +// ProfileViewModel.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 26/11/25. +// + +import SwiftUI +internal import Combine + +@MainActor +class ProfileViewModel: ObservableObject { + @Published var isSignedIn: Bool = false + @Published var profileImage: UIImage? + @Published var userName: String = "Guest" + @Published var userEmail: String? + + // Settings + @AppStorage("appTheme") var appTheme: String = "System" + @AppStorage("isUserSignedIn") private var storedSignInStatus: Bool = false + @AppStorage("savedUserName") private var savedUserName: String? + @AppStorage("savedUserEmail") private var savedUserEmail: String? + + private var cancellables = Set() + + init() { + loadUserData() + } + + // MARK: - Data Persistence + + private func loadUserData() { + isSignedIn = storedSignInStatus + + if let savedName = savedUserName { + userName = savedName + } + + if let savedEmail = savedUserEmail { + userEmail = savedEmail + } + + loadProfileImageFromDisk() + } + + func saveName() { + savedUserName = userName + print("Name saved: \(userName)") + } + + private func saveProfileImageToDisk() { + guard let image = profileImage, + let data = image.jpegData(compressionQuality: 0.8) else { return } + + let fileURL = getProfileImageURL() + try? data.write(to: fileURL) + } + + private func loadProfileImageFromDisk() { + let fileURL = getProfileImageURL() + guard let data = try? Data(contentsOf: fileURL), + let image = UIImage(data: data) else { return } + + profileImage = image + } + + private func deleteProfileImageFromDisk() { + let fileURL = getProfileImageURL() + try? FileManager.default.removeItem(at: fileURL) + } + + private func getProfileImageURL() -> URL { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return documentsPath.appendingPathComponent("profileImage.jpg") + } + + // MARK: - Authentication (Dummy Implementation) + + /// Dummy sign in - Replace this with real Apple Sign In implementation + func signIn() { + // TODO: Implement real Apple Sign In here + // This is a placeholder for demonstration + isSignedIn = true + userName = "John Doe" + userEmail = "john.doe@example.com" + + // Persist sign in status + storedSignInStatus = true + savedUserName = userName + savedUserEmail = userEmail + + print("User signed in (dummy)") + } + + /// Sign out user and clear all data + func signOut() { + isSignedIn = false + profileImage = nil + userName = "Guest" + userEmail = nil + + // Clear persisted data + storedSignInStatus = false + savedUserName = nil + savedUserEmail = nil + deleteProfileImageFromDisk() + + print("User signed out") + } + + // MARK: - Profile Image Management + + func updateProfileImage(_ image: UIImage?) { + profileImage = image + if image != nil { + saveProfileImageToDisk() + } + } +} diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift new file mode 100644 index 0000000..dc3870b --- /dev/null +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -0,0 +1,477 @@ +// +// ProfileView.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 26/11/25. +// + +import SwiftUI + +struct ProfileView: View { + @StateObject private var viewModel = ProfileViewModel() + @State private var showingSignOutConfirmation = false + + var body: some View { + NavigationStack { + ZStack { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .ignoresSafeArea() + + VStack(spacing: 0) { + // HEADER + profileHeader + .padding(.bottom, 30) + + // FEATURE CARDS + featureCards + .padding(.horizontal, 16) + .frame(height: 160) + + // SETTINGS LIST + settingsList + } + } + } + } + + // MARK: - View Components + + private var profileHeader: some View { + VStack(spacing: 16) { + GeometryReader { geo in + let size = geo.size.width * 0.28 + + VStack(spacing: 16) { + Spacer() + + NavigationLink { + ProfilePhotoDetailView(viewModel: viewModel) + } label: { + profileImageView(size: size) + } + .buttonStyle(.plain) + + Text(viewModel.isSignedIn ? viewModel.userName : "Guest") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(.white) + } + .frame(width: geo.size.width) + } + .frame(height: 260) + } + } + + private func profileImageView(size: CGFloat) -> some View { + Group { + if let img = viewModel.profileImage { + Image(uiImage: img) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.gray) + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + } + + private var featureCards: some View { + HStack(spacing: 12) { + let padding: CGFloat = 5 + let spacing: CGFloat = 12 + let cardWidth = (UIScreen.main.bounds.width - (padding * 5 + spacing)) / 2 + + featureCardLeft(width: cardWidth) + featureCardRight(width: cardWidth) + } + } + + private var settingsList: some View { + List { + settingsSection + accountSection + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(Color.clear) + } + + private var settingsSection: some View { + Section { + NavigationLink(destination: ThemeDummy()) { + Label("Theme", systemImage: "paintpalette.fill") + .foregroundStyle(.white) + } + NavigationLink(destination: TutorialDummy()) { + Label("Tutorial", systemImage: "book.fill") + .foregroundStyle(.white) + } + Link(destination: URL(string: "https://example.com/privacy")!) { + Label("Privacy Policy", systemImage: "shield.righthalf.filled") + .foregroundStyle(.white) + } + Link(destination: URL(string: "https://example.com/terms")!) { + Label("Terms and Conditions", systemImage: "doc.text") + .foregroundStyle(.white) + } + } + .listRowBackground(Color("rowProfileGrey")) + } + + private var accountSection: some View { + Section { + if viewModel.isSignedIn { + signedInView + } else { + signInView + } + } header: { + Text("Account") + } footer: { + if !viewModel.isSignedIn { + Text("Your privacy is protected. We only use your Apple ID to securely save your data.") + .font(.caption2) + } + } + .listRowBackground(Color("rowProfileGrey")) + } + + private var signedInView: some View { + Group { + Button(role: .destructive) { + showingSignOutConfirmation = true + } label: { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + .foregroundStyle(.red) + } + .confirmationDialog( + "Sign Out", + isPresented: $showingSignOutConfirmation, + titleVisibility: .visible + ) { + Button("Sign Out", role: .destructive) { + viewModel.signOut() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("You'll need to sign in again to sync your data and access personalized features.") + } + } + } + + private var signInView: some View { + VStack(spacing: 12) { + // Dummy Sign In Button styled like Apple's + Button { + viewModel.signIn() + } label: { + HStack { + Image(systemName: "applelogo") + .font(.system(size: 20, weight: .medium)) + Text("Sign in with Apple") + .font(.system(size: 17, weight: .semibold)) + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .foregroundStyle(.black) + .background(Color.white) + .cornerRadius(8) + } + .buttonStyle(.plain) + + Text("Sign in to sync your pregnancy journey across devices") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.vertical, 8) + } + + // MARK: - Feature Cards + + private func featureCardLeft(width: CGFloat) -> some View { + VStack(alignment: .leading) { + HStack { + Image(systemName: "heart.fill") + .font(.caption) + .foregroundColor(.white) + Text("Connected Journey") + .font(.caption) + .foregroundColor(.gray) + } + + Spacer() + + Text("Connect with Your Partner") + .font(.subheadline) + .foregroundColor(.blue) + .fontWeight(.medium) + + Spacer() + } + .frame(width: width, height: width * 0.63, alignment: .topLeading) + .padding() + .background(Color("rowProfileGrey")) + .cornerRadius(14) + } + + private func featureCardRight(width: CGFloat) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "calendar") + .font(.caption) + .foregroundColor(.white) + Text("Pregnancy Age") + .font(.caption) + .foregroundColor(.gray) + } + + Spacer() + + VStack(alignment: .center, spacing: 4) { + Text("20") + .font(.title) + .fontWeight(.bold) + .foregroundColor(Color("mainViolet")) + Text("Weeks") + .font(.subheadline) + .foregroundColor(Color("mainViolet")) + } + .frame(maxWidth: .infinity, alignment: .center) + + Spacer() + } + .frame(width: width * 0.65, height: width * 0.63) + .padding() + .background(Color("rowProfileGrey")) + .cornerRadius(14) + } +} + +struct ProfilePhotoDetailView: View { + @ObservedObject var viewModel: ProfileViewModel + @State private var showingImagePicker = false + @State private var showingCamera = false + @State private var showingPhotoOptions = false + @State private var tempUserName: String = "" + @Environment(\.dismiss) private var dismiss + + var body: some View { + ZStack { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .ignoresSafeArea() + + VStack(spacing: 30) { + profilePhotoButton + .padding(.top, 80) + + nameEditSection + .padding(.horizontal, 30) + + Spacer() + } + } + .navigationTitle("Edit Profile") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + viewModel.userName = tempUserName + viewModel.saveName() + dismiss() + } + .disabled(tempUserName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .onAppear { + tempUserName = viewModel.userName + } + .sheet(isPresented: $showingPhotoOptions) { + BottomPhotoPickerSheet( + showingCamera: $showingCamera, + showingImagePicker: $showingImagePicker + ) + } + .sheet(isPresented: $showingImagePicker) { + ImagePicker(image: $viewModel.profileImage, sourceType: .photoLibrary) + } + .fullScreenCover(isPresented: $showingCamera) { + ImagePicker(image: $viewModel.profileImage, sourceType: .camera) + } + } + + private var profilePhotoButton: some View { + Button { + showingPhotoOptions = true + } label: { + ZStack(alignment: .bottomTrailing) { + Group { + if let profileImage = viewModel.profileImage { + Image(uiImage: profileImage) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.gray) + } + } + .frame(width: 200, height: 200) + .clipShape(Circle()) + .overlay( + Circle() + .stroke(Color.white, lineWidth: 3) + ) + + // Camera badge + Image(systemName: "camera.circle.fill") + .font(.system(size: 44)) + .foregroundStyle(.white, Color("rowProfileGrey")) + .offset(x: -10, y: -10) + } + } + .buttonStyle(.plain) + } + + private var nameEditSection: some View { + VStack(spacing: 16) { + HStack { + Text("Name") + .foregroundColor(.gray) + .font(.subheadline) + Spacer() + } + + TextField("Enter your name", text: $tempUserName) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.words) + .submitLabel(.done) + } + } +} + +struct BottomPhotoPickerSheet: View { + @Binding var showingCamera: Bool + @Binding var showingImagePicker: Bool + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 20) { + Text("Change Profile Photo") + .font(.headline) + .foregroundColor(.primary) + .padding(.bottom, 8) + + PhotoPickerButton( + title: "Take Photo", + icon: "camera", + action: { + dismiss() + showingCamera = true + } + ) + + PhotoPickerButton( + title: "Choose From Library", + icon: "photo.on.rectangle", + action: { + dismiss() + showingImagePicker = true + } + ) + } + .padding() + .presentationDetents([.height(220)]) + .presentationDragIndicator(.visible) + } +} + +private struct PhotoPickerButton: View { + let title: String + let icon: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + Image(systemName: icon) + .font(.system(size: 18)) + Text(title) + .font(.system(size: 16, weight: .medium)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(Color("rowProfileGrey")) + .cornerRadius(12) + } + .buttonStyle(.plain) + } +} + +struct ImagePicker: UIViewControllerRepresentable { + @Binding var image: UIImage? + let sourceType: UIImagePickerController.SourceType + @Environment(\.dismiss) var dismiss + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = sourceType + picker.delegate = context.coordinator + picker.allowsEditing = false + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let parent: ImagePicker + + init(_ parent: ImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + if let image = info[.originalImage] as? UIImage { + parent.image = image + } + parent.dismiss() + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.dismiss() + } + } +} + +struct ThemeDummy: View { + var body: some View { + Text("Dummy Theme View") + .navigationTitle("Theme") + } +} + +struct TutorialDummy: View { + var body: some View { + Text("Dummy Tutorial View") + .navigationTitle("Tutorial") + } +} + +#Preview { + ProfileView() + .preferredColorScheme(.dark) +} diff --git a/Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json new file mode 100644 index 0000000..7d97c94 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE8", + "green" : "0x95", + "red" : "0x95" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE8", + "green" : "0x95", + "red" : "0x95" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json new file mode 100644 index 0000000..c195a24 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x28", + "green" : "0x21", + "red" : "0x21" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x28", + "green" : "0x21", + "red" : "0x21" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} From 1fbb098183b69899454f3841b794bc998c8719d1 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 00:24:00 +0700 Subject: [PATCH 02/44] feat: add profile navigation in PregnancyTimelineView --- Tiny.xcodeproj/project.pbxproj | 6 + .../Profile/Models/UserProfileManager.swift | 106 ++++++++++++++ .../Profile/ViewModels/ProfileViewModel.swift | 135 ++++++------------ Tiny/Features/Profile/Views/ProfileView.swift | 42 +++--- .../Views/PregnancyTimelineView.swift | 76 +++++++--- Tiny/Info.plist | 7 +- Tiny/Resources/Localizable.xcstrings | 84 +++++++++++ 7 files changed, 321 insertions(+), 135 deletions(-) create mode 100644 Tiny/Features/Profile/Models/UserProfileManager.swift diff --git a/Tiny.xcodeproj/project.pbxproj b/Tiny.xcodeproj/project.pbxproj index 97285b3..9823b13 100644 --- a/Tiny.xcodeproj/project.pbxproj +++ b/Tiny.xcodeproj/project.pbxproj @@ -472,8 +472,11 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Tiny/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Tiny; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; + INFOPLIST_KEY_NSCameraUsageDescription = "Allow Tiny to use camera for taking profile picture"; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Tiny needs to use the microphone to capture heartbeat sounds."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Allow Tiny to access photos library"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -513,8 +516,11 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Tiny/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Tiny; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; + INFOPLIST_KEY_NSCameraUsageDescription = "Allow Tiny to use camera for taking profile picture"; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Tiny needs to use the microphone to capture heartbeat sounds."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Allow Tiny to access photos library"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/Tiny/Features/Profile/Models/UserProfileManager.swift b/Tiny/Features/Profile/Models/UserProfileManager.swift new file mode 100644 index 0000000..60a0209 --- /dev/null +++ b/Tiny/Features/Profile/Models/UserProfileManager.swift @@ -0,0 +1,106 @@ +// +// UserProfileManager.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 27/11/25. +// + +import SwiftUI +internal import Combine + +class UserProfileManager: ObservableObject { + static let shared = UserProfileManager() + + @Published var profileImage: UIImage? + @Published var userName: String = "Guest" + @Published var userEmail: String? + @Published var isSignedIn: Bool = false + + // Persistence Keys + private let kUserName = "savedUserName" + private let kUserEmail = "savedUserEmail" + private let kIsSignedIn = "isUserSignedIn" + + private init() { + loadUserData() + } + + // MARK: - Data Persistence + + func loadUserData() { + let defaults = UserDefaults.standard + isSignedIn = defaults.bool(forKey: kIsSignedIn) + + if let savedName = defaults.string(forKey: kUserName) { + userName = savedName + } + + if let savedEmail = defaults.string(forKey: kUserEmail) { + userEmail = savedEmail + } + + loadProfileImageFromDisk() + } + + func saveUserData() { + let defaults = UserDefaults.standard + defaults.set(isSignedIn, forKey: kIsSignedIn) + defaults.set(userName, forKey: kUserName) + defaults.set(userEmail, forKey: kUserEmail) + } + + func saveProfileImage(_ image: UIImage?) { + profileImage = image + + if let image = image { + saveProfileImageToDisk(image) + } else { + deleteProfileImageFromDisk() + } + } + + private func saveProfileImageToDisk(_ image: UIImage) { + guard let data = image.jpegData(compressionQuality: 0.8) else { return } + let fileURL = getProfileImageURL() + try? data.write(to: fileURL) + } + + private func loadProfileImageFromDisk() { + let fileURL = getProfileImageURL() + guard let data = try? Data(contentsOf: fileURL), + let image = UIImage(data: data) else { + profileImage = nil + return + } + profileImage = image + } + + private func deleteProfileImageFromDisk() { + let fileURL = getProfileImageURL() + try? FileManager.default.removeItem(at: fileURL) + } + + private func getProfileImageURL() -> URL { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return documentsPath.appendingPathComponent("profileImage.jpg") + } + + // MARK: - Actions + + func signOut() { + isSignedIn = false + userName = "Guest" + userEmail = nil + profileImage = nil + + saveUserData() + deleteProfileImageFromDisk() + } + + func signInDummy() { + isSignedIn = true + userName = "John Doe" + userEmail = "john.doe@example.com" + saveUserData() + } +} diff --git a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift index 71f2b09..0bb02bb 100644 --- a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift +++ b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift @@ -8,112 +8,69 @@ import SwiftUI internal import Combine -@MainActor -class ProfileViewModel: ObservableObject { - @Published var isSignedIn: Bool = false - @Published var profileImage: UIImage? - @Published var userName: String = "Guest" - @Published var userEmail: String? +// +// ProfileViewModel.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 26/11/25. +// - // Settings - @AppStorage("appTheme") var appTheme: String = "System" - @AppStorage("isUserSignedIn") private var storedSignInStatus: Bool = false - @AppStorage("savedUserName") private var savedUserName: String? - @AppStorage("savedUserEmail") private var savedUserEmail: String? +import SwiftUI +internal import Combine +@MainActor +class ProfileViewModel: ObservableObject { + // Observe the singleton manager so this ViewModel publishes changes when manager changes + var manager = UserProfileManager.shared private var cancellables = Set() - + + @AppStorage("appTheme") var appTheme: String = "System" + init() { - loadUserData() - } - - // MARK: - Data Persistence - - private func loadUserData() { - isSignedIn = storedSignInStatus - - if let savedName = savedUserName { - userName = savedName - } - - if let savedEmail = savedUserEmail { - userEmail = savedEmail - } - - loadProfileImageFromDisk() + // Propagate manager changes to this ViewModel + manager.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) } - - func saveName() { - savedUserName = userName - print("Name saved: \(userName)") + + // MARK: - Computed Properties for View Bindings + + var isSignedIn: Bool { + manager.isSignedIn } - - private func saveProfileImageToDisk() { - guard let image = profileImage, - let data = image.jpegData(compressionQuality: 0.8) else { return } - - let fileURL = getProfileImageURL() - try? data.write(to: fileURL) + + var userName: String { + get { manager.userName } + set { manager.userName = newValue } } - - private func loadProfileImageFromDisk() { - let fileURL = getProfileImageURL() - guard let data = try? Data(contentsOf: fileURL), - let image = UIImage(data: data) else { return } - - profileImage = image + + var userEmail: String? { + manager.userEmail } - - private func deleteProfileImageFromDisk() { - let fileURL = getProfileImageURL() - try? FileManager.default.removeItem(at: fileURL) + + // For ImagePicker binding + var profileImage: UIImage? { + get { manager.profileImage } + set { manager.saveProfileImage(newValue) } } + + // MARK: - Actions - private func getProfileImageURL() -> URL { - let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - return documentsPath.appendingPathComponent("profileImage.jpg") + func saveName() { + manager.saveUserData() + print("Name saved: \(manager.userName)") } - // MARK: - Authentication (Dummy Implementation) - - /// Dummy sign in - Replace this with real Apple Sign In implementation func signIn() { - // TODO: Implement real Apple Sign In here - // This is a placeholder for demonstration - isSignedIn = true - userName = "John Doe" - userEmail = "john.doe@example.com" - - // Persist sign in status - storedSignInStatus = true - savedUserName = userName - savedUserEmail = userEmail - + manager.signInDummy() print("User signed in (dummy)") } - /// Sign out user and clear all data func signOut() { - isSignedIn = false - profileImage = nil - userName = "Guest" - userEmail = nil - - // Clear persisted data - storedSignInStatus = false - savedUserName = nil - savedUserEmail = nil - deleteProfileImageFromDisk() - + manager.signOut() print("User signed out") } - - // MARK: - Profile Image Management - - func updateProfileImage(_ image: UIImage?) { - profileImage = image - if image != nil { - saveProfileImageToDisk() - } - } } + diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift index dc3870b..0e45d41 100644 --- a/Tiny/Features/Profile/Views/ProfileView.swift +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -12,26 +12,24 @@ struct ProfileView: View { @State private var showingSignOutConfirmation = false var body: some View { - NavigationStack { - ZStack { - Image("backgroundPurple") - .resizable() - .scaledToFill() - .ignoresSafeArea() + ZStack { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .ignoresSafeArea() - VStack(spacing: 0) { - // HEADER - profileHeader - .padding(.bottom, 30) + VStack(spacing: 0) { + // HEADER + profileHeader + .padding(.bottom, 30) - // FEATURE CARDS - featureCards - .padding(.horizontal, 16) - .frame(height: 160) + // FEATURE CARDS + featureCards + .padding(.horizontal, 16) + .frame(height: 160) - // SETTINGS LIST - settingsList - } + // SETTINGS LIST + settingsList } } } @@ -301,10 +299,16 @@ struct ProfilePhotoDetailView: View { ) } .sheet(isPresented: $showingImagePicker) { - ImagePicker(image: $viewModel.profileImage, sourceType: .photoLibrary) + ImagePicker(image: Binding( + get: { viewModel.profileImage }, + set: { viewModel.profileImage = $0 } + ), sourceType: .photoLibrary) } .fullScreenCover(isPresented: $showingCamera) { - ImagePicker(image: $viewModel.profileImage, sourceType: .camera) + ImagePicker(image: Binding( + get: { viewModel.profileImage }, + set: { viewModel.profileImage = $0 } + ), sourceType: .camera) } } diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index 3c8ca1f..97a348e 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -17,31 +17,36 @@ struct PregnancyTimelineView: View { @State private var selectedWeek: WeekSection? @State private var groupedData: [WeekSection] = [] + @ObservedObject private var userProfile = UserProfileManager.shared + var body: some View { - ZStack { - LinearGradient(colors: [Color(red: 0.05, green: 0.05, blue: 0.15), Color.black], startPoint: .top, endPoint: .bottom) - .ignoresSafeArea() - + NavigationStack { ZStack { - if let week = selectedWeek { - TimelineDetailView(week: week, animation: animation, onSelectRecording: onSelectRecording) - .transition(.opacity) - } else { - MainTimelineListView(groupedData: groupedData, selectedWeek: $selectedWeek, animation: animation) - .transition(.opacity) + 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 } - - navigationButtons + .onAppear(perform: groupRecordings) } - .onAppear(perform: groupRecordings) } private var navigationButtons: some View { VStack { - if selectedWeek != nil { - // Back Button (Detail -> List) - HStack { + // Top Bar + HStack { + if selectedWeek != nil { + // Back Button (Detail -> List) Button { withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { selectedWeek = nil } } label: { @@ -53,14 +58,43 @@ struct PregnancyTimelineView: View { } .glassEffect(.clear) .matchedGeometryEffect(id: "navButton", in: animation) - .padding(.leading, 20) - .padding(.top, 0) + } else { Spacer() } + Spacer() - } else { - // ⬇️ Book Button (List -> Close to Orb) - Spacer() + + // Profile Button (Top Right) + if selectedWeek == nil { + NavigationLink { + ProfileView() + } label: { + Group { + if let image = userProfile.profileImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.white.opacity(0.8)) + } + } + .frame(width: 44, height: 44) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) + .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) + } + } + } + .padding(.horizontal, 20) + .padding(.top, 60) + + Spacer() + + if selectedWeek == nil { + // Book Button (List -> Close to Orb) Button { withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { showTimeline = false diff --git a/Tiny/Info.plist b/Tiny/Info.plist index 9b9d450..0c67376 100644 --- a/Tiny/Info.plist +++ b/Tiny/Info.plist @@ -1,10 +1,5 @@ - - CFBundleIdentifier - - ITSAppUsesNonExemptEncryption - - + diff --git a/Tiny/Resources/Localizable.xcstrings b/Tiny/Resources/Localizable.xcstrings index cb42ba6..af47e06 100644 --- a/Tiny/Resources/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -72,6 +72,10 @@ "comment" : "A caption displayed alongside the slider for adjusting the gain of the microphone.", "isCommentAutoGenerated" : true }, + "20" : { + "comment" : "The number \"20\" displayed in the \"Pregnancy Age\" feature card.", + "isCommentAutoGenerated" : true + }, "20Hz" : { }, @@ -83,6 +87,10 @@ "comment" : "A label displayed next to the rightmost text in the \"Frequency Spectrum\" section of the audio visualization view.", "isCommentAutoGenerated" : true }, + "Account" : { + "comment" : "A section header in the profile view that reads \"Account\".", + "isCommentAutoGenerated" : true + }, "Adaptive Gain" : { "comment" : "A label displayed above a toggle switch that controls adaptive gain.", "isCommentAutoGenerated" : true @@ -146,6 +154,10 @@ "comment" : "A button label that dismisses an alert.", "isCommentAutoGenerated" : true }, + "Change Profile Photo" : { + "comment" : "A title for the bottom sheet that appears when the user taps on their profile photo.", + "isCommentAutoGenerated" : true + }, "Confidence" : { "comment" : "A label describing the confidence level of a heartbeat reading.", "isCommentAutoGenerated" : true @@ -154,6 +166,10 @@ "comment" : "A button label that, when tapped, will direct the user to configure Bluetooth on their device.", "isCommentAutoGenerated" : true }, + "Connect with Your Partner" : { + "comment" : "A call-to-action text that encourages users to connect with their partners.", + "isCommentAutoGenerated" : true + }, "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 @@ -162,6 +178,10 @@ "comment" : "A label displayed next to the name of the currently connected audio device.", "isCommentAutoGenerated" : true }, + "Connected Journey" : { + "comment" : "A feature card title.", + "isCommentAutoGenerated" : true + }, "Continue" : { "comment" : "A button label that says \"Continue\" when a user pauses listening to a podcast.", "isCommentAutoGenerated" : true @@ -182,10 +202,26 @@ "comment" : "A text label that appears when the user drags their finger over the orb to save a new recording.", "isCommentAutoGenerated" : true }, + "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.", + "isCommentAutoGenerated" : true + }, "Enhanced proximity audio" : { "comment" : "A description of the spatial audio mode.", "isCommentAutoGenerated" : true }, + "Enter your name" : { + "comment" : "A placeholder text for a text field where a user can enter their name.", + "isCommentAutoGenerated" : true + }, "EQ Configuration" : { }, @@ -302,6 +338,10 @@ "comment" : "The title of an alert that appears when microphone access is denied.", "isCommentAutoGenerated" : true }, + "Name" : { + "comment" : "A label describing the user's name.", + "isCommentAutoGenerated" : true + }, "No heartbeat detected yet" : { "comment" : "A message displayed when no heartbeat data is available.", "isCommentAutoGenerated" : true @@ -358,6 +398,10 @@ "comment" : "A description of how to finish a session by pressing and holding a sphere.", "isCommentAutoGenerated" : true }, + "Privacy Policy" : { + "comment" : "A label for a privacy policy option in the profile settings.", + "isCommentAutoGenerated" : true + }, "Proximity Gain" : { "comment" : "A label displayed next to the value of the proximity gain slider.", "isCommentAutoGenerated" : true @@ -409,6 +453,10 @@ "comment" : "A label for the number of samples taken in the current session.", "isCommentAutoGenerated" : true }, + "Save" : { + "comment" : "A button to save changes made to the user's profile.", + "isCommentAutoGenerated" : true + }, "Save or Delete" : { "comment" : "A caption displayed underneath a coach mark in the tutorial overlay.", "isCommentAutoGenerated" : true @@ -429,6 +477,18 @@ "comment" : "A button that triggers the sharing of a heartbeat data analysis report.", "isCommentAutoGenerated" : true }, + "Sign in to sync your pregnancy journey across devices" : { + "comment" : "A description below the \"Sign in with Apple\" button in the Profile view, explaining that it allows users to sync their pregnancy journey across different devices.", + "isCommentAutoGenerated" : true + }, + "Sign in with Apple" : { + "comment" : "A button label that says \"Sign in with Apple\".", + "isCommentAutoGenerated" : true + }, + "Sign Out" : { + "comment" : "A button that signs the user out of their account.", + "isCommentAutoGenerated" : true + }, "Signal Amplitude" : { "comment" : "A heading for the signal amplitude indicator.", "isCommentAutoGenerated" : true @@ -515,6 +575,14 @@ }, "Tap twice" : { + }, + "Terms and Conditions" : { + "comment" : "A link to the terms and conditions of the app.", + "isCommentAutoGenerated" : true + }, + "Theme" : { + "comment" : "A button that allows the user to change the app's theme.", + "isCommentAutoGenerated" : true }, "Time" : { "comment" : "Data point on the heart rate trend chart.", @@ -528,6 +596,10 @@ "comment" : "A description under the title of the second onboarding page.", "isCommentAutoGenerated" : true }, + "Tutorial" : { + "comment" : "A link to a view that displays a tutorial.", + "isCommentAutoGenerated" : true + }, "Variability" : { "comment" : "A label for the variability of a user's heart rate.", "isCommentAutoGenerated" : true @@ -536,10 +608,22 @@ "comment" : "A label inside the bottom pocket of a folder, showing the current week.", "isCommentAutoGenerated" : true }, + "Weeks" : { + "comment" : "A unit of measurement for pregnancy weeks.", + "isCommentAutoGenerated" : true + }, "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 }, + "You'll need to sign in again to sync your data and access personalized features." : { + "comment" : "A message displayed in the confirmation dialog when signing out.", + "isCommentAutoGenerated" : true + }, + "Your privacy is protected. We only use your Apple ID to securely save your data." : { + "comment" : "A footer text in the profile view that explains data privacy.", + "isCommentAutoGenerated" : true + }, "Your recording will be saved automatically in your library." : { "comment" : "A description of what happens when the user stops a recording and it is saved.", "isCommentAutoGenerated" : true From 05d4a753a7d04717d7a5695d9de7e658d0a33da2 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 13:36:56 +0700 Subject: [PATCH 03/44] fix: account section in profile --- .../Profile/ViewModels/ProfileViewModel.swift | 11 ----------- Tiny/Features/Profile/Views/ProfileView.swift | 12 ------------ Tiny/Resources/Localizable.xcstrings | 12 ------------ 3 files changed, 35 deletions(-) diff --git a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift index 0bb02bb..0dd5d9a 100644 --- a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift +++ b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift @@ -8,16 +8,6 @@ import SwiftUI internal import Combine -// -// ProfileViewModel.swift -// Tiny -// -// Created by Destu Cikal Ramdani on 26/11/25. -// - -import SwiftUI -internal import Combine - @MainActor class ProfileViewModel: ObservableObject { // Observe the singleton manager so this ViewModel publishes changes when manager changes @@ -73,4 +63,3 @@ class ProfileViewModel: ObservableObject { print("User signed out") } } - diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift index 0e45d41..3be6cfa 100644 --- a/Tiny/Features/Profile/Views/ProfileView.swift +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -129,13 +129,6 @@ struct ProfileView: View { } else { signInView } - } header: { - Text("Account") - } footer: { - if !viewModel.isSignedIn { - Text("Your privacy is protected. We only use your Apple ID to securely save your data.") - .font(.caption2) - } } .listRowBackground(Color("rowProfileGrey")) } @@ -182,11 +175,6 @@ struct ProfileView: View { .cornerRadius(8) } .buttonStyle(.plain) - - Text("Sign in to sync your pregnancy journey across devices") - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) } .padding(.vertical, 8) } diff --git a/Tiny/Resources/Localizable.xcstrings b/Tiny/Resources/Localizable.xcstrings index af47e06..0e4e607 100644 --- a/Tiny/Resources/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -87,10 +87,6 @@ "comment" : "A label displayed next to the rightmost text in the \"Frequency Spectrum\" section of the audio visualization view.", "isCommentAutoGenerated" : true }, - "Account" : { - "comment" : "A section header in the profile view that reads \"Account\".", - "isCommentAutoGenerated" : true - }, "Adaptive Gain" : { "comment" : "A label displayed above a toggle switch that controls adaptive gain.", "isCommentAutoGenerated" : true @@ -477,10 +473,6 @@ "comment" : "A button that triggers the sharing of a heartbeat data analysis report.", "isCommentAutoGenerated" : true }, - "Sign in to sync your pregnancy journey across devices" : { - "comment" : "A description below the \"Sign in with Apple\" button in the Profile view, explaining that it allows users to sync their pregnancy journey across different devices.", - "isCommentAutoGenerated" : true - }, "Sign in with Apple" : { "comment" : "A button label that says \"Sign in with Apple\".", "isCommentAutoGenerated" : true @@ -620,10 +612,6 @@ "comment" : "A message displayed in the confirmation dialog when signing out.", "isCommentAutoGenerated" : true }, - "Your privacy is protected. We only use your Apple ID to securely save your data." : { - "comment" : "A footer text in the profile view that explains data privacy.", - "isCommentAutoGenerated" : true - }, "Your recording will be saved automatically in your library." : { "comment" : "A description of what happens when the user stops a recording and it is saved.", "isCommentAutoGenerated" : true From e9f64c0e2e736b94702f02ec9ae41fcddf2d2e79 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 13:43:25 +0700 Subject: [PATCH 04/44] fix: listening mode top text --- .../LiveListen/Views/OrbLiveListenView.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift index 74661ef..b779532 100644 --- a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift +++ b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift @@ -138,9 +138,15 @@ struct OrbLiveListenView: View { if viewModel.isListening && viewModel.isLongPressing { CountdownTextView(countdown: viewModel.longPressCountdown, isVisible: viewModel.isLongPressing) } else if viewModel.isListening { - Text("Listening...") - .font(.title) - .fontWeight(.bold) + VStack(spacing: 8) { + Text("Listening...") + .font(.title) + .fontWeight(.bold) + + Text("Hold sphere to stop session") + .font(.subheadline) + .foregroundStyle(.placeholder) + } } else if viewModel.isPlaybackMode { VStack(spacing: 8) { Text(viewModel.audioPostProcessingManager.isPlaying ? "Playing..." : From 46cee0c4c1e9035ae7d1a379f1e0b5ae3c45d954 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 14:01:31 +0700 Subject: [PATCH 05/44] fix: profile in pregnancy timeline view --- Tiny/Features/Timeline/Views/PregnancyTimelineView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index 97a348e..2b9e6f9 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -81,7 +81,7 @@ struct PregnancyTimelineView: View { .foregroundColor(.white.opacity(0.8)) } } - .frame(width: 44, height: 44) + .frame(width: 45, height: 45 ) .clipShape(Circle()) .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) @@ -89,8 +89,8 @@ struct PregnancyTimelineView: View { } } .padding(.horizontal, 20) - .padding(.top, 60) - + .padding(.top,20) + Spacer() if selectedWeek == nil { From 03ba76d9ec0e8da68fd12da976050e45911c683d Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 14:04:27 +0700 Subject: [PATCH 06/44] fix: swiftlint violation --- Tiny/Features/Timeline/Views/PregnancyTimelineView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index 2b9e6f9..bfb44f5 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -89,7 +89,7 @@ struct PregnancyTimelineView: View { } } .padding(.horizontal, 20) - .padding(.top,20) + .padding(.top, 20) Spacer() From 72905b107911846c65d9b03ce118168de3e3288e Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 13:43:25 +0700 Subject: [PATCH 07/44] fix: listening mode top text --- .../LiveListen/Views/OrbLiveListenView.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift index 74661ef..b779532 100644 --- a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift +++ b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift @@ -138,9 +138,15 @@ struct OrbLiveListenView: View { if viewModel.isListening && viewModel.isLongPressing { CountdownTextView(countdown: viewModel.longPressCountdown, isVisible: viewModel.isLongPressing) } else if viewModel.isListening { - Text("Listening...") - .font(.title) - .fontWeight(.bold) + VStack(spacing: 8) { + Text("Listening...") + .font(.title) + .fontWeight(.bold) + + Text("Hold sphere to stop session") + .font(.subheadline) + .foregroundStyle(.placeholder) + } } else if viewModel.isPlaybackMode { VStack(spacing: 8) { Text(viewModel.audioPostProcessingManager.isPlaying ? "Playing..." : From 1d8b08adb15c888b0063b9f81d9e01a90710ca5e Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Wed, 26 Nov 2025 23:59:41 +0700 Subject: [PATCH 08/44] feat: add profile screen --- .../Profile/ViewModels/ProfileViewModel.swift | 119 +++++ Tiny/Features/Profile/Views/ProfileView.swift | 477 ++++++++++++++++++ .../mainViolet.colorset/Contents.json | 38 ++ .../rowProfileGrey.colorset/Contents.json | 38 ++ 4 files changed, 672 insertions(+) create mode 100644 Tiny/Features/Profile/ViewModels/ProfileViewModel.swift create mode 100644 Tiny/Features/Profile/Views/ProfileView.swift create mode 100644 Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json create mode 100644 Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json diff --git a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift new file mode 100644 index 0000000..71f2b09 --- /dev/null +++ b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift @@ -0,0 +1,119 @@ +// +// ProfileViewModel.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 26/11/25. +// + +import SwiftUI +internal import Combine + +@MainActor +class ProfileViewModel: ObservableObject { + @Published var isSignedIn: Bool = false + @Published var profileImage: UIImage? + @Published var userName: String = "Guest" + @Published var userEmail: String? + + // Settings + @AppStorage("appTheme") var appTheme: String = "System" + @AppStorage("isUserSignedIn") private var storedSignInStatus: Bool = false + @AppStorage("savedUserName") private var savedUserName: String? + @AppStorage("savedUserEmail") private var savedUserEmail: String? + + private var cancellables = Set() + + init() { + loadUserData() + } + + // MARK: - Data Persistence + + private func loadUserData() { + isSignedIn = storedSignInStatus + + if let savedName = savedUserName { + userName = savedName + } + + if let savedEmail = savedUserEmail { + userEmail = savedEmail + } + + loadProfileImageFromDisk() + } + + func saveName() { + savedUserName = userName + print("Name saved: \(userName)") + } + + private func saveProfileImageToDisk() { + guard let image = profileImage, + let data = image.jpegData(compressionQuality: 0.8) else { return } + + let fileURL = getProfileImageURL() + try? data.write(to: fileURL) + } + + private func loadProfileImageFromDisk() { + let fileURL = getProfileImageURL() + guard let data = try? Data(contentsOf: fileURL), + let image = UIImage(data: data) else { return } + + profileImage = image + } + + private func deleteProfileImageFromDisk() { + let fileURL = getProfileImageURL() + try? FileManager.default.removeItem(at: fileURL) + } + + private func getProfileImageURL() -> URL { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return documentsPath.appendingPathComponent("profileImage.jpg") + } + + // MARK: - Authentication (Dummy Implementation) + + /// Dummy sign in - Replace this with real Apple Sign In implementation + func signIn() { + // TODO: Implement real Apple Sign In here + // This is a placeholder for demonstration + isSignedIn = true + userName = "John Doe" + userEmail = "john.doe@example.com" + + // Persist sign in status + storedSignInStatus = true + savedUserName = userName + savedUserEmail = userEmail + + print("User signed in (dummy)") + } + + /// Sign out user and clear all data + func signOut() { + isSignedIn = false + profileImage = nil + userName = "Guest" + userEmail = nil + + // Clear persisted data + storedSignInStatus = false + savedUserName = nil + savedUserEmail = nil + deleteProfileImageFromDisk() + + print("User signed out") + } + + // MARK: - Profile Image Management + + func updateProfileImage(_ image: UIImage?) { + profileImage = image + if image != nil { + saveProfileImageToDisk() + } + } +} diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift new file mode 100644 index 0000000..dc3870b --- /dev/null +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -0,0 +1,477 @@ +// +// ProfileView.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 26/11/25. +// + +import SwiftUI + +struct ProfileView: View { + @StateObject private var viewModel = ProfileViewModel() + @State private var showingSignOutConfirmation = false + + var body: some View { + NavigationStack { + ZStack { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .ignoresSafeArea() + + VStack(spacing: 0) { + // HEADER + profileHeader + .padding(.bottom, 30) + + // FEATURE CARDS + featureCards + .padding(.horizontal, 16) + .frame(height: 160) + + // SETTINGS LIST + settingsList + } + } + } + } + + // MARK: - View Components + + private var profileHeader: some View { + VStack(spacing: 16) { + GeometryReader { geo in + let size = geo.size.width * 0.28 + + VStack(spacing: 16) { + Spacer() + + NavigationLink { + ProfilePhotoDetailView(viewModel: viewModel) + } label: { + profileImageView(size: size) + } + .buttonStyle(.plain) + + Text(viewModel.isSignedIn ? viewModel.userName : "Guest") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(.white) + } + .frame(width: geo.size.width) + } + .frame(height: 260) + } + } + + private func profileImageView(size: CGFloat) -> some View { + Group { + if let img = viewModel.profileImage { + Image(uiImage: img) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.gray) + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + } + + private var featureCards: some View { + HStack(spacing: 12) { + let padding: CGFloat = 5 + let spacing: CGFloat = 12 + let cardWidth = (UIScreen.main.bounds.width - (padding * 5 + spacing)) / 2 + + featureCardLeft(width: cardWidth) + featureCardRight(width: cardWidth) + } + } + + private var settingsList: some View { + List { + settingsSection + accountSection + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(Color.clear) + } + + private var settingsSection: some View { + Section { + NavigationLink(destination: ThemeDummy()) { + Label("Theme", systemImage: "paintpalette.fill") + .foregroundStyle(.white) + } + NavigationLink(destination: TutorialDummy()) { + Label("Tutorial", systemImage: "book.fill") + .foregroundStyle(.white) + } + Link(destination: URL(string: "https://example.com/privacy")!) { + Label("Privacy Policy", systemImage: "shield.righthalf.filled") + .foregroundStyle(.white) + } + Link(destination: URL(string: "https://example.com/terms")!) { + Label("Terms and Conditions", systemImage: "doc.text") + .foregroundStyle(.white) + } + } + .listRowBackground(Color("rowProfileGrey")) + } + + private var accountSection: some View { + Section { + if viewModel.isSignedIn { + signedInView + } else { + signInView + } + } header: { + Text("Account") + } footer: { + if !viewModel.isSignedIn { + Text("Your privacy is protected. We only use your Apple ID to securely save your data.") + .font(.caption2) + } + } + .listRowBackground(Color("rowProfileGrey")) + } + + private var signedInView: some View { + Group { + Button(role: .destructive) { + showingSignOutConfirmation = true + } label: { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + .foregroundStyle(.red) + } + .confirmationDialog( + "Sign Out", + isPresented: $showingSignOutConfirmation, + titleVisibility: .visible + ) { + Button("Sign Out", role: .destructive) { + viewModel.signOut() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("You'll need to sign in again to sync your data and access personalized features.") + } + } + } + + private var signInView: some View { + VStack(spacing: 12) { + // Dummy Sign In Button styled like Apple's + Button { + viewModel.signIn() + } label: { + HStack { + Image(systemName: "applelogo") + .font(.system(size: 20, weight: .medium)) + Text("Sign in with Apple") + .font(.system(size: 17, weight: .semibold)) + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .foregroundStyle(.black) + .background(Color.white) + .cornerRadius(8) + } + .buttonStyle(.plain) + + Text("Sign in to sync your pregnancy journey across devices") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.vertical, 8) + } + + // MARK: - Feature Cards + + private func featureCardLeft(width: CGFloat) -> some View { + VStack(alignment: .leading) { + HStack { + Image(systemName: "heart.fill") + .font(.caption) + .foregroundColor(.white) + Text("Connected Journey") + .font(.caption) + .foregroundColor(.gray) + } + + Spacer() + + Text("Connect with Your Partner") + .font(.subheadline) + .foregroundColor(.blue) + .fontWeight(.medium) + + Spacer() + } + .frame(width: width, height: width * 0.63, alignment: .topLeading) + .padding() + .background(Color("rowProfileGrey")) + .cornerRadius(14) + } + + private func featureCardRight(width: CGFloat) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "calendar") + .font(.caption) + .foregroundColor(.white) + Text("Pregnancy Age") + .font(.caption) + .foregroundColor(.gray) + } + + Spacer() + + VStack(alignment: .center, spacing: 4) { + Text("20") + .font(.title) + .fontWeight(.bold) + .foregroundColor(Color("mainViolet")) + Text("Weeks") + .font(.subheadline) + .foregroundColor(Color("mainViolet")) + } + .frame(maxWidth: .infinity, alignment: .center) + + Spacer() + } + .frame(width: width * 0.65, height: width * 0.63) + .padding() + .background(Color("rowProfileGrey")) + .cornerRadius(14) + } +} + +struct ProfilePhotoDetailView: View { + @ObservedObject var viewModel: ProfileViewModel + @State private var showingImagePicker = false + @State private var showingCamera = false + @State private var showingPhotoOptions = false + @State private var tempUserName: String = "" + @Environment(\.dismiss) private var dismiss + + var body: some View { + ZStack { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .ignoresSafeArea() + + VStack(spacing: 30) { + profilePhotoButton + .padding(.top, 80) + + nameEditSection + .padding(.horizontal, 30) + + Spacer() + } + } + .navigationTitle("Edit Profile") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + viewModel.userName = tempUserName + viewModel.saveName() + dismiss() + } + .disabled(tempUserName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .onAppear { + tempUserName = viewModel.userName + } + .sheet(isPresented: $showingPhotoOptions) { + BottomPhotoPickerSheet( + showingCamera: $showingCamera, + showingImagePicker: $showingImagePicker + ) + } + .sheet(isPresented: $showingImagePicker) { + ImagePicker(image: $viewModel.profileImage, sourceType: .photoLibrary) + } + .fullScreenCover(isPresented: $showingCamera) { + ImagePicker(image: $viewModel.profileImage, sourceType: .camera) + } + } + + private var profilePhotoButton: some View { + Button { + showingPhotoOptions = true + } label: { + ZStack(alignment: .bottomTrailing) { + Group { + if let profileImage = viewModel.profileImage { + Image(uiImage: profileImage) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.gray) + } + } + .frame(width: 200, height: 200) + .clipShape(Circle()) + .overlay( + Circle() + .stroke(Color.white, lineWidth: 3) + ) + + // Camera badge + Image(systemName: "camera.circle.fill") + .font(.system(size: 44)) + .foregroundStyle(.white, Color("rowProfileGrey")) + .offset(x: -10, y: -10) + } + } + .buttonStyle(.plain) + } + + private var nameEditSection: some View { + VStack(spacing: 16) { + HStack { + Text("Name") + .foregroundColor(.gray) + .font(.subheadline) + Spacer() + } + + TextField("Enter your name", text: $tempUserName) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.words) + .submitLabel(.done) + } + } +} + +struct BottomPhotoPickerSheet: View { + @Binding var showingCamera: Bool + @Binding var showingImagePicker: Bool + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 20) { + Text("Change Profile Photo") + .font(.headline) + .foregroundColor(.primary) + .padding(.bottom, 8) + + PhotoPickerButton( + title: "Take Photo", + icon: "camera", + action: { + dismiss() + showingCamera = true + } + ) + + PhotoPickerButton( + title: "Choose From Library", + icon: "photo.on.rectangle", + action: { + dismiss() + showingImagePicker = true + } + ) + } + .padding() + .presentationDetents([.height(220)]) + .presentationDragIndicator(.visible) + } +} + +private struct PhotoPickerButton: View { + let title: String + let icon: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + Image(systemName: icon) + .font(.system(size: 18)) + Text(title) + .font(.system(size: 16, weight: .medium)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(Color("rowProfileGrey")) + .cornerRadius(12) + } + .buttonStyle(.plain) + } +} + +struct ImagePicker: UIViewControllerRepresentable { + @Binding var image: UIImage? + let sourceType: UIImagePickerController.SourceType + @Environment(\.dismiss) var dismiss + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = sourceType + picker.delegate = context.coordinator + picker.allowsEditing = false + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let parent: ImagePicker + + init(_ parent: ImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + if let image = info[.originalImage] as? UIImage { + parent.image = image + } + parent.dismiss() + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.dismiss() + } + } +} + +struct ThemeDummy: View { + var body: some View { + Text("Dummy Theme View") + .navigationTitle("Theme") + } +} + +struct TutorialDummy: View { + var body: some View { + Text("Dummy Tutorial View") + .navigationTitle("Tutorial") + } +} + +#Preview { + ProfileView() + .preferredColorScheme(.dark) +} diff --git a/Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json new file mode 100644 index 0000000..7d97c94 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE8", + "green" : "0x95", + "red" : "0x95" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE8", + "green" : "0x95", + "red" : "0x95" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json new file mode 100644 index 0000000..c195a24 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x28", + "green" : "0x21", + "red" : "0x21" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x28", + "green" : "0x21", + "red" : "0x21" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} From 701b03417d0a26353b7fca028a26005e225314fb Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 13:43:25 +0700 Subject: [PATCH 09/44] fix: listening mode top text --- .../LiveListen/Views/OrbLiveListenView.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift index 74661ef..b779532 100644 --- a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift +++ b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift @@ -138,9 +138,15 @@ struct OrbLiveListenView: View { if viewModel.isListening && viewModel.isLongPressing { CountdownTextView(countdown: viewModel.longPressCountdown, isVisible: viewModel.isLongPressing) } else if viewModel.isListening { - Text("Listening...") - .font(.title) - .fontWeight(.bold) + VStack(spacing: 8) { + Text("Listening...") + .font(.title) + .fontWeight(.bold) + + Text("Hold sphere to stop session") + .font(.subheadline) + .foregroundStyle(.placeholder) + } } else if viewModel.isPlaybackMode { VStack(spacing: 8) { Text(viewModel.audioPostProcessingManager.isPlaying ? "Playing..." : From 8e975571d17074467ffa953aea81f35021e2df88 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Wed, 26 Nov 2025 23:59:41 +0700 Subject: [PATCH 10/44] feat: add profile screen --- .../Profile/ViewModels/ProfileViewModel.swift | 119 +++++ Tiny/Features/Profile/Views/ProfileView.swift | 477 ++++++++++++++++++ .../mainViolet.colorset/Contents.json | 38 ++ .../rowProfileGrey.colorset/Contents.json | 38 ++ 4 files changed, 672 insertions(+) create mode 100644 Tiny/Features/Profile/ViewModels/ProfileViewModel.swift create mode 100644 Tiny/Features/Profile/Views/ProfileView.swift create mode 100644 Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json create mode 100644 Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json diff --git a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift new file mode 100644 index 0000000..71f2b09 --- /dev/null +++ b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift @@ -0,0 +1,119 @@ +// +// ProfileViewModel.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 26/11/25. +// + +import SwiftUI +internal import Combine + +@MainActor +class ProfileViewModel: ObservableObject { + @Published var isSignedIn: Bool = false + @Published var profileImage: UIImage? + @Published var userName: String = "Guest" + @Published var userEmail: String? + + // Settings + @AppStorage("appTheme") var appTheme: String = "System" + @AppStorage("isUserSignedIn") private var storedSignInStatus: Bool = false + @AppStorage("savedUserName") private var savedUserName: String? + @AppStorage("savedUserEmail") private var savedUserEmail: String? + + private var cancellables = Set() + + init() { + loadUserData() + } + + // MARK: - Data Persistence + + private func loadUserData() { + isSignedIn = storedSignInStatus + + if let savedName = savedUserName { + userName = savedName + } + + if let savedEmail = savedUserEmail { + userEmail = savedEmail + } + + loadProfileImageFromDisk() + } + + func saveName() { + savedUserName = userName + print("Name saved: \(userName)") + } + + private func saveProfileImageToDisk() { + guard let image = profileImage, + let data = image.jpegData(compressionQuality: 0.8) else { return } + + let fileURL = getProfileImageURL() + try? data.write(to: fileURL) + } + + private func loadProfileImageFromDisk() { + let fileURL = getProfileImageURL() + guard let data = try? Data(contentsOf: fileURL), + let image = UIImage(data: data) else { return } + + profileImage = image + } + + private func deleteProfileImageFromDisk() { + let fileURL = getProfileImageURL() + try? FileManager.default.removeItem(at: fileURL) + } + + private func getProfileImageURL() -> URL { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return documentsPath.appendingPathComponent("profileImage.jpg") + } + + // MARK: - Authentication (Dummy Implementation) + + /// Dummy sign in - Replace this with real Apple Sign In implementation + func signIn() { + // TODO: Implement real Apple Sign In here + // This is a placeholder for demonstration + isSignedIn = true + userName = "John Doe" + userEmail = "john.doe@example.com" + + // Persist sign in status + storedSignInStatus = true + savedUserName = userName + savedUserEmail = userEmail + + print("User signed in (dummy)") + } + + /// Sign out user and clear all data + func signOut() { + isSignedIn = false + profileImage = nil + userName = "Guest" + userEmail = nil + + // Clear persisted data + storedSignInStatus = false + savedUserName = nil + savedUserEmail = nil + deleteProfileImageFromDisk() + + print("User signed out") + } + + // MARK: - Profile Image Management + + func updateProfileImage(_ image: UIImage?) { + profileImage = image + if image != nil { + saveProfileImageToDisk() + } + } +} diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift new file mode 100644 index 0000000..dc3870b --- /dev/null +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -0,0 +1,477 @@ +// +// ProfileView.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 26/11/25. +// + +import SwiftUI + +struct ProfileView: View { + @StateObject private var viewModel = ProfileViewModel() + @State private var showingSignOutConfirmation = false + + var body: some View { + NavigationStack { + ZStack { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .ignoresSafeArea() + + VStack(spacing: 0) { + // HEADER + profileHeader + .padding(.bottom, 30) + + // FEATURE CARDS + featureCards + .padding(.horizontal, 16) + .frame(height: 160) + + // SETTINGS LIST + settingsList + } + } + } + } + + // MARK: - View Components + + private var profileHeader: some View { + VStack(spacing: 16) { + GeometryReader { geo in + let size = geo.size.width * 0.28 + + VStack(spacing: 16) { + Spacer() + + NavigationLink { + ProfilePhotoDetailView(viewModel: viewModel) + } label: { + profileImageView(size: size) + } + .buttonStyle(.plain) + + Text(viewModel.isSignedIn ? viewModel.userName : "Guest") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(.white) + } + .frame(width: geo.size.width) + } + .frame(height: 260) + } + } + + private func profileImageView(size: CGFloat) -> some View { + Group { + if let img = viewModel.profileImage { + Image(uiImage: img) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.gray) + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + } + + private var featureCards: some View { + HStack(spacing: 12) { + let padding: CGFloat = 5 + let spacing: CGFloat = 12 + let cardWidth = (UIScreen.main.bounds.width - (padding * 5 + spacing)) / 2 + + featureCardLeft(width: cardWidth) + featureCardRight(width: cardWidth) + } + } + + private var settingsList: some View { + List { + settingsSection + accountSection + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(Color.clear) + } + + private var settingsSection: some View { + Section { + NavigationLink(destination: ThemeDummy()) { + Label("Theme", systemImage: "paintpalette.fill") + .foregroundStyle(.white) + } + NavigationLink(destination: TutorialDummy()) { + Label("Tutorial", systemImage: "book.fill") + .foregroundStyle(.white) + } + Link(destination: URL(string: "https://example.com/privacy")!) { + Label("Privacy Policy", systemImage: "shield.righthalf.filled") + .foregroundStyle(.white) + } + Link(destination: URL(string: "https://example.com/terms")!) { + Label("Terms and Conditions", systemImage: "doc.text") + .foregroundStyle(.white) + } + } + .listRowBackground(Color("rowProfileGrey")) + } + + private var accountSection: some View { + Section { + if viewModel.isSignedIn { + signedInView + } else { + signInView + } + } header: { + Text("Account") + } footer: { + if !viewModel.isSignedIn { + Text("Your privacy is protected. We only use your Apple ID to securely save your data.") + .font(.caption2) + } + } + .listRowBackground(Color("rowProfileGrey")) + } + + private var signedInView: some View { + Group { + Button(role: .destructive) { + showingSignOutConfirmation = true + } label: { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + .foregroundStyle(.red) + } + .confirmationDialog( + "Sign Out", + isPresented: $showingSignOutConfirmation, + titleVisibility: .visible + ) { + Button("Sign Out", role: .destructive) { + viewModel.signOut() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("You'll need to sign in again to sync your data and access personalized features.") + } + } + } + + private var signInView: some View { + VStack(spacing: 12) { + // Dummy Sign In Button styled like Apple's + Button { + viewModel.signIn() + } label: { + HStack { + Image(systemName: "applelogo") + .font(.system(size: 20, weight: .medium)) + Text("Sign in with Apple") + .font(.system(size: 17, weight: .semibold)) + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .foregroundStyle(.black) + .background(Color.white) + .cornerRadius(8) + } + .buttonStyle(.plain) + + Text("Sign in to sync your pregnancy journey across devices") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.vertical, 8) + } + + // MARK: - Feature Cards + + private func featureCardLeft(width: CGFloat) -> some View { + VStack(alignment: .leading) { + HStack { + Image(systemName: "heart.fill") + .font(.caption) + .foregroundColor(.white) + Text("Connected Journey") + .font(.caption) + .foregroundColor(.gray) + } + + Spacer() + + Text("Connect with Your Partner") + .font(.subheadline) + .foregroundColor(.blue) + .fontWeight(.medium) + + Spacer() + } + .frame(width: width, height: width * 0.63, alignment: .topLeading) + .padding() + .background(Color("rowProfileGrey")) + .cornerRadius(14) + } + + private func featureCardRight(width: CGFloat) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "calendar") + .font(.caption) + .foregroundColor(.white) + Text("Pregnancy Age") + .font(.caption) + .foregroundColor(.gray) + } + + Spacer() + + VStack(alignment: .center, spacing: 4) { + Text("20") + .font(.title) + .fontWeight(.bold) + .foregroundColor(Color("mainViolet")) + Text("Weeks") + .font(.subheadline) + .foregroundColor(Color("mainViolet")) + } + .frame(maxWidth: .infinity, alignment: .center) + + Spacer() + } + .frame(width: width * 0.65, height: width * 0.63) + .padding() + .background(Color("rowProfileGrey")) + .cornerRadius(14) + } +} + +struct ProfilePhotoDetailView: View { + @ObservedObject var viewModel: ProfileViewModel + @State private var showingImagePicker = false + @State private var showingCamera = false + @State private var showingPhotoOptions = false + @State private var tempUserName: String = "" + @Environment(\.dismiss) private var dismiss + + var body: some View { + ZStack { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .ignoresSafeArea() + + VStack(spacing: 30) { + profilePhotoButton + .padding(.top, 80) + + nameEditSection + .padding(.horizontal, 30) + + Spacer() + } + } + .navigationTitle("Edit Profile") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + viewModel.userName = tempUserName + viewModel.saveName() + dismiss() + } + .disabled(tempUserName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .onAppear { + tempUserName = viewModel.userName + } + .sheet(isPresented: $showingPhotoOptions) { + BottomPhotoPickerSheet( + showingCamera: $showingCamera, + showingImagePicker: $showingImagePicker + ) + } + .sheet(isPresented: $showingImagePicker) { + ImagePicker(image: $viewModel.profileImage, sourceType: .photoLibrary) + } + .fullScreenCover(isPresented: $showingCamera) { + ImagePicker(image: $viewModel.profileImage, sourceType: .camera) + } + } + + private var profilePhotoButton: some View { + Button { + showingPhotoOptions = true + } label: { + ZStack(alignment: .bottomTrailing) { + Group { + if let profileImage = viewModel.profileImage { + Image(uiImage: profileImage) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.gray) + } + } + .frame(width: 200, height: 200) + .clipShape(Circle()) + .overlay( + Circle() + .stroke(Color.white, lineWidth: 3) + ) + + // Camera badge + Image(systemName: "camera.circle.fill") + .font(.system(size: 44)) + .foregroundStyle(.white, Color("rowProfileGrey")) + .offset(x: -10, y: -10) + } + } + .buttonStyle(.plain) + } + + private var nameEditSection: some View { + VStack(spacing: 16) { + HStack { + Text("Name") + .foregroundColor(.gray) + .font(.subheadline) + Spacer() + } + + TextField("Enter your name", text: $tempUserName) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.words) + .submitLabel(.done) + } + } +} + +struct BottomPhotoPickerSheet: View { + @Binding var showingCamera: Bool + @Binding var showingImagePicker: Bool + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 20) { + Text("Change Profile Photo") + .font(.headline) + .foregroundColor(.primary) + .padding(.bottom, 8) + + PhotoPickerButton( + title: "Take Photo", + icon: "camera", + action: { + dismiss() + showingCamera = true + } + ) + + PhotoPickerButton( + title: "Choose From Library", + icon: "photo.on.rectangle", + action: { + dismiss() + showingImagePicker = true + } + ) + } + .padding() + .presentationDetents([.height(220)]) + .presentationDragIndicator(.visible) + } +} + +private struct PhotoPickerButton: View { + let title: String + let icon: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + Image(systemName: icon) + .font(.system(size: 18)) + Text(title) + .font(.system(size: 16, weight: .medium)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(Color("rowProfileGrey")) + .cornerRadius(12) + } + .buttonStyle(.plain) + } +} + +struct ImagePicker: UIViewControllerRepresentable { + @Binding var image: UIImage? + let sourceType: UIImagePickerController.SourceType + @Environment(\.dismiss) var dismiss + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = sourceType + picker.delegate = context.coordinator + picker.allowsEditing = false + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let parent: ImagePicker + + init(_ parent: ImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + if let image = info[.originalImage] as? UIImage { + parent.image = image + } + parent.dismiss() + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.dismiss() + } + } +} + +struct ThemeDummy: View { + var body: some View { + Text("Dummy Theme View") + .navigationTitle("Theme") + } +} + +struct TutorialDummy: View { + var body: some View { + Text("Dummy Tutorial View") + .navigationTitle("Tutorial") + } +} + +#Preview { + ProfileView() + .preferredColorScheme(.dark) +} diff --git a/Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json new file mode 100644 index 0000000..7d97c94 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE8", + "green" : "0x95", + "red" : "0x95" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE8", + "green" : "0x95", + "red" : "0x95" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json new file mode 100644 index 0000000..c195a24 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x28", + "green" : "0x21", + "red" : "0x21" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x28", + "green" : "0x21", + "red" : "0x21" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} From fe4b29c59d1f9442d705ace26b78907b5e5d302a Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 00:24:00 +0700 Subject: [PATCH 11/44] feat: add profile navigation in PregnancyTimelineView --- Tiny.xcodeproj/project.pbxproj | 4 + Tiny/App/ContentView.swift | 31 +++- Tiny/App/tinyApp.swift | 44 +---- .../LiveListen/Views/HeartbeatMainView.swift | 60 +------ .../Profile/Models/UserProfileManager.swift | 106 ++++++++++++ .../Profile/ViewModels/ProfileViewModel.swift | 107 +++--------- Tiny/Features/Profile/Views/ProfileView.swift | 155 +++++++++++++----- .../Views/PregnancyTimelineView.swift | 112 +++++++------ Tiny/Resources/Localizable.xcstrings | 76 +++++++++ 9 files changed, 432 insertions(+), 263 deletions(-) create mode 100644 Tiny/Features/Profile/Models/UserProfileManager.swift diff --git a/Tiny.xcodeproj/project.pbxproj b/Tiny.xcodeproj/project.pbxproj index 2e413b1..743a9d8 100644 --- a/Tiny.xcodeproj/project.pbxproj +++ b/Tiny.xcodeproj/project.pbxproj @@ -484,7 +484,9 @@ INFOPLIST_KEY_CFBundleDisplayName = Tiny; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; + INFOPLIST_KEY_NSCameraUsageDescription = "Allow Tiny to use camera for taking profile picture"; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Tiny needs to use the microphone to capture heartbeat sounds."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Allow Tiny to access photos library"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -531,7 +533,9 @@ INFOPLIST_KEY_CFBundleDisplayName = Tiny; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; + INFOPLIST_KEY_NSCameraUsageDescription = "Allow Tiny to use camera for taking profile picture"; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Tiny needs to use the microphone to capture heartbeat sounds."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Allow Tiny to access photos library"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/Tiny/App/ContentView.swift b/Tiny/App/ContentView.swift index 4257428..313861f 100644 --- a/Tiny/App/ContentView.swift +++ b/Tiny/App/ContentView.swift @@ -11,15 +11,21 @@ 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 + @EnvironmentObject var authService: AuthenticationService + @EnvironmentObject var syncManager: HeartbeatSyncManager var body: some View { Group { if hasShownOnboarding { - HeartbeatMainView() + // After onboarding → always go to RootView (SignIn → Onboarding → Main) + RootView() + .environmentObject(heartbeatSoundManager) + .environmentObject(authService) + .environmentObject(syncManager) } else { + // Onboarding only OnBoardingView(hasShownOnboarding: $hasShownOnboarding) } } @@ -31,6 +37,25 @@ struct ContentView: View { } } +struct RootView: View { + @EnvironmentObject var authService: AuthenticationService + + var body: some View { + Group { + if !authService.isAuthenticated { + // Step 1: Landing screen with Sign in with Apple + SignInView() + } else if authService.currentUser?.role == nil { + // Step 2-4: Onboarding flow (role selection, name input, room code) + OnboardingCoordinator() + } else { + // Step 5: Main app - go to HeartbeatMainView + HeartbeatMainView() + } + } + } +} + #Preview { ContentView() .modelContainer(for: SavedHeartbeat.self, inMemory: true) diff --git a/Tiny/App/tinyApp.swift b/Tiny/App/tinyApp.swift index 3dc600a..f5b5402 100644 --- a/Tiny/App/tinyApp.swift +++ b/Tiny/App/tinyApp.swift @@ -9,46 +9,39 @@ import SwiftUI import SwiftData import FirebaseCore -class AppDelegate: NSObject, UIApplicationDelegate { - func application(_ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { - FirebaseApp.configure() - return true - } -} - @main struct TinyApp: App { - @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate - @StateObject var heartbeatSoundManager = HeartbeatSoundManager() @StateObject var authService = AuthenticationService() @StateObject var syncManager = HeartbeatSyncManager() - @State private var isShowingSplashScreen: Bool = true // Add state to control splash screen + @State private var isShowingSplashScreen: Bool = true // Add state to control splash screen + + init() { + FirebaseApp.configure() + } + // 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) .preferredColorScheme(.dark) } else { - // ContentView() - // .environmentObject(heartbeatSoundManager) - RootView() + ContentView() .environmentObject(heartbeatSoundManager) .environmentObject(authService) .environmentObject(syncManager) @@ -57,22 +50,3 @@ struct TinyApp: App { .modelContainer(sharedModelContainer) } } - -struct RootView: View { - @EnvironmentObject var authService: AuthenticationService - - var body: some View { - Group { - if !authService.isAuthenticated { - // Step 1: Landing screen with Sign in with Apple - SignInView() - } else if authService.currentUser?.role == nil { - // Step 2-4: Onboarding flow (role selection, name input, room code) - OnboardingCoordinator() - } else { - // Step 5: Main app - go to HeartbeatMainView - HeartbeatMainView() - } - } - } -} diff --git a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift index 7e26734..a0026c6 100644 --- a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift +++ b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift @@ -13,15 +13,15 @@ struct HeartbeatMainView: View { @Environment(\.modelContext) private var modelContext @EnvironmentObject var authService: AuthenticationService @EnvironmentObject var syncManager: HeartbeatSyncManager - + @State private var showRoomCode = false @State private var isInitialized = false - + // Check if user is a mother private var isMother: Bool { authService.currentUser?.role == .mother } - + var body: some View { ZStack { // Timeline view - accessible by both mom and dad @@ -41,51 +41,9 @@ struct HeartbeatMainView: View { ) .transition(.opacity) } - - // Room Code Button (Top Right) - VStack { - HStack { - Spacer() - - Button(action: { - showRoomCode.toggle() - }) { - Image(systemName: "person.2.fill") - .font(.system(size: 20)) - .foregroundColor(.white) - .padding(12) - .background(Color.white.opacity(0.2)) - .clipShape(Circle()) - } - .padding(.trailing, 20) - .padding(.top, 50) - } - - Spacer() - } - } - .preferredColorScheme(.dark) - .onAppear { - // Initialize only once - if !isInitialized { - initializeManager() - } - } - .onChange(of: authService.currentUser?.roomCode) { oldValue, newValue in - // Re-initialize when room code changes - if newValue != nil && newValue != oldValue { - print("🔄 Room code updated: \(newValue ?? "nil")") - initializeManager() - } - } - .sheet(isPresented: $showRoomCode) { - RoomCodeDisplayView() - .environmentObject(authService) - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) } } - + private func initializeManager() { Task { // Auto-create room for mothers if they don't have one @@ -97,18 +55,18 @@ struct HeartbeatMainView: View { print("❌ Error creating room: \(error)") } } - + // Wait a bit for room code to be set try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds - + // Now setup the manager with current user data let userId = authService.currentUser?.id let roomCode = authService.currentUser?.roomCode - + print("🔍 Initializing manager with:") print(" User ID: \(userId ?? "nil")") print(" Room Code: \(roomCode ?? "nil")") - + await MainActor.run { viewModel.setupManager( modelContext: modelContext, @@ -119,7 +77,7 @@ struct HeartbeatMainView: View { ) isInitialized = true } - + // For fathers, start in timeline view if !isMother { await MainActor.run { diff --git a/Tiny/Features/Profile/Models/UserProfileManager.swift b/Tiny/Features/Profile/Models/UserProfileManager.swift new file mode 100644 index 0000000..6821bba --- /dev/null +++ b/Tiny/Features/Profile/Models/UserProfileManager.swift @@ -0,0 +1,106 @@ +// +// UserProfileManager.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 27/11/25. +// + +import SwiftUI +internal import Combine + +class UserProfileManager: ObservableObject { + static let shared = UserProfileManager() + + @Published var profileImage: UIImage? + @Published var userName: String = "Guest" + @Published var userEmail: String? + @Published var isSignedIn: Bool = false + + // Persistence Keys + private let kUserName = "savedUserName" + private let kUserEmail = "savedUserEmail" + private let kIsSignedIn = "isUserSignedIn" + + private init() { + loadUserData() + } + + // MARK: - Data Persistence + + func loadUserData() { + let defaults = UserDefaults.standard + isSignedIn = defaults.bool(forKey: kIsSignedIn) + + if let savedName = defaults.string(forKey: kUserName) { + userName = savedName + } + + if let savedEmail = defaults.string(forKey: kUserEmail) { + userEmail = savedEmail + } + + loadProfileImageFromDisk() + } + + func saveUserData() { + let defaults = UserDefaults.standard + defaults.set(isSignedIn, forKey: kIsSignedIn) + defaults.set(userName, forKey: kUserName) + defaults.set(userEmail, forKey: kUserEmail) + } + + func saveProfileImage(_ image: UIImage?) { + profileImage = image + + if let image = image { + saveProfileImageToDisk(image) + } else { + deleteProfileImageFromDisk() + } + } + + private func saveProfileImageToDisk(_ image: UIImage) { + guard let data = image.jpegData(compressionQuality: 0.8) else { return } + let fileURL = getProfileImageURL() + try? data.write(to: fileURL) + } + + private func loadProfileImageFromDisk() { + let fileURL = getProfileImageURL() + guard let data = try? Data(contentsOf: fileURL), + let image = UIImage(data: data) else { + profileImage = nil + return + } + profileImage = image + } + + private func deleteProfileImageFromDisk() { + let fileURL = getProfileImageURL() + try? FileManager.default.removeItem(at: fileURL) + } + + private func getProfileImageURL() -> URL { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return documentsPath.appendingPathComponent("profileImage.jpg") + } + + // MARK: - Actions + + func signOut() { + isSignedIn = false + userName = "Guest" + userEmail = nil + profileImage = nil + + saveUserData() + deleteProfileImageFromDisk() + } + + func signInDummy() { + isSignedIn = true + userName = "John Doe" + userEmail = "john.doe@example.com" + saveUserData() + } +} diff --git a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift index 71f2b09..7b8270d 100644 --- a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift +++ b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift @@ -10,110 +10,49 @@ internal import Combine @MainActor class ProfileViewModel: ObservableObject { - @Published var isSignedIn: Bool = false - @Published var profileImage: UIImage? - @Published var userName: String = "Guest" - @Published var userEmail: String? + // Observe the singleton manager so this ViewModel publishes changes when manager changes + var manager = UserProfileManager.shared + private var cancellables = Set() - // Settings @AppStorage("appTheme") var appTheme: String = "System" - @AppStorage("isUserSignedIn") private var storedSignInStatus: Bool = false - @AppStorage("savedUserName") private var savedUserName: String? - @AppStorage("savedUserEmail") private var savedUserEmail: String? - - private var cancellables = Set() init() { - loadUserData() - } - - // MARK: - Data Persistence - - private func loadUserData() { - isSignedIn = storedSignInStatus - - if let savedName = savedUserName { - userName = savedName - } - - if let savedEmail = savedUserEmail { - userEmail = savedEmail - } - - loadProfileImageFromDisk() - } - - func saveName() { - savedUserName = userName - print("Name saved: \(userName)") + // Propagate manager changes to this ViewModel + manager.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) } - private func saveProfileImageToDisk() { - guard let image = profileImage, - let data = image.jpegData(compressionQuality: 0.8) else { return } + // MARK: - Computed Properties for View Bindings - let fileURL = getProfileImageURL() - try? data.write(to: fileURL) + var isSignedIn: Bool { + manager.isSignedIn } - private func loadProfileImageFromDisk() { - let fileURL = getProfileImageURL() - guard let data = try? Data(contentsOf: fileURL), - let image = UIImage(data: data) else { return } - - profileImage = image + var userName: String { + get { manager.userName } + set { manager.userName = newValue } } - private func deleteProfileImageFromDisk() { - let fileURL = getProfileImageURL() - try? FileManager.default.removeItem(at: fileURL) + var userEmail: String? { + manager.userEmail } - private func getProfileImageURL() -> URL { - let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - return documentsPath.appendingPathComponent("profileImage.jpg") + // For ImagePicker binding + var profileImage: UIImage? { + get { manager.profileImage } + set { manager.saveProfileImage(newValue) } } - // MARK: - Authentication (Dummy Implementation) - - /// Dummy sign in - Replace this with real Apple Sign In implementation func signIn() { - // TODO: Implement real Apple Sign In here - // This is a placeholder for demonstration - isSignedIn = true - userName = "John Doe" - userEmail = "john.doe@example.com" - - // Persist sign in status - storedSignInStatus = true - savedUserName = userName - savedUserEmail = userEmail - + manager.signInDummy() print("User signed in (dummy)") } - /// Sign out user and clear all data func signOut() { - isSignedIn = false - profileImage = nil - userName = "Guest" - userEmail = nil - - // Clear persisted data - storedSignInStatus = false - savedUserName = nil - savedUserEmail = nil - deleteProfileImageFromDisk() - + manager.signOut() print("User signed out") } - - // MARK: - Profile Image Management - - func updateProfileImage(_ image: UIImage?) { - profileImage = image - if image != nil { - saveProfileImageToDisk() - } - } } diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift index dc3870b..446f451 100644 --- a/Tiny/Features/Profile/Views/ProfileView.swift +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -10,30 +10,59 @@ import SwiftUI struct ProfileView: View { @StateObject private var viewModel = ProfileViewModel() @State private var showingSignOutConfirmation = false + @Environment(\.modelContext) private var modelContext + @EnvironmentObject var authService: AuthenticationService + @EnvironmentObject var syncManager: HeartbeatSyncManager + @StateObject private var heartbeatMainViewModel = HeartbeatMainViewModel() + + @State private var showRoomCode = false + @State private var isInitialized = false + + // Check if user is a mother + private var isMother: Bool { + authService.currentUser?.role == .mother + } var body: some View { - NavigationStack { - ZStack { - Image("backgroundPurple") - .resizable() - .scaledToFill() - .ignoresSafeArea() + ZStack { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .ignoresSafeArea() - VStack(spacing: 0) { - // HEADER - profileHeader - .padding(.bottom, 30) + VStack(spacing: 0) { + // HEADER + profileHeader + .padding(.bottom, 30) - // FEATURE CARDS - featureCards - .padding(.horizontal, 16) - .frame(height: 160) + // FEATURE CARDS + featureCards + .padding(.horizontal, 16) + .frame(height: 160) - // SETTINGS LIST - settingsList - } + // SETTINGS LIST + settingsList } } + .onAppear { + // Initialize only once + if !isInitialized { + initializeManager() + } + } + .onChange(of: authService.currentUser?.roomCode) { oldValue, newValue in + // Re-initialize when room code changes + if newValue != nil && newValue != oldValue { + print("🔄 Room code updated: \(newValue ?? "nil")") + initializeManager() + } + } + .sheet(isPresented: $showRoomCode) { + RoomCodeDisplayView() + .environmentObject(authService) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } } // MARK: - View Components @@ -53,7 +82,7 @@ struct ProfileView: View { } .buttonStyle(.plain) - Text(viewModel.isSignedIn ? viewModel.userName : "Guest") + Text(authService.currentUser?.name ?? "Guest") .font(.title2) .fontWeight(.semibold) .foregroundStyle(.white) @@ -131,13 +160,6 @@ struct ProfileView: View { } else { signInView } - } header: { - Text("Account") - } footer: { - if !viewModel.isSignedIn { - Text("Your privacy is protected. We only use your Apple ID to securely save your data.") - .font(.caption2) - } } .listRowBackground(Color("rowProfileGrey")) } @@ -184,11 +206,6 @@ struct ProfileView: View { .cornerRadius(8) } .buttonStyle(.plain) - - Text("Sign in to sync your pregnancy journey across devices") - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) } .padding(.vertical, 8) } @@ -208,10 +225,14 @@ struct ProfileView: View { Spacer() - Text("Connect with Your Partner") - .font(.subheadline) - .foregroundColor(.blue) - .fontWeight(.medium) + Button(action: { + showRoomCode.toggle() + }, label: { + Text("Connect with Your Partner") + .font(.subheadline) + .foregroundColor(.blue) + .fontWeight(.medium) + }) Spacer() } @@ -252,9 +273,54 @@ struct ProfileView: View { .background(Color("rowProfileGrey")) .cornerRadius(14) } + + private func initializeManager() { + Task { + // Auto-create room for mothers if they don't have one + if isMother && authService.currentUser?.roomCode == nil { + do { + let roomCode = try await authService.createRoom() + print("✅ Room created: \(roomCode)") + } catch { + print("❌ Error creating room: \(error)") + } + } + + // Wait a bit for room code to be set + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + + // Now setup the manager with current user data + let userId = authService.currentUser?.id + let roomCode = authService.currentUser?.roomCode + + print("🔍 Initializing manager with:") + print(" User ID: \(userId ?? "nil")") + print(" Room Code: \(roomCode ?? "nil")") + + await MainActor.run { + heartbeatMainViewModel.setupManager( + modelContext: modelContext, + syncManager: syncManager, + userId: userId, + roomCode: roomCode, + userRole: authService.currentUser?.role + ) + isInitialized = true + } + + // For fathers, start in timeline view + if !isMother { + await MainActor.run { + heartbeatMainViewModel.showTimeline = true + } + } + } + } + } struct ProfilePhotoDetailView: View { + @EnvironmentObject var authService: AuthenticationService @ObservedObject var viewModel: ProfileViewModel @State private var showingImagePicker = false @State private var showingCamera = false @@ -284,15 +350,16 @@ struct ProfilePhotoDetailView: View { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Save") { - viewModel.userName = tempUserName - viewModel.saveName() - dismiss() + Task { + try? await authService.updateUserName(name: tempUserName) + dismiss() + } } .disabled(tempUserName.trimmingCharacters(in: .whitespaces).isEmpty) } } .onAppear { - tempUserName = viewModel.userName + tempUserName = authService.currentUser?.name ?? "" } .sheet(isPresented: $showingPhotoOptions) { BottomPhotoPickerSheet( @@ -301,10 +368,16 @@ struct ProfilePhotoDetailView: View { ) } .sheet(isPresented: $showingImagePicker) { - ImagePicker(image: $viewModel.profileImage, sourceType: .photoLibrary) + ImagePicker(image: Binding( + get: { viewModel.profileImage }, + set: { viewModel.profileImage = $0 } + ), sourceType: .photoLibrary) } .fullScreenCover(isPresented: $showingCamera) { - ImagePicker(image: $viewModel.profileImage, sourceType: .camera) + ImagePicker(image: Binding( + get: { viewModel.profileImage }, + set: { viewModel.profileImage = $0 } + ), sourceType: .camera) } } @@ -473,5 +546,7 @@ struct TutorialDummy: View { #Preview { ProfileView() + .environmentObject(AuthenticationService()) // <-- mock + .environmentObject(HeartbeatSyncManager()) .preferredColorScheme(.dark) } diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index 9ba34a1..0b99a58 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -12,16 +12,15 @@ struct PregnancyTimelineView: View { @Binding var showTimeline: Bool let onSelectRecording: (Recording) -> Void let isMother: Bool // Add this parameter - + @Namespace private var animation @State private var selectedWeek: WeekSection? @State private var groupedData: [WeekSection] = [] - + + @ObservedObject private var userProfile = UserProfileManager.shared + var body: some View { - ZStack { - LinearGradient(colors: [Color(red: 0.05, green: 0.05, blue: 0.15), Color.black], startPoint: .top, endPoint: .bottom) - .ignoresSafeArea() - + NavigationStack { ZStack { if let week = selectedWeek { TimelineDetailView( @@ -35,27 +34,30 @@ struct PregnancyTimelineView: View { MainTimelineListView(groupedData: groupedData, selectedWeek: $selectedWeek, animation: animation) .transition(.opacity) } + + navigationButtons + } + .onAppear { + print("📱 Timeline appeared - grouping recordings") + groupRecordings() + } + .onChange(of: heartbeatSoundManager.savedRecordings) { oldValue, newValue in + print("🔄 Recordings changed: \(oldValue.count) -> \(newValue.count)") + groupRecordings() } - - navigationButtons - } - .onAppear { - print("📱 Timeline appeared - grouping recordings") - groupRecordings() - } - .onChange(of: heartbeatSoundManager.savedRecordings) { oldValue, newValue in - print("🔄 Recordings changed: \(oldValue.count) -> \(newValue.count)") - groupRecordings() } } - + private var navigationButtons: some View { VStack { - if selectedWeek != nil { - // Back Button (Detail -> List) - HStack { + // Top Bar + HStack { + if selectedWeek != nil { + // Back Button (Detail -> List) Button { - withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { selectedWeek = nil } + withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { + selectedWeek = nil + } } label: { Image(systemName: "chevron.left") .font(.system(size: 20, weight: .bold)) @@ -65,39 +67,49 @@ struct PregnancyTimelineView: View { } .glassEffect(.clear) .matchedGeometryEffect(id: "navButton", in: animation) - .padding(.leading, 20) - .padding(.top, 0) - Spacer() - } - Spacer() - } else { - // Sync Button (Top Right) - HStack { + + } else { + Spacer() - Button { - print("🔄 Manual sync triggered") - heartbeatSoundManager.loadFromSwiftData() + + NavigationLink { + ProfileView() } label: { - Image(systemName: "arrow.clockwise") - .font(.system(size: 20, weight: .bold)) - .foregroundColor(.white) - .frame(width: 50, height: 50) - .clipShape(Circle()) + Group { + if let image = userProfile.profileImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.white.opacity(0.8)) + } + } + .frame(width: 44, height: 44) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) + .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) } - .glassEffect(.clear) - .padding(.trailing, 20) - .padding(.top, 50) } - - Spacer() - - // Book Button (List -> Close to Orb) + } + .padding(.horizontal, 20) + .padding(.top, 60) + + Spacer() + + if selectedWeek == nil { 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()) + Image(systemName: "book.fill") + .font(.system(size: 28)) + .foregroundColor(.white) + .frame(width: 77, height: 77) + .clipShape(Circle()) } .glassEffect(.clear) .matchedGeometryEffect(id: "navButton", in: animation) @@ -106,21 +118,21 @@ struct PregnancyTimelineView: View { } .ignoresSafeArea(.all, edges: .bottom) } - + private func groupRecordings() { let raw = heartbeatSoundManager.savedRecordings print("📊 Grouping \(raw.count) recordings") - + 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 }) - + print("📊 Created \(groupedData.count) week sections") for section in groupedData { print(" Week \(section.weekNumber): \(section.recordings.count) recordings") diff --git a/Tiny/Resources/Localizable.xcstrings b/Tiny/Resources/Localizable.xcstrings index dd4f0b9..39f4a6e 100644 --- a/Tiny/Resources/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -72,6 +72,10 @@ "comment" : "A caption displayed alongside the slider for adjusting the gain of the microphone.", "isCommentAutoGenerated" : true }, + "20" : { + "comment" : "The number of weeks in a pregnancy.", + "isCommentAutoGenerated" : true + }, "20Hz" : { }, @@ -150,6 +154,10 @@ "comment" : "A button label that dismisses an alert.", "isCommentAutoGenerated" : true }, + "Change Profile Photo" : { + "comment" : "A title for the bottom sheet that appears when a user wants to change their profile photo.", + "isCommentAutoGenerated" : true + }, "Confidence" : { "comment" : "A label describing the confidence level of a heartbeat reading.", "isCommentAutoGenerated" : true @@ -158,6 +166,10 @@ "comment" : "A button label that, when tapped, will direct the user to configure Bluetooth on their device.", "isCommentAutoGenerated" : true }, + "Connect with Your Partner" : { + "comment" : "A call-to-action text displayed below the feature card.", + "isCommentAutoGenerated" : true + }, "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 @@ -166,6 +178,10 @@ "comment" : "A label displayed next to the name of the currently connected audio device.", "isCommentAutoGenerated" : true }, + "Connected Journey" : { + "comment" : "A feature card title.", + "isCommentAutoGenerated" : true + }, "Continue" : { "comment" : "A button label that says \"Continue\" when a user pauses listening to a podcast.", "isCommentAutoGenerated" : true @@ -198,6 +214,18 @@ "comment" : "A text label that appears when the user drags their finger over the orb to save a new recording.", "isCommentAutoGenerated" : true }, + "Dummy Theme View" : { + "comment" : "A placeholder view for a theme-related feature.", + "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.", + "isCommentAutoGenerated" : true + }, "Enhanced proximity audio" : { "comment" : "A description of the spatial audio mode.", "isCommentAutoGenerated" : true @@ -208,6 +236,10 @@ }, "Enter the code from Mom to join your shared parent space." : { + }, + "Enter your name" : { + "comment" : "A placeholder text for a text field where a user can enter their name.", + "isCommentAutoGenerated" : true }, "EQ Configuration" : { @@ -278,6 +310,10 @@ "comment" : "A label describing the number of high-quality heartbeat detections.", "isCommentAutoGenerated" : true }, + "Hold sphere to stop session" : { + "comment" : "A text displayed when a user is holding the orb to stop a live listening session.", + "isCommentAutoGenerated" : true + }, "Hold then drag the sphere" : { "comment" : "A description of how to save or delete a recording.", "isCommentAutoGenerated" : true @@ -337,6 +373,10 @@ "comment" : "The title of an alert that appears when microphone access is denied.", "isCommentAutoGenerated" : true }, + "Name" : { + "comment" : "A label describing the user's name.", + "isCommentAutoGenerated" : true + }, "No heartbeat detected yet" : { "comment" : "A message displayed when no heartbeat data is available.", "isCommentAutoGenerated" : true @@ -397,6 +437,10 @@ "comment" : "A description of how to finish a session by pressing and holding a sphere.", "isCommentAutoGenerated" : true }, + "Privacy Policy" : { + "comment" : "A label for the privacy policy option in the profile settings.", + "isCommentAutoGenerated" : true + }, "Proximity Gain" : { "comment" : "A label displayed next to the value of the proximity gain slider.", "isCommentAutoGenerated" : true @@ -452,6 +496,10 @@ "comment" : "A label for the number of samples taken in the current session.", "isCommentAutoGenerated" : true }, + "Save" : { + "comment" : "The text of a button that saves changes made in the profile.", + "isCommentAutoGenerated" : true + }, "Save or Delete" : { "comment" : "A caption displayed underneath a coach mark in the tutorial overlay.", "isCommentAutoGenerated" : true @@ -476,6 +524,14 @@ "comment" : "A description under the \"Your Room Code\" title, instructing the user to share their room code with their partner.", "isCommentAutoGenerated" : true }, + "Sign in with Apple" : { + "comment" : "A button label that says \"Sign in with Apple\".", + "isCommentAutoGenerated" : true + }, + "Sign Out" : { + "comment" : "A button that signs the user out of their account.", + "isCommentAutoGenerated" : true + }, "Signal Amplitude" : { "comment" : "A heading for the signal amplitude indicator.", "isCommentAutoGenerated" : true @@ -575,6 +631,14 @@ "comment" : "A description below the text field asking the user to input their name.", "isCommentAutoGenerated" : true }, + "Terms and Conditions" : { + "comment" : "A link to the app's terms and conditions.", + "isCommentAutoGenerated" : true + }, + "Theme" : { + "comment" : "A button that allows the user to change the app's theme.", + "isCommentAutoGenerated" : true + }, "Time" : { "comment" : "Data point on the heart rate trend chart.", "isCommentAutoGenerated" : true @@ -587,6 +651,10 @@ "comment" : "A description under the title of the second onboarding page.", "isCommentAutoGenerated" : true }, + "Tutorial" : { + "comment" : "A link to a view that displays a tutorial.", + "isCommentAutoGenerated" : true + }, "Variability" : { "comment" : "A label for the variability of a user's heart rate.", "isCommentAutoGenerated" : true @@ -595,6 +663,10 @@ "comment" : "A label inside the bottom pocket of a folder, showing the current week.", "isCommentAutoGenerated" : true }, + "Weeks" : { + "comment" : "A unit of measurement for weeks.", + "isCommentAutoGenerated" : true + }, "You can always find guides and info in your profile later." : { "comment" : "A description below the \"Let's Begin!\" title, explaining that users can find more information in their profiles later.", "isCommentAutoGenerated" : true @@ -603,6 +675,10 @@ "comment" : "A description of the live and recorded heartbeat features.", "isCommentAutoGenerated" : true }, + "You'll need to sign in again to sync your data and access personalized features." : { + "comment" : "A message displayed in the confirmation dialog when signing out.", + "isCommentAutoGenerated" : true + }, "You're connected to this room" : { "comment" : "A description below a code display, indicating that they are connected to a room.", "isCommentAutoGenerated" : true From d2f8d9ea834582de06882ca45f3930a402c53d32 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 14:01:31 +0700 Subject: [PATCH 12/44] fix: profile in pregnancy timeline view --- Tiny/Features/Timeline/Views/PregnancyTimelineView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index 0b99a58..0538a1a 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -87,7 +87,7 @@ struct PregnancyTimelineView: View { .foregroundColor(.white.opacity(0.8)) } } - .frame(width: 44, height: 44) + .frame(width: 45, height: 45 ) .clipShape(Circle()) .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) @@ -95,7 +95,7 @@ struct PregnancyTimelineView: View { } } .padding(.horizontal, 20) - .padding(.top, 60) + .padding(.top,20) Spacer() From a379a91525e154826509a253df0d2d8fe994223f Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 14:04:27 +0700 Subject: [PATCH 13/44] fix: swiftlint violation --- Tiny/Features/Timeline/Views/PregnancyTimelineView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index 0538a1a..c3bc034 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -95,7 +95,7 @@ struct PregnancyTimelineView: View { } } .padding(.horizontal, 20) - .padding(.top,20) + .padding(.top, 20) Spacer() From de7a696f33cfdf5a6075007189a91c96942a1dd1 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Fri, 28 Nov 2025 15:16:44 +0700 Subject: [PATCH 14/44] fix: profile name input --- Tiny/App/tinyApp.swift | 3 ++- .../Views/PregnancyTimelineView.swift | 21 +++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Tiny/App/tinyApp.swift b/Tiny/App/tinyApp.swift index f5b5402..2e41636 100644 --- a/Tiny/App/tinyApp.swift +++ b/Tiny/App/tinyApp.swift @@ -14,7 +14,7 @@ struct TinyApp: App { @StateObject var heartbeatSoundManager = HeartbeatSoundManager() @StateObject var authService = AuthenticationService() @StateObject var syncManager = HeartbeatSyncManager() - + @State private var isShowingSplashScreen: Bool = true // Add state to control splash screen init() { @@ -45,6 +45,7 @@ struct TinyApp: App { .environmentObject(heartbeatSoundManager) .environmentObject(authService) .environmentObject(syncManager) + .preferredColorScheme(.dark) } } .modelContainer(sharedModelContainer) diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index c3bc034..739c6ef 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -52,6 +52,8 @@ struct PregnancyTimelineView: View { VStack { // Top Bar HStack { + + // LEFT SIDE if selectedWeek != nil { // Back Button (Detail -> List) Button { @@ -70,8 +72,15 @@ struct PregnancyTimelineView: View { } else { - Spacer() + Spacer(minLength: 0) + } + + Spacer() + + // RIGHT SIDE + if selectedWeek == nil { + // Profile Button NavigationLink { ProfileView() } label: { @@ -84,12 +93,15 @@ struct PregnancyTimelineView: View { Image(systemName: "person.crop.circle.fill") .resizable() .scaledToFit() - .foregroundColor(.white.opacity(0.8)) + .foregroundStyle(.white.opacity(0.8)) } } - .frame(width: 45, height: 45 ) + .frame(width: 45, height: 45) .clipShape(Circle()) - .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) + .overlay( + Circle() + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) } } @@ -99,6 +111,7 @@ struct PregnancyTimelineView: View { Spacer() + // Bottom Close Button (Only on List Screen) if selectedWeek == nil { Button { withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { From 5760534c3f08b88c6d3e3106551f3702a41a257f Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Fri, 28 Nov 2025 16:16:20 +0700 Subject: [PATCH 15/44] fix: swiftlint --- Tiny/Core/Components/RoleButton.swift | 4 ++-- .../AuthenticationService.swift | 22 +++++++++---------- .../Authentication/Views/NameInputView.swift | 4 ++-- .../Views/RoleSelectionView.swift | 4 ++-- .../Room/Views/RoomCodeDisplayView.swift | 16 +++++--------- 5 files changed, 22 insertions(+), 28 deletions(-) diff --git a/Tiny/Core/Components/RoleButton.swift b/Tiny/Core/Components/RoleButton.swift index 2367c62..857d7e8 100644 --- a/Tiny/Core/Components/RoleButton.swift +++ b/Tiny/Core/Components/RoleButton.swift @@ -38,7 +38,7 @@ struct RoleButton: View { } } -//#Preview{ +// #Preview{ // RoleSelectionView() // .preferredColorScheme(.dark) -//} +// } diff --git a/Tiny/Core/Services/Authentication/AuthenticationService.swift b/Tiny/Core/Services/Authentication/AuthenticationService.swift index f4420d5..1613c75 100644 --- a/Tiny/Core/Services/Authentication/AuthenticationService.swift +++ b/Tiny/Core/Services/Authentication/AuthenticationService.swift @@ -18,7 +18,7 @@ class AuthenticationService: ObservableObject { @Published var isAuthenticated = false private let auth = Auth.auth() - private let db = Firestore.firestore() + private let database = Firestore.firestore() private var currentNonce: String? @@ -53,7 +53,7 @@ class AuthenticationService: ObservableObject { let result = try await auth.signIn(with: credential) - let userDoc = try await db.collection("users").document(result.user.uid).getDocument() + let userDoc = try await database.collection("users").document(result.user.uid).getDocument() if !userDoc.exists { var displayName: String? @@ -76,7 +76,7 @@ class AuthenticationService: ObservableObject { createdAt: Date() ) - try db.collection("users").document(result.user.uid).setData(from: newUser) + try database.collection("users").document(result.user.uid).setData(from: newUser) self.currentUser = newUser } else { fetchUserData(userId: result.user.uid) @@ -105,7 +105,7 @@ class AuthenticationService: ObservableObject { updateData["roomCode"] = code } - try await db.collection("users").document(userId).updateData(updateData) + try await database.collection("users").document(userId).updateData(updateData) fetchUserData(userId: userId) } @@ -114,7 +114,7 @@ class AuthenticationService: ObservableObject { return } - try await db.collection("users").document(userId).updateData(["name": name]) + try await database.collection("users").document(userId).updateData(["name": name]) fetchUserData(userId: userId) } @@ -133,9 +133,9 @@ class AuthenticationService: ObservableObject { createdAt: Date() ) - let docRef = try db.collection("rooms").addDocument(from: room) + let docRef = try database.collection("rooms").addDocument(from: room) - try await db.collection("users").document(userId).updateData(["roomCode": roomCode]) + try await database.collection("users").document(userId).updateData(["roomCode": roomCode]) fetchUserData(userId: userId) return roomCode @@ -148,7 +148,7 @@ class AuthenticationService: ObservableObject { } private func fetchUserData(userId: String) { - db.collection("users").document(userId).addSnapshotListener { [weak self] snapshot, error in + database.collection("users").document(userId).addSnapshotListener { [weak self] snapshot, error in guard let snapshot = snapshot, snapshot.exists, let data = snapshot.data() else { print("Error fetching user data: \(error?.localizedDescription ?? "Unknown error")") return @@ -207,7 +207,7 @@ class AuthenticationService: ObservableObject { print("🚪 User attempting to join room: \(roomCode)") // Find the room with this code - let snapshot = try await db.collection("rooms") + let snapshot = try await database.collection("rooms") .whereField("code", isEqualTo: roomCode) .limit(to: 1) .getDocuments() @@ -219,14 +219,14 @@ class AuthenticationService: ObservableObject { print("✅ Found room: \(roomDoc.documentID)") // Update the room to add father's user ID - try await db.collection("rooms").document(roomDoc.documentID).updateData([ + try await database.collection("rooms").document(roomDoc.documentID).updateData([ "fatherUserId": userId ]) print("✅ Updated room with father's user ID") // Update user's roomCode - try await db.collection("users").document(userId).updateData([ + try await database.collection("users").document(userId).updateData([ "roomCode": roomCode ]) diff --git a/Tiny/Features/Authentication/Views/NameInputView.swift b/Tiny/Features/Authentication/Views/NameInputView.swift index c77c067..b5e95b1 100644 --- a/Tiny/Features/Authentication/Views/NameInputView.swift +++ b/Tiny/Features/Authentication/Views/NameInputView.swift @@ -112,8 +112,8 @@ struct NameInputView: View { } } -//#Preview { +// #Preview { // NameInputView(selectedRole: .mother) // .environmentObject(AuthenticationService()) // .preferredColorScheme(.dark) -//} +// } diff --git a/Tiny/Features/Authentication/Views/RoleSelectionView.swift b/Tiny/Features/Authentication/Views/RoleSelectionView.swift index 5971776..f00146c 100644 --- a/Tiny/Features/Authentication/Views/RoleSelectionView.swift +++ b/Tiny/Features/Authentication/Views/RoleSelectionView.swift @@ -86,7 +86,7 @@ struct RoleSelectionView: View { } } -//#Preview { +// #Preview { // RoleSelectionView() // .preferredColorScheme(.dark) -//} +// } diff --git a/Tiny/Features/Room/Views/RoomCodeDisplayView.swift b/Tiny/Features/Room/Views/RoomCodeDisplayView.swift index 8d7b24a..d4bf99b 100644 --- a/Tiny/Features/Room/Views/RoomCodeDisplayView.swift +++ b/Tiny/Features/Room/Views/RoomCodeDisplayView.swift @@ -5,14 +5,6 @@ // Created by Benedictus Yogatama Favian Satyajati on 27/11/25. // - -// -// RoomCodeDisplayView.swift -// Tiny -// -// Created by Benedictus Yogatama Favian Satyajati on 27/11/25. -// - import SwiftUI struct RoomCodeDisplayView: View { @@ -29,11 +21,13 @@ struct RoomCodeDisplayView: View { // Header HStack { Spacer() - Button(action: { dismiss() }) { + Button(action: { + dismiss() + }, label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 28)) .foregroundColor(.secondary) - } + }) } .padding(.horizontal) .padding(.top, 10) @@ -140,4 +134,4 @@ struct RoomCodeDisplayView: View { #Preview { RoomCodeDisplayView() .environmentObject(AuthenticationService()) -} \ No newline at end of file +} From 882040f1d2742a6aa3d9204e4402d96235ccd16f Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 13:43:25 +0700 Subject: [PATCH 16/44] fix: listening mode top text --- .../LiveListen/Views/OrbLiveListenView.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift index b70f37b..093aff4 100644 --- a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift +++ b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift @@ -147,9 +147,15 @@ struct OrbLiveListenView: View { if viewModel.isListening && viewModel.isLongPressing { CountdownTextView(countdown: viewModel.longPressCountdown, isVisible: viewModel.isLongPressing) } else if viewModel.isListening { - Text("Listening...") - .font(.title) - .fontWeight(.bold) + VStack(spacing: 8) { + Text("Listening...") + .font(.title) + .fontWeight(.bold) + + Text("Hold sphere to stop session") + .font(.subheadline) + .foregroundStyle(.placeholder) + } } else if viewModel.isPlaybackMode { VStack(spacing: 8) { Text(viewModel.audioPostProcessingManager.isPlaying ? "Playing..." : From dec523e42797e8b26da61ea15020dd781336c269 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Wed, 26 Nov 2025 23:59:41 +0700 Subject: [PATCH 17/44] feat: add profile screen --- .../Profile/ViewModels/ProfileViewModel.swift | 119 +++++ Tiny/Features/Profile/Views/ProfileView.swift | 477 ++++++++++++++++++ .../mainViolet.colorset/Contents.json | 38 ++ .../rowProfileGrey.colorset/Contents.json | 38 ++ 4 files changed, 672 insertions(+) create mode 100644 Tiny/Features/Profile/ViewModels/ProfileViewModel.swift create mode 100644 Tiny/Features/Profile/Views/ProfileView.swift create mode 100644 Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json create mode 100644 Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json diff --git a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift new file mode 100644 index 0000000..71f2b09 --- /dev/null +++ b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift @@ -0,0 +1,119 @@ +// +// ProfileViewModel.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 26/11/25. +// + +import SwiftUI +internal import Combine + +@MainActor +class ProfileViewModel: ObservableObject { + @Published var isSignedIn: Bool = false + @Published var profileImage: UIImage? + @Published var userName: String = "Guest" + @Published var userEmail: String? + + // Settings + @AppStorage("appTheme") var appTheme: String = "System" + @AppStorage("isUserSignedIn") private var storedSignInStatus: Bool = false + @AppStorage("savedUserName") private var savedUserName: String? + @AppStorage("savedUserEmail") private var savedUserEmail: String? + + private var cancellables = Set() + + init() { + loadUserData() + } + + // MARK: - Data Persistence + + private func loadUserData() { + isSignedIn = storedSignInStatus + + if let savedName = savedUserName { + userName = savedName + } + + if let savedEmail = savedUserEmail { + userEmail = savedEmail + } + + loadProfileImageFromDisk() + } + + func saveName() { + savedUserName = userName + print("Name saved: \(userName)") + } + + private func saveProfileImageToDisk() { + guard let image = profileImage, + let data = image.jpegData(compressionQuality: 0.8) else { return } + + let fileURL = getProfileImageURL() + try? data.write(to: fileURL) + } + + private func loadProfileImageFromDisk() { + let fileURL = getProfileImageURL() + guard let data = try? Data(contentsOf: fileURL), + let image = UIImage(data: data) else { return } + + profileImage = image + } + + private func deleteProfileImageFromDisk() { + let fileURL = getProfileImageURL() + try? FileManager.default.removeItem(at: fileURL) + } + + private func getProfileImageURL() -> URL { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return documentsPath.appendingPathComponent("profileImage.jpg") + } + + // MARK: - Authentication (Dummy Implementation) + + /// Dummy sign in - Replace this with real Apple Sign In implementation + func signIn() { + // TODO: Implement real Apple Sign In here + // This is a placeholder for demonstration + isSignedIn = true + userName = "John Doe" + userEmail = "john.doe@example.com" + + // Persist sign in status + storedSignInStatus = true + savedUserName = userName + savedUserEmail = userEmail + + print("User signed in (dummy)") + } + + /// Sign out user and clear all data + func signOut() { + isSignedIn = false + profileImage = nil + userName = "Guest" + userEmail = nil + + // Clear persisted data + storedSignInStatus = false + savedUserName = nil + savedUserEmail = nil + deleteProfileImageFromDisk() + + print("User signed out") + } + + // MARK: - Profile Image Management + + func updateProfileImage(_ image: UIImage?) { + profileImage = image + if image != nil { + saveProfileImageToDisk() + } + } +} diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift new file mode 100644 index 0000000..dc3870b --- /dev/null +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -0,0 +1,477 @@ +// +// ProfileView.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 26/11/25. +// + +import SwiftUI + +struct ProfileView: View { + @StateObject private var viewModel = ProfileViewModel() + @State private var showingSignOutConfirmation = false + + var body: some View { + NavigationStack { + ZStack { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .ignoresSafeArea() + + VStack(spacing: 0) { + // HEADER + profileHeader + .padding(.bottom, 30) + + // FEATURE CARDS + featureCards + .padding(.horizontal, 16) + .frame(height: 160) + + // SETTINGS LIST + settingsList + } + } + } + } + + // MARK: - View Components + + private var profileHeader: some View { + VStack(spacing: 16) { + GeometryReader { geo in + let size = geo.size.width * 0.28 + + VStack(spacing: 16) { + Spacer() + + NavigationLink { + ProfilePhotoDetailView(viewModel: viewModel) + } label: { + profileImageView(size: size) + } + .buttonStyle(.plain) + + Text(viewModel.isSignedIn ? viewModel.userName : "Guest") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(.white) + } + .frame(width: geo.size.width) + } + .frame(height: 260) + } + } + + private func profileImageView(size: CGFloat) -> some View { + Group { + if let img = viewModel.profileImage { + Image(uiImage: img) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.gray) + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + } + + private var featureCards: some View { + HStack(spacing: 12) { + let padding: CGFloat = 5 + let spacing: CGFloat = 12 + let cardWidth = (UIScreen.main.bounds.width - (padding * 5 + spacing)) / 2 + + featureCardLeft(width: cardWidth) + featureCardRight(width: cardWidth) + } + } + + private var settingsList: some View { + List { + settingsSection + accountSection + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(Color.clear) + } + + private var settingsSection: some View { + Section { + NavigationLink(destination: ThemeDummy()) { + Label("Theme", systemImage: "paintpalette.fill") + .foregroundStyle(.white) + } + NavigationLink(destination: TutorialDummy()) { + Label("Tutorial", systemImage: "book.fill") + .foregroundStyle(.white) + } + Link(destination: URL(string: "https://example.com/privacy")!) { + Label("Privacy Policy", systemImage: "shield.righthalf.filled") + .foregroundStyle(.white) + } + Link(destination: URL(string: "https://example.com/terms")!) { + Label("Terms and Conditions", systemImage: "doc.text") + .foregroundStyle(.white) + } + } + .listRowBackground(Color("rowProfileGrey")) + } + + private var accountSection: some View { + Section { + if viewModel.isSignedIn { + signedInView + } else { + signInView + } + } header: { + Text("Account") + } footer: { + if !viewModel.isSignedIn { + Text("Your privacy is protected. We only use your Apple ID to securely save your data.") + .font(.caption2) + } + } + .listRowBackground(Color("rowProfileGrey")) + } + + private var signedInView: some View { + Group { + Button(role: .destructive) { + showingSignOutConfirmation = true + } label: { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + .foregroundStyle(.red) + } + .confirmationDialog( + "Sign Out", + isPresented: $showingSignOutConfirmation, + titleVisibility: .visible + ) { + Button("Sign Out", role: .destructive) { + viewModel.signOut() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("You'll need to sign in again to sync your data and access personalized features.") + } + } + } + + private var signInView: some View { + VStack(spacing: 12) { + // Dummy Sign In Button styled like Apple's + Button { + viewModel.signIn() + } label: { + HStack { + Image(systemName: "applelogo") + .font(.system(size: 20, weight: .medium)) + Text("Sign in with Apple") + .font(.system(size: 17, weight: .semibold)) + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .foregroundStyle(.black) + .background(Color.white) + .cornerRadius(8) + } + .buttonStyle(.plain) + + Text("Sign in to sync your pregnancy journey across devices") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.vertical, 8) + } + + // MARK: - Feature Cards + + private func featureCardLeft(width: CGFloat) -> some View { + VStack(alignment: .leading) { + HStack { + Image(systemName: "heart.fill") + .font(.caption) + .foregroundColor(.white) + Text("Connected Journey") + .font(.caption) + .foregroundColor(.gray) + } + + Spacer() + + Text("Connect with Your Partner") + .font(.subheadline) + .foregroundColor(.blue) + .fontWeight(.medium) + + Spacer() + } + .frame(width: width, height: width * 0.63, alignment: .topLeading) + .padding() + .background(Color("rowProfileGrey")) + .cornerRadius(14) + } + + private func featureCardRight(width: CGFloat) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "calendar") + .font(.caption) + .foregroundColor(.white) + Text("Pregnancy Age") + .font(.caption) + .foregroundColor(.gray) + } + + Spacer() + + VStack(alignment: .center, spacing: 4) { + Text("20") + .font(.title) + .fontWeight(.bold) + .foregroundColor(Color("mainViolet")) + Text("Weeks") + .font(.subheadline) + .foregroundColor(Color("mainViolet")) + } + .frame(maxWidth: .infinity, alignment: .center) + + Spacer() + } + .frame(width: width * 0.65, height: width * 0.63) + .padding() + .background(Color("rowProfileGrey")) + .cornerRadius(14) + } +} + +struct ProfilePhotoDetailView: View { + @ObservedObject var viewModel: ProfileViewModel + @State private var showingImagePicker = false + @State private var showingCamera = false + @State private var showingPhotoOptions = false + @State private var tempUserName: String = "" + @Environment(\.dismiss) private var dismiss + + var body: some View { + ZStack { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .ignoresSafeArea() + + VStack(spacing: 30) { + profilePhotoButton + .padding(.top, 80) + + nameEditSection + .padding(.horizontal, 30) + + Spacer() + } + } + .navigationTitle("Edit Profile") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + viewModel.userName = tempUserName + viewModel.saveName() + dismiss() + } + .disabled(tempUserName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .onAppear { + tempUserName = viewModel.userName + } + .sheet(isPresented: $showingPhotoOptions) { + BottomPhotoPickerSheet( + showingCamera: $showingCamera, + showingImagePicker: $showingImagePicker + ) + } + .sheet(isPresented: $showingImagePicker) { + ImagePicker(image: $viewModel.profileImage, sourceType: .photoLibrary) + } + .fullScreenCover(isPresented: $showingCamera) { + ImagePicker(image: $viewModel.profileImage, sourceType: .camera) + } + } + + private var profilePhotoButton: some View { + Button { + showingPhotoOptions = true + } label: { + ZStack(alignment: .bottomTrailing) { + Group { + if let profileImage = viewModel.profileImage { + Image(uiImage: profileImage) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.gray) + } + } + .frame(width: 200, height: 200) + .clipShape(Circle()) + .overlay( + Circle() + .stroke(Color.white, lineWidth: 3) + ) + + // Camera badge + Image(systemName: "camera.circle.fill") + .font(.system(size: 44)) + .foregroundStyle(.white, Color("rowProfileGrey")) + .offset(x: -10, y: -10) + } + } + .buttonStyle(.plain) + } + + private var nameEditSection: some View { + VStack(spacing: 16) { + HStack { + Text("Name") + .foregroundColor(.gray) + .font(.subheadline) + Spacer() + } + + TextField("Enter your name", text: $tempUserName) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.words) + .submitLabel(.done) + } + } +} + +struct BottomPhotoPickerSheet: View { + @Binding var showingCamera: Bool + @Binding var showingImagePicker: Bool + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 20) { + Text("Change Profile Photo") + .font(.headline) + .foregroundColor(.primary) + .padding(.bottom, 8) + + PhotoPickerButton( + title: "Take Photo", + icon: "camera", + action: { + dismiss() + showingCamera = true + } + ) + + PhotoPickerButton( + title: "Choose From Library", + icon: "photo.on.rectangle", + action: { + dismiss() + showingImagePicker = true + } + ) + } + .padding() + .presentationDetents([.height(220)]) + .presentationDragIndicator(.visible) + } +} + +private struct PhotoPickerButton: View { + let title: String + let icon: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + Image(systemName: icon) + .font(.system(size: 18)) + Text(title) + .font(.system(size: 16, weight: .medium)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(Color("rowProfileGrey")) + .cornerRadius(12) + } + .buttonStyle(.plain) + } +} + +struct ImagePicker: UIViewControllerRepresentable { + @Binding var image: UIImage? + let sourceType: UIImagePickerController.SourceType + @Environment(\.dismiss) var dismiss + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = sourceType + picker.delegate = context.coordinator + picker.allowsEditing = false + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let parent: ImagePicker + + init(_ parent: ImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + if let image = info[.originalImage] as? UIImage { + parent.image = image + } + parent.dismiss() + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.dismiss() + } + } +} + +struct ThemeDummy: View { + var body: some View { + Text("Dummy Theme View") + .navigationTitle("Theme") + } +} + +struct TutorialDummy: View { + var body: some View { + Text("Dummy Tutorial View") + .navigationTitle("Tutorial") + } +} + +#Preview { + ProfileView() + .preferredColorScheme(.dark) +} diff --git a/Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json new file mode 100644 index 0000000..7d97c94 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE8", + "green" : "0x95", + "red" : "0x95" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE8", + "green" : "0x95", + "red" : "0x95" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json new file mode 100644 index 0000000..c195a24 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x28", + "green" : "0x21", + "red" : "0x21" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x28", + "green" : "0x21", + "red" : "0x21" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} From ca3cb16c4a0b690fd4bb2b926d8c6981ac96f2b9 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 00:24:00 +0700 Subject: [PATCH 18/44] feat: add profile navigation in PregnancyTimelineView --- Tiny.xcodeproj/project.pbxproj | 4 + Tiny/App/ContentView.swift | 31 +++- Tiny/App/tinyApp.swift | 44 +---- .../Views/ThemeCustomizationView.swift | 61 ++----- .../LiveListen/Views/HeartbeatMainView.swift | 60 +------ .../LiveListen/Views/OrbLiveListenView.swift | 8 +- .../Profile/Models/UserProfileManager.swift | 106 ++++++++++++ .../Profile/ViewModels/ProfileViewModel.swift | 107 +++--------- Tiny/Features/Profile/Views/ProfileView.swift | 158 +++++++++++++----- .../Views/PregnancyTimelineView.swift | 112 +++++++------ Tiny/Resources/Localizable.xcstrings | 74 +++++++- 11 files changed, 451 insertions(+), 314 deletions(-) create mode 100644 Tiny/Features/Profile/Models/UserProfileManager.swift diff --git a/Tiny.xcodeproj/project.pbxproj b/Tiny.xcodeproj/project.pbxproj index 2e413b1..743a9d8 100644 --- a/Tiny.xcodeproj/project.pbxproj +++ b/Tiny.xcodeproj/project.pbxproj @@ -484,7 +484,9 @@ INFOPLIST_KEY_CFBundleDisplayName = Tiny; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; + INFOPLIST_KEY_NSCameraUsageDescription = "Allow Tiny to use camera for taking profile picture"; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Tiny needs to use the microphone to capture heartbeat sounds."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Allow Tiny to access photos library"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -531,7 +533,9 @@ INFOPLIST_KEY_CFBundleDisplayName = Tiny; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; + INFOPLIST_KEY_NSCameraUsageDescription = "Allow Tiny to use camera for taking profile picture"; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Tiny needs to use the microphone to capture heartbeat sounds."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Allow Tiny to access photos library"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/Tiny/App/ContentView.swift b/Tiny/App/ContentView.swift index 4257428..313861f 100644 --- a/Tiny/App/ContentView.swift +++ b/Tiny/App/ContentView.swift @@ -11,15 +11,21 @@ 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 + @EnvironmentObject var authService: AuthenticationService + @EnvironmentObject var syncManager: HeartbeatSyncManager var body: some View { Group { if hasShownOnboarding { - HeartbeatMainView() + // After onboarding → always go to RootView (SignIn → Onboarding → Main) + RootView() + .environmentObject(heartbeatSoundManager) + .environmentObject(authService) + .environmentObject(syncManager) } else { + // Onboarding only OnBoardingView(hasShownOnboarding: $hasShownOnboarding) } } @@ -31,6 +37,25 @@ struct ContentView: View { } } +struct RootView: View { + @EnvironmentObject var authService: AuthenticationService + + var body: some View { + Group { + if !authService.isAuthenticated { + // Step 1: Landing screen with Sign in with Apple + SignInView() + } else if authService.currentUser?.role == nil { + // Step 2-4: Onboarding flow (role selection, name input, room code) + OnboardingCoordinator() + } else { + // Step 5: Main app - go to HeartbeatMainView + HeartbeatMainView() + } + } + } +} + #Preview { ContentView() .modelContainer(for: SavedHeartbeat.self, inMemory: true) diff --git a/Tiny/App/tinyApp.swift b/Tiny/App/tinyApp.swift index f41ddb7..cfe2e63 100644 --- a/Tiny/App/tinyApp.swift +++ b/Tiny/App/tinyApp.swift @@ -9,38 +9,33 @@ import SwiftUI import SwiftData import FirebaseCore -class AppDelegate: NSObject, UIApplicationDelegate { - func application(_ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { - FirebaseApp.configure() - return true - } -} - @main struct TinyApp: App { - @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate - @StateObject var heartbeatSoundManager = HeartbeatSoundManager() @StateObject private var themeManager = ThemeManager() @StateObject var authService = AuthenticationService() @StateObject var syncManager = HeartbeatSyncManager() - @State private var isShowingSplashScreen: Bool = true // Add state to control splash screen + @State private var isShowingSplashScreen: Bool = true // Add state to control splash screen + + init() { + FirebaseApp.configure() + } + // 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 { @@ -48,9 +43,7 @@ struct TinyApp: App { .environmentObject(themeManager) .preferredColorScheme(.dark) } else { - // ContentView() - // .environmentObject(heartbeatSoundManager) - RootView() + ContentView() .environmentObject(heartbeatSoundManager) .environmentObject(authService) .environmentObject(syncManager) @@ -60,22 +53,3 @@ struct TinyApp: App { .modelContainer(sharedModelContainer) } } - -struct RootView: View { - @EnvironmentObject var authService: AuthenticationService - - var body: some View { - Group { - if !authService.isAuthenticated { - // Step 1: Landing screen with Sign in with Apple - SignInView() - } else if authService.currentUser?.role == nil { - // Step 2-4: Onboarding flow (role selection, name input, room code) - OnboardingCoordinator() - } else { - // Step 5: Main app - go to HeartbeatMainView - HeartbeatMainView() - } - } - } -} diff --git a/Tiny/Features/Customization/Views/ThemeCustomizationView.swift b/Tiny/Features/Customization/Views/ThemeCustomizationView.swift index 7c05c4d..8efc821 100644 --- a/Tiny/Features/Customization/Views/ThemeCustomizationView.swift +++ b/Tiny/Features/Customization/Views/ThemeCustomizationView.swift @@ -9,15 +9,14 @@ import SwiftUI struct ThemeCustomizationView: View { @EnvironmentObject var themeManager: ThemeManager - @Environment(\.dismiss) private var dismiss - + @State private var selectedTab: CustomizationTab = .sphere - + enum CustomizationTab: String, CaseIterable { case sphere = "Sphere" case background = "Background" } - + var body: some View { ZStack { // Background @@ -26,48 +25,22 @@ struct ThemeCustomizationView: View { .resizable() .ignoresSafeArea() .opacity(1) - + VStack(spacing: 0) { - // Header - HStack { - Button(action: { dismiss() }) { - Image(systemName: "chevron.left") - .font(.system(size: 22, weight: .semibold)) - .foregroundColor(.white) - .frame(width: 50, height: 50) - .clipShape(Circle()) - } - .glassEffect(.clear) - - Spacer() - - Text("Theme") - .font(.title2) - .fontWeight(.bold) - .foregroundColor(.white) - - Spacer() - - // Placeholder for symmetry - Color.clear.frame(width: 50, height: 50) - } - .padding(.horizontal, 24) - .padding(.top, 60) - // Preview Orb - Centered in remaining space Spacer() - + ZStack { AnimatedOrbView(size: 240) .environmentObject(themeManager) - + BokehEffectView(amplitude: .constant(0.6)) .environmentObject(themeManager) } .frame(width: 240, height: 240) - + Spacer() - + // Bottom Sheet VStack(spacing: 0) { // Segmented Control @@ -77,7 +50,7 @@ struct ThemeCustomizationView: View { withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { selectedTab = tab } - }) { + }, label: { Text(tab.rawValue) .font(.system(size: 16, weight: .semibold)) .foregroundColor(selectedTab == tab ? .white : .tinyViolet) @@ -87,7 +60,7 @@ struct ThemeCustomizationView: View { RoundedRectangle(cornerRadius: 20) .fill(selectedTab == tab ? Color.tinyViolet : Color.clear) ) - } + }) .buttonStyle(.plain) } } @@ -98,7 +71,7 @@ struct ThemeCustomizationView: View { ) .padding(.horizontal, 24) .padding(.top, 24) - + // Options - Horizontal scroll with selected item prominent ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { @@ -159,6 +132,8 @@ struct ThemeCustomizationView: View { .padding(.bottom, 0) } } + .navigationTitle("Theme") + .navigationBarTitleDisplayMode(.inline) .preferredColorScheme(.dark) } } @@ -168,13 +143,13 @@ struct OrbOptionButton: View { let style: OrbStyles let isSelected: Bool let action: () -> Void - + var body: some View { Button(action: action) { ZStack { // The orb itself AnimatedOrbView(size: isSelected ? 120 : 90, style: style) - + // Subtle glow for selected if isSelected { Circle() @@ -196,7 +171,7 @@ struct BackgroundOptionButton: View { let background: BackgroundTheme let isSelected: Bool let action: () -> Void - + var body: some View { Button(action: action) { ZStack { @@ -209,14 +184,14 @@ struct BackgroundOptionButton: View { ) ) .frame(width: isSelected ? 120 : 90, height: isSelected ? 120 : 90) - + // Border for selected if isSelected { Circle() .stroke(Color.white.opacity(0.6), lineWidth: 3) .frame(width: isSelected ? 120 : 95, height: isSelected ? 120 : 95) } - + // Subtle outer glow if isSelected { Circle() diff --git a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift index 7e26734..a0026c6 100644 --- a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift +++ b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift @@ -13,15 +13,15 @@ struct HeartbeatMainView: View { @Environment(\.modelContext) private var modelContext @EnvironmentObject var authService: AuthenticationService @EnvironmentObject var syncManager: HeartbeatSyncManager - + @State private var showRoomCode = false @State private var isInitialized = false - + // Check if user is a mother private var isMother: Bool { authService.currentUser?.role == .mother } - + var body: some View { ZStack { // Timeline view - accessible by both mom and dad @@ -41,51 +41,9 @@ struct HeartbeatMainView: View { ) .transition(.opacity) } - - // Room Code Button (Top Right) - VStack { - HStack { - Spacer() - - Button(action: { - showRoomCode.toggle() - }) { - Image(systemName: "person.2.fill") - .font(.system(size: 20)) - .foregroundColor(.white) - .padding(12) - .background(Color.white.opacity(0.2)) - .clipShape(Circle()) - } - .padding(.trailing, 20) - .padding(.top, 50) - } - - Spacer() - } - } - .preferredColorScheme(.dark) - .onAppear { - // Initialize only once - if !isInitialized { - initializeManager() - } - } - .onChange(of: authService.currentUser?.roomCode) { oldValue, newValue in - // Re-initialize when room code changes - if newValue != nil && newValue != oldValue { - print("🔄 Room code updated: \(newValue ?? "nil")") - initializeManager() - } - } - .sheet(isPresented: $showRoomCode) { - RoomCodeDisplayView() - .environmentObject(authService) - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) } } - + private func initializeManager() { Task { // Auto-create room for mothers if they don't have one @@ -97,18 +55,18 @@ struct HeartbeatMainView: View { print("❌ Error creating room: \(error)") } } - + // Wait a bit for room code to be set try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds - + // Now setup the manager with current user data let userId = authService.currentUser?.id let roomCode = authService.currentUser?.roomCode - + print("🔍 Initializing manager with:") print(" User ID: \(userId ?? "nil")") print(" Room Code: \(roomCode ?? "nil")") - + await MainActor.run { viewModel.setupManager( modelContext: modelContext, @@ -119,7 +77,7 @@ struct HeartbeatMainView: View { ) isInitialized = true } - + // For fathers, start in timeline view if !isMother { await MainActor.run { diff --git a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift index 093aff4..ebe0c9e 100644 --- a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift +++ b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift @@ -30,9 +30,6 @@ struct OrbLiveListenView: View { libraryOpenButton(geometry: geometry) } - // Theme button (on top of everything) - themeButton - coachMarkView if let context = tutorialViewModel.activeTutorial { @@ -263,14 +260,14 @@ struct OrbLiveListenView: View { if !viewModel.isListening && !viewModel.isPlaybackMode { Button(action: { showThemeCustomization = true - }) { + }, label: { Image(systemName: "paintbrush.fill") .font(.system(size: 20, weight: .semibold)) .foregroundColor(.white) .frame(width: 50, height: 50) .background(Circle().fill(Color.white.opacity(0.1))) .clipShape(Circle()) - } + }) .padding(.leading, 16) .padding(.top, 50) .transition(.opacity.animation(.easeInOut)) @@ -282,7 +279,6 @@ struct OrbLiveListenView: View { .allowsHitTesting(true) // Ensure button is tappable } - struct GestureModifier: ViewModifier { let isPlaybackMode: Bool let geometry: GeometryProxy diff --git a/Tiny/Features/Profile/Models/UserProfileManager.swift b/Tiny/Features/Profile/Models/UserProfileManager.swift new file mode 100644 index 0000000..6821bba --- /dev/null +++ b/Tiny/Features/Profile/Models/UserProfileManager.swift @@ -0,0 +1,106 @@ +// +// UserProfileManager.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 27/11/25. +// + +import SwiftUI +internal import Combine + +class UserProfileManager: ObservableObject { + static let shared = UserProfileManager() + + @Published var profileImage: UIImage? + @Published var userName: String = "Guest" + @Published var userEmail: String? + @Published var isSignedIn: Bool = false + + // Persistence Keys + private let kUserName = "savedUserName" + private let kUserEmail = "savedUserEmail" + private let kIsSignedIn = "isUserSignedIn" + + private init() { + loadUserData() + } + + // MARK: - Data Persistence + + func loadUserData() { + let defaults = UserDefaults.standard + isSignedIn = defaults.bool(forKey: kIsSignedIn) + + if let savedName = defaults.string(forKey: kUserName) { + userName = savedName + } + + if let savedEmail = defaults.string(forKey: kUserEmail) { + userEmail = savedEmail + } + + loadProfileImageFromDisk() + } + + func saveUserData() { + let defaults = UserDefaults.standard + defaults.set(isSignedIn, forKey: kIsSignedIn) + defaults.set(userName, forKey: kUserName) + defaults.set(userEmail, forKey: kUserEmail) + } + + func saveProfileImage(_ image: UIImage?) { + profileImage = image + + if let image = image { + saveProfileImageToDisk(image) + } else { + deleteProfileImageFromDisk() + } + } + + private func saveProfileImageToDisk(_ image: UIImage) { + guard let data = image.jpegData(compressionQuality: 0.8) else { return } + let fileURL = getProfileImageURL() + try? data.write(to: fileURL) + } + + private func loadProfileImageFromDisk() { + let fileURL = getProfileImageURL() + guard let data = try? Data(contentsOf: fileURL), + let image = UIImage(data: data) else { + profileImage = nil + return + } + profileImage = image + } + + private func deleteProfileImageFromDisk() { + let fileURL = getProfileImageURL() + try? FileManager.default.removeItem(at: fileURL) + } + + private func getProfileImageURL() -> URL { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return documentsPath.appendingPathComponent("profileImage.jpg") + } + + // MARK: - Actions + + func signOut() { + isSignedIn = false + userName = "Guest" + userEmail = nil + profileImage = nil + + saveUserData() + deleteProfileImageFromDisk() + } + + func signInDummy() { + isSignedIn = true + userName = "John Doe" + userEmail = "john.doe@example.com" + saveUserData() + } +} diff --git a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift index 71f2b09..7b8270d 100644 --- a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift +++ b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift @@ -10,110 +10,49 @@ internal import Combine @MainActor class ProfileViewModel: ObservableObject { - @Published var isSignedIn: Bool = false - @Published var profileImage: UIImage? - @Published var userName: String = "Guest" - @Published var userEmail: String? + // Observe the singleton manager so this ViewModel publishes changes when manager changes + var manager = UserProfileManager.shared + private var cancellables = Set() - // Settings @AppStorage("appTheme") var appTheme: String = "System" - @AppStorage("isUserSignedIn") private var storedSignInStatus: Bool = false - @AppStorage("savedUserName") private var savedUserName: String? - @AppStorage("savedUserEmail") private var savedUserEmail: String? - - private var cancellables = Set() init() { - loadUserData() - } - - // MARK: - Data Persistence - - private func loadUserData() { - isSignedIn = storedSignInStatus - - if let savedName = savedUserName { - userName = savedName - } - - if let savedEmail = savedUserEmail { - userEmail = savedEmail - } - - loadProfileImageFromDisk() - } - - func saveName() { - savedUserName = userName - print("Name saved: \(userName)") + // Propagate manager changes to this ViewModel + manager.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) } - private func saveProfileImageToDisk() { - guard let image = profileImage, - let data = image.jpegData(compressionQuality: 0.8) else { return } + // MARK: - Computed Properties for View Bindings - let fileURL = getProfileImageURL() - try? data.write(to: fileURL) + var isSignedIn: Bool { + manager.isSignedIn } - private func loadProfileImageFromDisk() { - let fileURL = getProfileImageURL() - guard let data = try? Data(contentsOf: fileURL), - let image = UIImage(data: data) else { return } - - profileImage = image + var userName: String { + get { manager.userName } + set { manager.userName = newValue } } - private func deleteProfileImageFromDisk() { - let fileURL = getProfileImageURL() - try? FileManager.default.removeItem(at: fileURL) + var userEmail: String? { + manager.userEmail } - private func getProfileImageURL() -> URL { - let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - return documentsPath.appendingPathComponent("profileImage.jpg") + // For ImagePicker binding + var profileImage: UIImage? { + get { manager.profileImage } + set { manager.saveProfileImage(newValue) } } - // MARK: - Authentication (Dummy Implementation) - - /// Dummy sign in - Replace this with real Apple Sign In implementation func signIn() { - // TODO: Implement real Apple Sign In here - // This is a placeholder for demonstration - isSignedIn = true - userName = "John Doe" - userEmail = "john.doe@example.com" - - // Persist sign in status - storedSignInStatus = true - savedUserName = userName - savedUserEmail = userEmail - + manager.signInDummy() print("User signed in (dummy)") } - /// Sign out user and clear all data func signOut() { - isSignedIn = false - profileImage = nil - userName = "Guest" - userEmail = nil - - // Clear persisted data - storedSignInStatus = false - savedUserName = nil - savedUserEmail = nil - deleteProfileImageFromDisk() - + manager.signOut() print("User signed out") } - - // MARK: - Profile Image Management - - func updateProfileImage(_ image: UIImage?) { - profileImage = image - if image != nil { - saveProfileImageToDisk() - } - } } diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift index dc3870b..4dd91a8 100644 --- a/Tiny/Features/Profile/Views/ProfileView.swift +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -10,30 +10,60 @@ import SwiftUI struct ProfileView: View { @StateObject private var viewModel = ProfileViewModel() @State private var showingSignOutConfirmation = false + @Environment(\.modelContext) private var modelContext + @EnvironmentObject var authService: AuthenticationService + @EnvironmentObject var syncManager: HeartbeatSyncManager + @StateObject private var heartbeatMainViewModel = HeartbeatMainViewModel() + @EnvironmentObject var themeManager: ThemeManager + + @State private var showRoomCode = false + @State private var isInitialized = false + + // Check if user is a mother + private var isMother: Bool { + authService.currentUser?.role == .mother + } var body: some View { - NavigationStack { - ZStack { - Image("backgroundPurple") - .resizable() - .scaledToFill() - .ignoresSafeArea() + ZStack { + Image(themeManager.selectedBackground.imageName) + .resizable() + .scaledToFill() + .ignoresSafeArea() - VStack(spacing: 0) { - // HEADER - profileHeader - .padding(.bottom, 30) + VStack(spacing: 0) { + // HEADER + profileHeader + .padding(.bottom, 30) - // FEATURE CARDS - featureCards - .padding(.horizontal, 16) - .frame(height: 160) + // FEATURE CARDS + featureCards + .padding(.horizontal, 16) + .frame(height: 160) - // SETTINGS LIST - settingsList - } + // SETTINGS LIST + settingsList } } + .onAppear { + // Initialize only once + if !isInitialized { + initializeManager() + } + } + .onChange(of: authService.currentUser?.roomCode) { oldValue, newValue in + // Re-initialize when room code changes + if newValue != nil && newValue != oldValue { + print("🔄 Room code updated: \(newValue ?? "nil")") + initializeManager() + } + } + .sheet(isPresented: $showRoomCode) { + RoomCodeDisplayView() + .environmentObject(authService) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } } // MARK: - View Components @@ -53,7 +83,7 @@ struct ProfileView: View { } .buttonStyle(.plain) - Text(viewModel.isSignedIn ? viewModel.userName : "Guest") + Text(authService.currentUser?.name ?? "Guest") .font(.title2) .fontWeight(.semibold) .foregroundStyle(.white) @@ -104,7 +134,7 @@ struct ProfileView: View { private var settingsSection: some View { Section { - NavigationLink(destination: ThemeDummy()) { + NavigationLink(destination: ThemeCustomizationView()) { Label("Theme", systemImage: "paintpalette.fill") .foregroundStyle(.white) } @@ -131,13 +161,6 @@ struct ProfileView: View { } else { signInView } - } header: { - Text("Account") - } footer: { - if !viewModel.isSignedIn { - Text("Your privacy is protected. We only use your Apple ID to securely save your data.") - .font(.caption2) - } } .listRowBackground(Color("rowProfileGrey")) } @@ -184,11 +207,6 @@ struct ProfileView: View { .cornerRadius(8) } .buttonStyle(.plain) - - Text("Sign in to sync your pregnancy journey across devices") - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) } .padding(.vertical, 8) } @@ -208,10 +226,14 @@ struct ProfileView: View { Spacer() - Text("Connect with Your Partner") - .font(.subheadline) - .foregroundColor(.blue) - .fontWeight(.medium) + Button(action: { + showRoomCode.toggle() + }, label: { + Text("Connect with Your Partner") + .font(.subheadline) + .foregroundColor(.blue) + .fontWeight(.medium) + }) Spacer() } @@ -252,9 +274,54 @@ struct ProfileView: View { .background(Color("rowProfileGrey")) .cornerRadius(14) } + + private func initializeManager() { + Task { + // Auto-create room for mothers if they don't have one + if isMother && authService.currentUser?.roomCode == nil { + do { + let roomCode = try await authService.createRoom() + print("✅ Room created: \(roomCode)") + } catch { + print("❌ Error creating room: \(error)") + } + } + + // Wait a bit for room code to be set + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + + // Now setup the manager with current user data + let userId = authService.currentUser?.id + let roomCode = authService.currentUser?.roomCode + + print("🔍 Initializing manager with:") + print(" User ID: \(userId ?? "nil")") + print(" Room Code: \(roomCode ?? "nil")") + + await MainActor.run { + heartbeatMainViewModel.setupManager( + modelContext: modelContext, + syncManager: syncManager, + userId: userId, + roomCode: roomCode, + userRole: authService.currentUser?.role + ) + isInitialized = true + } + + // For fathers, start in timeline view + if !isMother { + await MainActor.run { + heartbeatMainViewModel.showTimeline = true + } + } + } + } + } struct ProfilePhotoDetailView: View { + @EnvironmentObject var authService: AuthenticationService @ObservedObject var viewModel: ProfileViewModel @State private var showingImagePicker = false @State private var showingCamera = false @@ -284,15 +351,16 @@ struct ProfilePhotoDetailView: View { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Save") { - viewModel.userName = tempUserName - viewModel.saveName() - dismiss() + Task { + try? await authService.updateUserName(name: tempUserName) + dismiss() + } } .disabled(tempUserName.trimmingCharacters(in: .whitespaces).isEmpty) } } .onAppear { - tempUserName = viewModel.userName + tempUserName = authService.currentUser?.name ?? "" } .sheet(isPresented: $showingPhotoOptions) { BottomPhotoPickerSheet( @@ -301,10 +369,16 @@ struct ProfilePhotoDetailView: View { ) } .sheet(isPresented: $showingImagePicker) { - ImagePicker(image: $viewModel.profileImage, sourceType: .photoLibrary) + ImagePicker(image: Binding( + get: { viewModel.profileImage }, + set: { viewModel.profileImage = $0 } + ), sourceType: .photoLibrary) } .fullScreenCover(isPresented: $showingCamera) { - ImagePicker(image: $viewModel.profileImage, sourceType: .camera) + ImagePicker(image: Binding( + get: { viewModel.profileImage }, + set: { viewModel.profileImage = $0 } + ), sourceType: .camera) } } @@ -473,5 +547,7 @@ struct TutorialDummy: View { #Preview { ProfileView() + .environmentObject(AuthenticationService()) // <-- mock + .environmentObject(HeartbeatSyncManager()) .preferredColorScheme(.dark) } diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index 9ba34a1..0b99a58 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -12,16 +12,15 @@ struct PregnancyTimelineView: View { @Binding var showTimeline: Bool let onSelectRecording: (Recording) -> Void let isMother: Bool // Add this parameter - + @Namespace private var animation @State private var selectedWeek: WeekSection? @State private var groupedData: [WeekSection] = [] - + + @ObservedObject private var userProfile = UserProfileManager.shared + var body: some View { - ZStack { - LinearGradient(colors: [Color(red: 0.05, green: 0.05, blue: 0.15), Color.black], startPoint: .top, endPoint: .bottom) - .ignoresSafeArea() - + NavigationStack { ZStack { if let week = selectedWeek { TimelineDetailView( @@ -35,27 +34,30 @@ struct PregnancyTimelineView: View { MainTimelineListView(groupedData: groupedData, selectedWeek: $selectedWeek, animation: animation) .transition(.opacity) } + + navigationButtons + } + .onAppear { + print("📱 Timeline appeared - grouping recordings") + groupRecordings() + } + .onChange(of: heartbeatSoundManager.savedRecordings) { oldValue, newValue in + print("🔄 Recordings changed: \(oldValue.count) -> \(newValue.count)") + groupRecordings() } - - navigationButtons - } - .onAppear { - print("📱 Timeline appeared - grouping recordings") - groupRecordings() - } - .onChange(of: heartbeatSoundManager.savedRecordings) { oldValue, newValue in - print("🔄 Recordings changed: \(oldValue.count) -> \(newValue.count)") - groupRecordings() } } - + private var navigationButtons: some View { VStack { - if selectedWeek != nil { - // Back Button (Detail -> List) - HStack { + // Top Bar + HStack { + if selectedWeek != nil { + // Back Button (Detail -> List) Button { - withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { selectedWeek = nil } + withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { + selectedWeek = nil + } } label: { Image(systemName: "chevron.left") .font(.system(size: 20, weight: .bold)) @@ -65,39 +67,49 @@ struct PregnancyTimelineView: View { } .glassEffect(.clear) .matchedGeometryEffect(id: "navButton", in: animation) - .padding(.leading, 20) - .padding(.top, 0) - Spacer() - } - Spacer() - } else { - // Sync Button (Top Right) - HStack { + + } else { + Spacer() - Button { - print("🔄 Manual sync triggered") - heartbeatSoundManager.loadFromSwiftData() + + NavigationLink { + ProfileView() } label: { - Image(systemName: "arrow.clockwise") - .font(.system(size: 20, weight: .bold)) - .foregroundColor(.white) - .frame(width: 50, height: 50) - .clipShape(Circle()) + Group { + if let image = userProfile.profileImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.white.opacity(0.8)) + } + } + .frame(width: 44, height: 44) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) + .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) } - .glassEffect(.clear) - .padding(.trailing, 20) - .padding(.top, 50) } - - Spacer() - - // Book Button (List -> Close to Orb) + } + .padding(.horizontal, 20) + .padding(.top, 60) + + Spacer() + + if selectedWeek == nil { 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()) + Image(systemName: "book.fill") + .font(.system(size: 28)) + .foregroundColor(.white) + .frame(width: 77, height: 77) + .clipShape(Circle()) } .glassEffect(.clear) .matchedGeometryEffect(id: "navButton", in: animation) @@ -106,21 +118,21 @@ struct PregnancyTimelineView: View { } .ignoresSafeArea(.all, edges: .bottom) } - + private func groupRecordings() { let raw = heartbeatSoundManager.savedRecordings print("📊 Grouping \(raw.count) recordings") - + 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 }) - + print("📊 Created \(groupedData.count) week sections") for section in groupedData { print(" Week \(section.weekNumber): \(section.recordings.count) recordings") diff --git a/Tiny/Resources/Localizable.xcstrings b/Tiny/Resources/Localizable.xcstrings index 6677735..90c3b07 100644 --- a/Tiny/Resources/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -72,6 +72,10 @@ "comment" : "A caption displayed alongside the slider for adjusting the gain of the microphone.", "isCommentAutoGenerated" : true }, + "20" : { + "comment" : "The number \"20\" displayed in the user interface.", + "isCommentAutoGenerated" : true + }, "20Hz" : { }, @@ -150,6 +154,10 @@ "comment" : "A button label that dismisses an alert.", "isCommentAutoGenerated" : true }, + "Change Profile Photo" : { + "comment" : "A title for the bottom sheet that appears when the user taps on their profile photo.", + "isCommentAutoGenerated" : true + }, "Confidence" : { "comment" : "A label describing the confidence level of a heartbeat reading.", "isCommentAutoGenerated" : true @@ -158,6 +166,10 @@ "comment" : "A button label that, when tapped, will direct the user to configure Bluetooth on their device.", "isCommentAutoGenerated" : true }, + "Connect with Your Partner" : { + "comment" : "A button label that invites the user to connect with their partner.", + "isCommentAutoGenerated" : true + }, "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 @@ -166,6 +178,10 @@ "comment" : "A label displayed next to the name of the currently connected audio device.", "isCommentAutoGenerated" : true }, + "Connected Journey" : { + "comment" : "A feature card title.", + "isCommentAutoGenerated" : true + }, "Continue" : { "comment" : "A button label that says \"Continue\" when a user pauses listening to a podcast.", "isCommentAutoGenerated" : true @@ -198,6 +214,18 @@ "comment" : "A text label that appears when the user drags their finger over the orb to save a new recording.", "isCommentAutoGenerated" : true }, + "Dummy Theme View" : { + "comment" : "A placeholder view for a theme-related feature.", + "isCommentAutoGenerated" : true + }, + "Dummy Tutorial View" : { + "comment" : "A placeholder view for a tutorial screen.", + "isCommentAutoGenerated" : true + }, + "Edit Profile" : { + "comment" : "The title of the view.", + "isCommentAutoGenerated" : true + }, "Enhanced proximity audio" : { "comment" : "A description of the spatial audio mode.", "isCommentAutoGenerated" : true @@ -208,6 +236,10 @@ }, "Enter the code from Mom to join your shared parent space." : { + }, + "Enter your name" : { + "comment" : "A placeholder text for a text field where a user can enter their name.", + "isCommentAutoGenerated" : true }, "EQ Configuration" : { @@ -278,6 +310,10 @@ "comment" : "A label describing the number of high-quality heartbeat detections.", "isCommentAutoGenerated" : true }, + "Hold sphere to stop session" : { + "comment" : "A text displayed when a user is holding the orb to stop a live listening session.", + "isCommentAutoGenerated" : true + }, "Hold then drag the sphere" : { "comment" : "A description of how to save or delete a recording.", "isCommentAutoGenerated" : true @@ -337,6 +373,10 @@ "comment" : "The title of an alert that appears when microphone access is denied.", "isCommentAutoGenerated" : true }, + "Name" : { + "comment" : "A label displayed above the user's name field in the profile view.", + "isCommentAutoGenerated" : true + }, "No heartbeat detected yet" : { "comment" : "A message displayed when no heartbeat data is available.", "isCommentAutoGenerated" : true @@ -397,6 +437,10 @@ "comment" : "A description of how to finish a session by pressing and holding a sphere.", "isCommentAutoGenerated" : true }, + "Privacy Policy" : { + "comment" : "A link to the privacy policy.", + "isCommentAutoGenerated" : true + }, "Proximity Gain" : { "comment" : "A label displayed next to the value of the proximity gain slider.", "isCommentAutoGenerated" : true @@ -452,6 +496,10 @@ "comment" : "A label for the number of samples taken in the current session.", "isCommentAutoGenerated" : true }, + "Save" : { + "comment" : "The text of a button that saves changes made to a user's profile.", + "isCommentAutoGenerated" : true + }, "Save or Delete" : { "comment" : "A caption displayed underneath a coach mark in the tutorial overlay.", "isCommentAutoGenerated" : true @@ -476,6 +524,14 @@ "comment" : "A description under the \"Your Room Code\" title, instructing the user to share their room code with their partner.", "isCommentAutoGenerated" : true }, + "Sign in with Apple" : { + "comment" : "A button label that says \"Sign in with Apple\".", + "isCommentAutoGenerated" : true + }, + "Sign Out" : { + "comment" : "A button that triggers a confirmation dialog to sign out the user.", + "isCommentAutoGenerated" : true + }, "Signal Amplitude" : { "comment" : "A heading for the signal amplitude indicator.", "isCommentAutoGenerated" : true @@ -575,6 +631,10 @@ "comment" : "A description below the text field asking the user to input their name.", "isCommentAutoGenerated" : true }, + "Terms and Conditions" : { + "comment" : "A link to the terms and conditions of the app.", + "isCommentAutoGenerated" : true + }, "Theme" : { "comment" : "The title of the view where users can customize the theme of the app.", "isCommentAutoGenerated" : true @@ -591,6 +651,10 @@ "comment" : "A description under the title of the second onboarding page.", "isCommentAutoGenerated" : true }, + "Tutorial" : { + "comment" : "A link to a view that shows a tutorial.", + "isCommentAutoGenerated" : true + }, "Variability" : { "comment" : "A label for the variability of a user's heart rate.", "isCommentAutoGenerated" : true @@ -599,6 +663,10 @@ "comment" : "A label inside the bottom pocket of a folder, showing the current week.", "isCommentAutoGenerated" : true }, + "Weeks" : { + "comment" : "A unit of measurement for weeks.", + "isCommentAutoGenerated" : true + }, "You can always find guides and info in your profile later." : { "comment" : "A description below the \"Let's Begin!\" title, explaining that users can find more information in their profiles later.", "isCommentAutoGenerated" : true @@ -607,6 +675,10 @@ "comment" : "A description of the live and recorded heartbeat features.", "isCommentAutoGenerated" : true }, + "You'll need to sign in again to sync your data and access personalized features." : { + "comment" : "A message displayed in a confirmation dialog when a user signs out.", + "isCommentAutoGenerated" : true + }, "You're connected to this room" : { "comment" : "A description below a code display, indicating that they are connected to a room.", "isCommentAutoGenerated" : true @@ -621,4 +693,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file From 91bde4814a3b6d07da46d21f5662629e2caa425a Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 14:01:31 +0700 Subject: [PATCH 19/44] fix: profile in pregnancy timeline view --- Tiny/Features/Timeline/Views/PregnancyTimelineView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index 0b99a58..0538a1a 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -87,7 +87,7 @@ struct PregnancyTimelineView: View { .foregroundColor(.white.opacity(0.8)) } } - .frame(width: 44, height: 44) + .frame(width: 45, height: 45 ) .clipShape(Circle()) .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) @@ -95,7 +95,7 @@ struct PregnancyTimelineView: View { } } .padding(.horizontal, 20) - .padding(.top, 60) + .padding(.top,20) Spacer() From 85787780c7271f70850e969640e530dc677aaec5 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 14:04:27 +0700 Subject: [PATCH 20/44] fix: swiftlint violation --- Tiny/Features/Timeline/Views/PregnancyTimelineView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index 0538a1a..c3bc034 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -95,7 +95,7 @@ struct PregnancyTimelineView: View { } } .padding(.horizontal, 20) - .padding(.top,20) + .padding(.top, 20) Spacer() From e61a12eb8c39c0c0bbfea70e55b1795eec879f7c Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Fri, 28 Nov 2025 15:16:44 +0700 Subject: [PATCH 21/44] fix: profile name input --- Tiny/App/tinyApp.swift | 3 ++- .../Views/PregnancyTimelineView.swift | 21 +++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Tiny/App/tinyApp.swift b/Tiny/App/tinyApp.swift index cfe2e63..550ddd8 100644 --- a/Tiny/App/tinyApp.swift +++ b/Tiny/App/tinyApp.swift @@ -15,7 +15,7 @@ struct TinyApp: App { @StateObject private var themeManager = ThemeManager() @StateObject var authService = AuthenticationService() @StateObject var syncManager = HeartbeatSyncManager() - + @State private var isShowingSplashScreen: Bool = true // Add state to control splash screen init() { @@ -48,6 +48,7 @@ struct TinyApp: App { .environmentObject(authService) .environmentObject(syncManager) .environmentObject(themeManager) + .preferredColorScheme(.dark) } } .modelContainer(sharedModelContainer) diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index c3bc034..739c6ef 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -52,6 +52,8 @@ struct PregnancyTimelineView: View { VStack { // Top Bar HStack { + + // LEFT SIDE if selectedWeek != nil { // Back Button (Detail -> List) Button { @@ -70,8 +72,15 @@ struct PregnancyTimelineView: View { } else { - Spacer() + Spacer(minLength: 0) + } + + Spacer() + + // RIGHT SIDE + if selectedWeek == nil { + // Profile Button NavigationLink { ProfileView() } label: { @@ -84,12 +93,15 @@ struct PregnancyTimelineView: View { Image(systemName: "person.crop.circle.fill") .resizable() .scaledToFit() - .foregroundColor(.white.opacity(0.8)) + .foregroundStyle(.white.opacity(0.8)) } } - .frame(width: 45, height: 45 ) + .frame(width: 45, height: 45) .clipShape(Circle()) - .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) + .overlay( + Circle() + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) } } @@ -99,6 +111,7 @@ struct PregnancyTimelineView: View { Spacer() + // Bottom Close Button (Only on List Screen) if selectedWeek == nil { Button { withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { From 5501a05f7df85e7cca9bc6b53b8e9c97382934ec Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 00:24:00 +0700 Subject: [PATCH 22/44] feat: add profile navigation in PregnancyTimelineView --- .../Profile/Models/UserProfileManager.swift | 70 +++++++++++++++++++ .../Profile/ViewModels/ProfileViewModel.swift | 48 +++++++++++++ 2 files changed, 118 insertions(+) diff --git a/Tiny/Features/Profile/Models/UserProfileManager.swift b/Tiny/Features/Profile/Models/UserProfileManager.swift index 6821bba..5dc29ff 100644 --- a/Tiny/Features/Profile/Models/UserProfileManager.swift +++ b/Tiny/Features/Profile/Models/UserProfileManager.swift @@ -10,16 +10,25 @@ internal import Combine class UserProfileManager: ObservableObject { static let shared = UserProfileManager() +<<<<<<< HEAD +======= + +>>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) @Published var profileImage: UIImage? @Published var userName: String = "Guest" @Published var userEmail: String? @Published var isSignedIn: Bool = false +<<<<<<< HEAD +======= + +>>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) // Persistence Keys private let kUserName = "savedUserName" private let kUserEmail = "savedUserEmail" private let kIsSignedIn = "isUserSignedIn" +<<<<<<< HEAD private init() { loadUserData() @@ -42,29 +51,68 @@ class UserProfileManager: ObservableObject { loadProfileImageFromDisk() } +======= + + private init() { + loadUserData() + } + + // MARK: - Data Persistence + + func loadUserData() { + let defaults = UserDefaults.standard + isSignedIn = defaults.bool(forKey: kIsSignedIn) + + if let savedName = defaults.string(forKey: kUserName) { + userName = savedName + } + + if let savedEmail = defaults.string(forKey: kUserEmail) { + userEmail = savedEmail + } + + loadProfileImageFromDisk() + } + +>>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) func saveUserData() { let defaults = UserDefaults.standard defaults.set(isSignedIn, forKey: kIsSignedIn) defaults.set(userName, forKey: kUserName) defaults.set(userEmail, forKey: kUserEmail) } +<<<<<<< HEAD func saveProfileImage(_ image: UIImage?) { profileImage = image +======= + + func saveProfileImage(_ image: UIImage?) { + profileImage = image + +>>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) if let image = image { saveProfileImageToDisk(image) } else { deleteProfileImageFromDisk() } } +<<<<<<< HEAD +======= + +>>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) private func saveProfileImageToDisk(_ image: UIImage) { guard let data = image.jpegData(compressionQuality: 0.8) else { return } let fileURL = getProfileImageURL() try? data.write(to: fileURL) } +<<<<<<< HEAD +======= + +>>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) private func loadProfileImageFromDisk() { let fileURL = getProfileImageURL() guard let data = try? Data(contentsOf: fileURL), @@ -74,29 +122,51 @@ class UserProfileManager: ObservableObject { } profileImage = image } +<<<<<<< HEAD +======= + +>>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) private func deleteProfileImageFromDisk() { let fileURL = getProfileImageURL() try? FileManager.default.removeItem(at: fileURL) } +<<<<<<< HEAD +======= + +>>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) private func getProfileImageURL() -> URL { let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] return documentsPath.appendingPathComponent("profileImage.jpg") } +<<<<<<< HEAD // MARK: - Actions +======= + + // MARK: - Actions + +>>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) func signOut() { isSignedIn = false userName = "Guest" userEmail = nil profileImage = nil +<<<<<<< HEAD saveUserData() deleteProfileImageFromDisk() } +======= + + saveUserData() + deleteProfileImageFromDisk() + } + +>>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) func signInDummy() { isSignedIn = true userName = "John Doe" diff --git a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift index 7b8270d..f4a48dd 100644 --- a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift +++ b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift @@ -8,14 +8,30 @@ import SwiftUI internal import Combine +// +// ProfileViewModel.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 26/11/25. +// + +import SwiftUI +internal import Combine + @MainActor class ProfileViewModel: ObservableObject { // Observe the singleton manager so this ViewModel publishes changes when manager changes var manager = UserProfileManager.shared private var cancellables = Set() +<<<<<<< HEAD @AppStorage("appTheme") var appTheme: String = "System" +======= + + @AppStorage("appTheme") var appTheme: String = "System" + +>>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) init() { // Propagate manager changes to this ViewModel manager.objectWillChange @@ -24,13 +40,37 @@ class ProfileViewModel: ObservableObject { } .store(in: &cancellables) } +<<<<<<< HEAD // MARK: - Computed Properties for View Bindings +======= + + // MARK: - Computed Properties for View Bindings + +>>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) var isSignedIn: Bool { manager.isSignedIn } + + var userName: String { + get { manager.userName } + set { manager.userName = newValue } + } + + var userEmail: String? { + manager.userEmail + } + + // For ImagePicker binding + var profileImage: UIImage? { + get { manager.profileImage } + set { manager.saveProfileImage(newValue) } + } + + // MARK: - Actions +<<<<<<< HEAD var userName: String { get { manager.userName } set { manager.userName = newValue } @@ -46,6 +86,13 @@ class ProfileViewModel: ObservableObject { set { manager.saveProfileImage(newValue) } } +======= + func saveName() { + manager.saveUserData() + print("Name saved: \(manager.userName)") + } + +>>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) func signIn() { manager.signInDummy() print("User signed in (dummy)") @@ -56,3 +103,4 @@ class ProfileViewModel: ObservableObject { print("User signed out") } } + From 28d621a78122198c3e1dcc0dda8c448aabb40a86 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 13:36:56 +0700 Subject: [PATCH 23/44] fix: account section in profile --- .../Profile/Models/UserProfileManager.swift | 70 ------------------- .../Profile/ViewModels/ProfileViewModel.swift | 48 ------------- 2 files changed, 118 deletions(-) diff --git a/Tiny/Features/Profile/Models/UserProfileManager.swift b/Tiny/Features/Profile/Models/UserProfileManager.swift index 5dc29ff..6821bba 100644 --- a/Tiny/Features/Profile/Models/UserProfileManager.swift +++ b/Tiny/Features/Profile/Models/UserProfileManager.swift @@ -10,25 +10,16 @@ internal import Combine class UserProfileManager: ObservableObject { static let shared = UserProfileManager() -<<<<<<< HEAD -======= - ->>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) @Published var profileImage: UIImage? @Published var userName: String = "Guest" @Published var userEmail: String? @Published var isSignedIn: Bool = false -<<<<<<< HEAD -======= - ->>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) // Persistence Keys private let kUserName = "savedUserName" private let kUserEmail = "savedUserEmail" private let kIsSignedIn = "isUserSignedIn" -<<<<<<< HEAD private init() { loadUserData() @@ -51,68 +42,29 @@ class UserProfileManager: ObservableObject { loadProfileImageFromDisk() } -======= - - private init() { - loadUserData() - } - - // MARK: - Data Persistence - - func loadUserData() { - let defaults = UserDefaults.standard - isSignedIn = defaults.bool(forKey: kIsSignedIn) - - if let savedName = defaults.string(forKey: kUserName) { - userName = savedName - } - - if let savedEmail = defaults.string(forKey: kUserEmail) { - userEmail = savedEmail - } - - loadProfileImageFromDisk() - } - ->>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) func saveUserData() { let defaults = UserDefaults.standard defaults.set(isSignedIn, forKey: kIsSignedIn) defaults.set(userName, forKey: kUserName) defaults.set(userEmail, forKey: kUserEmail) } -<<<<<<< HEAD func saveProfileImage(_ image: UIImage?) { profileImage = image -======= - - func saveProfileImage(_ image: UIImage?) { - profileImage = image - ->>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) if let image = image { saveProfileImageToDisk(image) } else { deleteProfileImageFromDisk() } } -<<<<<<< HEAD -======= - ->>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) private func saveProfileImageToDisk(_ image: UIImage) { guard let data = image.jpegData(compressionQuality: 0.8) else { return } let fileURL = getProfileImageURL() try? data.write(to: fileURL) } -<<<<<<< HEAD -======= - ->>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) private func loadProfileImageFromDisk() { let fileURL = getProfileImageURL() guard let data = try? Data(contentsOf: fileURL), @@ -122,51 +74,29 @@ class UserProfileManager: ObservableObject { } profileImage = image } -<<<<<<< HEAD -======= - ->>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) private func deleteProfileImageFromDisk() { let fileURL = getProfileImageURL() try? FileManager.default.removeItem(at: fileURL) } -<<<<<<< HEAD -======= - ->>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) private func getProfileImageURL() -> URL { let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] return documentsPath.appendingPathComponent("profileImage.jpg") } -<<<<<<< HEAD // MARK: - Actions -======= - - // MARK: - Actions - ->>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) func signOut() { isSignedIn = false userName = "Guest" userEmail = nil profileImage = nil -<<<<<<< HEAD saveUserData() deleteProfileImageFromDisk() } -======= - - saveUserData() - deleteProfileImageFromDisk() - } - ->>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) func signInDummy() { isSignedIn = true userName = "John Doe" diff --git a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift index f4a48dd..7b8270d 100644 --- a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift +++ b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift @@ -8,30 +8,14 @@ import SwiftUI internal import Combine -// -// ProfileViewModel.swift -// Tiny -// -// Created by Destu Cikal Ramdani on 26/11/25. -// - -import SwiftUI -internal import Combine - @MainActor class ProfileViewModel: ObservableObject { // Observe the singleton manager so this ViewModel publishes changes when manager changes var manager = UserProfileManager.shared private var cancellables = Set() -<<<<<<< HEAD @AppStorage("appTheme") var appTheme: String = "System" -======= - - @AppStorage("appTheme") var appTheme: String = "System" - ->>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) init() { // Propagate manager changes to this ViewModel manager.objectWillChange @@ -40,37 +24,13 @@ class ProfileViewModel: ObservableObject { } .store(in: &cancellables) } -<<<<<<< HEAD // MARK: - Computed Properties for View Bindings -======= - - // MARK: - Computed Properties for View Bindings - ->>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) var isSignedIn: Bool { manager.isSignedIn } - - var userName: String { - get { manager.userName } - set { manager.userName = newValue } - } - - var userEmail: String? { - manager.userEmail - } - - // For ImagePicker binding - var profileImage: UIImage? { - get { manager.profileImage } - set { manager.saveProfileImage(newValue) } - } - - // MARK: - Actions -<<<<<<< HEAD var userName: String { get { manager.userName } set { manager.userName = newValue } @@ -86,13 +46,6 @@ class ProfileViewModel: ObservableObject { set { manager.saveProfileImage(newValue) } } -======= - func saveName() { - manager.saveUserData() - print("Name saved: \(manager.userName)") - } - ->>>>>>> 1fbb098 (feat: add profile navigation in PregnancyTimelineView) func signIn() { manager.signInDummy() print("User signed in (dummy)") @@ -103,4 +56,3 @@ class ProfileViewModel: ObservableObject { print("User signed out") } } - From 611e0d02e94e6c0626f8c0e9a6e370aa716acc6e Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Fri, 28 Nov 2025 16:16:20 +0700 Subject: [PATCH 24/44] fix: swiftlint --- Tiny/Core/Components/RoleButton.swift | 4 ++-- .../AuthenticationService.swift | 22 +++++++++---------- .../Authentication/Views/NameInputView.swift | 4 ++-- .../Views/RoleSelectionView.swift | 4 ++-- .../Room/Views/RoomCodeDisplayView.swift | 16 +++++--------- 5 files changed, 22 insertions(+), 28 deletions(-) diff --git a/Tiny/Core/Components/RoleButton.swift b/Tiny/Core/Components/RoleButton.swift index 2367c62..857d7e8 100644 --- a/Tiny/Core/Components/RoleButton.swift +++ b/Tiny/Core/Components/RoleButton.swift @@ -38,7 +38,7 @@ struct RoleButton: View { } } -//#Preview{ +// #Preview{ // RoleSelectionView() // .preferredColorScheme(.dark) -//} +// } diff --git a/Tiny/Core/Services/Authentication/AuthenticationService.swift b/Tiny/Core/Services/Authentication/AuthenticationService.swift index f4420d5..1613c75 100644 --- a/Tiny/Core/Services/Authentication/AuthenticationService.swift +++ b/Tiny/Core/Services/Authentication/AuthenticationService.swift @@ -18,7 +18,7 @@ class AuthenticationService: ObservableObject { @Published var isAuthenticated = false private let auth = Auth.auth() - private let db = Firestore.firestore() + private let database = Firestore.firestore() private var currentNonce: String? @@ -53,7 +53,7 @@ class AuthenticationService: ObservableObject { let result = try await auth.signIn(with: credential) - let userDoc = try await db.collection("users").document(result.user.uid).getDocument() + let userDoc = try await database.collection("users").document(result.user.uid).getDocument() if !userDoc.exists { var displayName: String? @@ -76,7 +76,7 @@ class AuthenticationService: ObservableObject { createdAt: Date() ) - try db.collection("users").document(result.user.uid).setData(from: newUser) + try database.collection("users").document(result.user.uid).setData(from: newUser) self.currentUser = newUser } else { fetchUserData(userId: result.user.uid) @@ -105,7 +105,7 @@ class AuthenticationService: ObservableObject { updateData["roomCode"] = code } - try await db.collection("users").document(userId).updateData(updateData) + try await database.collection("users").document(userId).updateData(updateData) fetchUserData(userId: userId) } @@ -114,7 +114,7 @@ class AuthenticationService: ObservableObject { return } - try await db.collection("users").document(userId).updateData(["name": name]) + try await database.collection("users").document(userId).updateData(["name": name]) fetchUserData(userId: userId) } @@ -133,9 +133,9 @@ class AuthenticationService: ObservableObject { createdAt: Date() ) - let docRef = try db.collection("rooms").addDocument(from: room) + let docRef = try database.collection("rooms").addDocument(from: room) - try await db.collection("users").document(userId).updateData(["roomCode": roomCode]) + try await database.collection("users").document(userId).updateData(["roomCode": roomCode]) fetchUserData(userId: userId) return roomCode @@ -148,7 +148,7 @@ class AuthenticationService: ObservableObject { } private func fetchUserData(userId: String) { - db.collection("users").document(userId).addSnapshotListener { [weak self] snapshot, error in + database.collection("users").document(userId).addSnapshotListener { [weak self] snapshot, error in guard let snapshot = snapshot, snapshot.exists, let data = snapshot.data() else { print("Error fetching user data: \(error?.localizedDescription ?? "Unknown error")") return @@ -207,7 +207,7 @@ class AuthenticationService: ObservableObject { print("🚪 User attempting to join room: \(roomCode)") // Find the room with this code - let snapshot = try await db.collection("rooms") + let snapshot = try await database.collection("rooms") .whereField("code", isEqualTo: roomCode) .limit(to: 1) .getDocuments() @@ -219,14 +219,14 @@ class AuthenticationService: ObservableObject { print("✅ Found room: \(roomDoc.documentID)") // Update the room to add father's user ID - try await db.collection("rooms").document(roomDoc.documentID).updateData([ + try await database.collection("rooms").document(roomDoc.documentID).updateData([ "fatherUserId": userId ]) print("✅ Updated room with father's user ID") // Update user's roomCode - try await db.collection("users").document(userId).updateData([ + try await database.collection("users").document(userId).updateData([ "roomCode": roomCode ]) diff --git a/Tiny/Features/Authentication/Views/NameInputView.swift b/Tiny/Features/Authentication/Views/NameInputView.swift index 31bdc41..f70b0f8 100644 --- a/Tiny/Features/Authentication/Views/NameInputView.swift +++ b/Tiny/Features/Authentication/Views/NameInputView.swift @@ -114,8 +114,8 @@ struct NameInputView: View { } } -//#Preview { +// #Preview { // NameInputView(selectedRole: .mother) // .environmentObject(AuthenticationService()) // .preferredColorScheme(.dark) -//} +// } diff --git a/Tiny/Features/Authentication/Views/RoleSelectionView.swift b/Tiny/Features/Authentication/Views/RoleSelectionView.swift index cbb7617..571917d 100644 --- a/Tiny/Features/Authentication/Views/RoleSelectionView.swift +++ b/Tiny/Features/Authentication/Views/RoleSelectionView.swift @@ -88,7 +88,7 @@ struct RoleSelectionView: View { } } -//#Preview { +// #Preview { // RoleSelectionView() // .preferredColorScheme(.dark) -//} +// } diff --git a/Tiny/Features/Room/Views/RoomCodeDisplayView.swift b/Tiny/Features/Room/Views/RoomCodeDisplayView.swift index 8d7b24a..d4bf99b 100644 --- a/Tiny/Features/Room/Views/RoomCodeDisplayView.swift +++ b/Tiny/Features/Room/Views/RoomCodeDisplayView.swift @@ -5,14 +5,6 @@ // Created by Benedictus Yogatama Favian Satyajati on 27/11/25. // - -// -// RoomCodeDisplayView.swift -// Tiny -// -// Created by Benedictus Yogatama Favian Satyajati on 27/11/25. -// - import SwiftUI struct RoomCodeDisplayView: View { @@ -29,11 +21,13 @@ struct RoomCodeDisplayView: View { // Header HStack { Spacer() - Button(action: { dismiss() }) { + Button(action: { + dismiss() + }, label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 28)) .foregroundColor(.secondary) - } + }) } .padding(.horizontal) .padding(.top, 10) @@ -140,4 +134,4 @@ struct RoomCodeDisplayView: View { #Preview { RoomCodeDisplayView() .environmentObject(AuthenticationService()) -} \ No newline at end of file +} From 948e616da5d3c05dc5d25de86f4465d2c3c27d86 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Fri, 28 Nov 2025 17:01:44 +0700 Subject: [PATCH 25/44] fix: swiftlint --- Tiny/Core/Components/Orb/Models/OrbStyles.swift | 1 - Tiny/Features/LiveListen/Views/OrbLiveListenView.swift | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Tiny/Core/Components/Orb/Models/OrbStyles.swift b/Tiny/Core/Components/Orb/Models/OrbStyles.swift index 312248b..dde91d7 100644 --- a/Tiny/Core/Components/Orb/Models/OrbStyles.swift +++ b/Tiny/Core/Components/Orb/Models/OrbStyles.swift @@ -14,7 +14,6 @@ enum OrbStyles: String, CaseIterable, Identifiable { case blue = "Blue" case green = "Green" - var id: String { rawValue } var displayName: String { rawValue } diff --git a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift index ebe0c9e..4eb2b7c 100644 --- a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift +++ b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift @@ -260,7 +260,7 @@ struct OrbLiveListenView: View { if !viewModel.isListening && !viewModel.isPlaybackMode { Button(action: { showThemeCustomization = true - }, label: { + }, label: { Image(systemName: "paintbrush.fill") .font(.system(size: 20, weight: .semibold)) .foregroundColor(.white) From 2eadbc005ee3abe3e894202aff85f6cd2d290ba1 Mon Sep 17 00:00:00 2001 From: Revanza Narendra Date: Fri, 28 Nov 2025 17:16:34 +0700 Subject: [PATCH 26/44] feat: animation timeline --- Tiny.xcodeproj/project.pbxproj | 30 ++- Tiny/App/tinyApp.swift | 21 +- .../TimelineAnimationController.swift | 97 +++++++ Tiny/Core/Models/User.swift | 6 +- .../AuthenticationService.swift | 10 +- .../Authentication/Views/NameInputView.swift | 2 +- .../Views/OnboardingCoordinator.swift | 12 +- .../ViewModels/HeartbeatMainViewModel.swift | 6 +- .../LiveListen/Views/HeartbeatMainView.swift | 29 +-- .../Preview/OnboardingToTimelinePreview.swift | 60 +++++ .../Profile/Models/UserProfileManager.swift | 106 ++++++++ .../Profile/ViewModels/ProfileViewModel.swift | 135 ++++------ Tiny/Features/Profile/Views/ProfileView.swift | 42 +-- .../Features/SignUp/Views/WeekInputView.swift | 228 ++++++++++++++++ .../Timeline/Models/TimelineModel.swift | 51 ++++ .../Timeline/Views/MainTimelineListView.swift | 182 ++++++++----- .../Timeline/Views/PlaceholderDot.swift | 43 ++++ .../Views/PregnancyTimelineView.swift | 243 ++++++++++++++---- .../Timeline/Views/TimelineDetailView.swift | 21 +- .../bgPurple.imageset/Background Main.png | Bin 0 -> 146572 bytes .../bgPurple.imageset/Contents.json | 21 ++ Tiny/Resources/Localizable.xcstrings | 140 +++++++++- 22 files changed, 1203 insertions(+), 282 deletions(-) create mode 100644 Tiny/Core/Animation/TimelineAnimationController.swift create mode 100644 Tiny/Features/Preview/OnboardingToTimelinePreview.swift create mode 100644 Tiny/Features/Profile/Models/UserProfileManager.swift create mode 100644 Tiny/Features/SignUp/Views/WeekInputView.swift create mode 100644 Tiny/Features/Timeline/Views/PlaceholderDot.swift create mode 100644 Tiny/Resources/Assets.xcassets/bgPurple.imageset/Background Main.png create mode 100644 Tiny/Resources/Assets.xcassets/bgPurple.imageset/Contents.json diff --git a/Tiny.xcodeproj/project.pbxproj b/Tiny.xcodeproj/project.pbxproj index 67770e1..8b84384 100644 --- a/Tiny.xcodeproj/project.pbxproj +++ b/Tiny.xcodeproj/project.pbxproj @@ -473,16 +473,21 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Tiny/Tiny.entitlements; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = YPC2WUCUT5; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = YPC2WUCUT5; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Tiny/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Tiny; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; + INFOPLIST_KEY_NSCameraUsageDescription = "Allow Tiny to use camera for taking profile picture"; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Tiny needs to use the microphone to capture heartbeat sounds."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Allow Tiny to access photos library"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -496,6 +501,8 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.miracle.tiny; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = TinyProvisionProfile; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -516,16 +523,21 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Tiny/Tiny.entitlements; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = YPC2WUCUT5; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = YPC2WUCUT5; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Tiny/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Tiny; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; + INFOPLIST_KEY_NSCameraUsageDescription = "Allow Tiny to use camera for taking profile picture"; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Tiny needs to use the microphone to capture heartbeat sounds."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Allow Tiny to access photos library"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -539,6 +551,8 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.miracle.tiny; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = TinyProvisionProfile; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -559,7 +573,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 6PN64DQKLX; + DEVELOPMENT_TEAM = LM2TBH9R6C; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; @@ -581,7 +595,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 6PN64DQKLX; + DEVELOPMENT_TEAM = LM2TBH9R6C; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; @@ -602,7 +616,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 6PN64DQKLX; + DEVELOPMENT_TEAM = LM2TBH9R6C; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.miracle.tinyUITests; @@ -622,7 +636,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 6PN64DQKLX; + DEVELOPMENT_TEAM = LM2TBH9R6C; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.miracle.tinyUITests; diff --git a/Tiny/App/tinyApp.swift b/Tiny/App/tinyApp.swift index 3dc600a..1703df7 100644 --- a/Tiny/App/tinyApp.swift +++ b/Tiny/App/tinyApp.swift @@ -61,16 +61,31 @@ struct TinyApp: App { struct RootView: View { @EnvironmentObject var authService: AuthenticationService + // Check if onboarding is complete + private var isOnboardingComplete: Bool { + guard let user = authService.currentUser, let role = user.role else { + return false + } + + // For mothers: need role + pregnancyWeek + if role == .mother { + return user.pregnancyWeeks != nil || UserDefaults.standard.integer(forKey: "pregnancyWeek") > 0 + } + + // For fathers: need role + roomCode + return user.roomCode != nil + } + var body: some View { Group { if !authService.isAuthenticated { // Step 1: Landing screen with Sign in with Apple SignInView() - } else if authService.currentUser?.role == nil { - // Step 2-4: Onboarding flow (role selection, name input, room code) + } else if !isOnboardingComplete { + // Step 2-4: Onboarding flow (role, name, week/roomCode) OnboardingCoordinator() } else { - // Step 5: Main app - go to HeartbeatMainView + // Step 5: Main app - Timeline is now the main page HeartbeatMainView() } } diff --git a/Tiny/Core/Animation/TimelineAnimationController.swift b/Tiny/Core/Animation/TimelineAnimationController.swift new file mode 100644 index 0000000..cdd4590 --- /dev/null +++ b/Tiny/Core/Animation/TimelineAnimationController.swift @@ -0,0 +1,97 @@ +// +// TimelineAnimationController.swift +// Tiny +// +// Animation state management for timeline first-time experience +// + +import SwiftUI +internal import Combine + +enum AnimationPhase { + case empty + case drawingPath + case showingDots + case transformingOrb + case showingProfile + case complete +} + +class TimelineAnimationController: ObservableObject { + @Published var currentPhase: AnimationPhase = .empty + @Published var pathProgress: CGFloat = 0.0 + @Published var dotsVisible: [Bool] = [false, false, false] + @Published var orbVisible: Bool = false + @Published var profileVisible: Bool = false + + // Timing constants (in seconds) - Slowed down for better visibility + private let pathDuration: Double = 2.5 // Was 1.5 + private let dotDelay: Double = 0.5 // Was 0.33 + private let dotDuration: Double = 0.5 // Was 0.3 + private let transformDuration: Double = 1.0 // Was 0.7 + private let profileDuration: Double = 0.8 // Was 0.6 + + func startAnimation() { + currentPhase = .drawingPath + animatePathDrawing() + } + + private func animatePathDrawing() { + withAnimation(.easeInOut(duration: pathDuration)) { + pathProgress = 1.0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + pathDuration) { + self.currentPhase = .showingDots + self.animateDotsAppearing() + } + } + + private func animateDotsAppearing() { + // Show dots sequentially + for index in 0..<3 { + DispatchQueue.main.asyncAfter(deadline: .now() + (Double(index) * dotDelay)) { + withAnimation(.easeOut(duration: self.dotDuration)) { + self.dotsVisible[index] = true + } + } + } + + // After all dots appear, transform first dot to orb + let totalDotTime = Double(3) * dotDelay + dotDuration + DispatchQueue.main.asyncAfter(deadline: .now() + totalDotTime) { + self.currentPhase = .transformingOrb + self.animateOrbTransform() + } + } + + private func animateOrbTransform() { + withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { + dotsVisible[0] = false + orbVisible = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + transformDuration) { + self.currentPhase = .showingProfile + self.animateProfileAppear() + } + } + + private func animateProfileAppear() { + withAnimation(.easeIn(duration: profileDuration)) { + profileVisible = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + profileDuration) { + self.currentPhase = .complete + } + } + + func skipAnimation() { + currentPhase = .complete + pathProgress = 1.0 + dotsVisible = [false, false, false] + orbVisible = true + profileVisible = true + } +} diff --git a/Tiny/Core/Models/User.swift b/Tiny/Core/Models/User.swift index b790613..7212ffc 100644 --- a/Tiny/Core/Models/User.swift +++ b/Tiny/Core/Models/User.swift @@ -18,7 +18,7 @@ struct User: Codable, Identifiable { var email: String var name: String? var role: UserRole? - var pregnancyMonths: Int? + var pregnancyWeeks: Int? var roomCode: String? var createdAt: Date @@ -26,14 +26,14 @@ struct User: Codable, Identifiable { email: String, name: String? = nil, role: UserRole? = nil, - pregnancyMonths: Int? = nil, + pregnancyWeeks: Int? = nil, roomCode: String? = nil, createdAt: Date) { self.id = id self.email = email self.name = name self.role = role - self.pregnancyMonths = pregnancyMonths + self.pregnancyWeeks = pregnancyWeeks self.roomCode = roomCode self.createdAt = createdAt } diff --git a/Tiny/Core/Services/Authentication/AuthenticationService.swift b/Tiny/Core/Services/Authentication/AuthenticationService.swift index f4420d5..c8f4df9 100644 --- a/Tiny/Core/Services/Authentication/AuthenticationService.swift +++ b/Tiny/Core/Services/Authentication/AuthenticationService.swift @@ -71,7 +71,7 @@ class AuthenticationService: ObservableObject { email: result.user.email ?? "", name: displayName, role: nil, - pregnancyMonths: nil, + pregnancyWeeks: nil, roomCode: nil, createdAt: Date() ) @@ -90,15 +90,15 @@ class AuthenticationService: ObservableObject { return sha256(nonce) } - func updateUserRole(role: UserRole, pregnancyMonths: Int? = nil, roomCode: String? = nil) async throws { + func updateUserRole(role: UserRole, pregnancyWeeks: Int? = nil, roomCode: String? = nil) async throws { guard let userId = auth.currentUser?.uid else { return } var updateData: [String: Any] = ["role": role.rawValue] - if role == .mother, let months = pregnancyMonths { - updateData["pregnancyMonths"] = months + if role == .mother, let weeks = pregnancyWeeks { + updateData["pregnancyWeeks"] = weeks } if role == .father, let code = roomCode, !code.isEmpty { @@ -160,7 +160,7 @@ class AuthenticationService: ObservableObject { email: data["email"] as? String ?? "", name: data["name"] as? String, role: (data["role"] as? String).flatMap { UserRole(rawValue: $0) }, - pregnancyMonths: data["pregnancyMonths"] as? Int, + pregnancyWeeks: data["pregnancyWeeks"] as? Int, roomCode: data["roomCode"] as? String, createdAt: (data["createdAt"] as? Timestamp)?.dateValue() ?? Date() ) diff --git a/Tiny/Features/Authentication/Views/NameInputView.swift b/Tiny/Features/Authentication/Views/NameInputView.swift index c77c067..ef1d29f 100644 --- a/Tiny/Features/Authentication/Views/NameInputView.swift +++ b/Tiny/Features/Authentication/Views/NameInputView.swift @@ -96,7 +96,7 @@ struct NameInputView: View { do { try await authService.updateUserName(name: name.trimmingCharacters(in: .whitespaces)) if selectedRole == .mother { - try await authService.updateUserRole(role: selectedRole, pregnancyMonths: 5) + try await authService.updateUserRole(role: selectedRole, pregnancyWeeks: 5) } else { onContinue() } diff --git a/Tiny/Features/Authentication/Views/OnboardingCoordinator.swift b/Tiny/Features/Authentication/Views/OnboardingCoordinator.swift index 698b196..d99f402 100644 --- a/Tiny/Features/Authentication/Views/OnboardingCoordinator.swift +++ b/Tiny/Features/Authentication/Views/OnboardingCoordinator.swift @@ -10,7 +10,8 @@ import SwiftUI enum OnboardingStep { case roleSelection case nameInput(role: UserRole) - case roomCodeInput + case weekInput // For mothers only + case roomCodeInput // For fathers only } struct OnboardingCoordinator: View { @@ -34,11 +35,18 @@ struct OnboardingCoordinator: View { NameInputView( selectedRole: role, onContinue: { - if role == .father { + if role == .mother { + currentStep = .weekInput + } else { currentStep = .roomCodeInput } } ) + case .weekInput: + WeekInputView(onComplete: { week in + // Week is automatically saved in WeekInputView + // Onboarding is complete, RootView will navigate to timeline + }) case .roomCodeInput: RoomCodeInputView() } diff --git a/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift b/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift index 802f230..232e4c8 100644 --- a/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift +++ b/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift @@ -10,7 +10,7 @@ import SwiftData internal import Combine class HeartbeatMainViewModel: ObservableObject { - @Published var showTimeline = false + @Published var currentPage = 0 // 0 = Timeline (left), 1 = Orb (right) let heartbeatSoundManager = HeartbeatSoundManager() func setupManager( @@ -37,9 +37,9 @@ class HeartbeatMainViewModel: ObservableObject { // Play the recording heartbeatSoundManager.togglePlayback(recording: recording) - // Close timeline and go to orb view for playback (for both mother and father) + // Switch to orb view (page 1) for playback withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { - showTimeline = false + currentPage = 1 } } } diff --git a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift index 7e26734..105b0e6 100644 --- a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift +++ b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift @@ -24,23 +24,29 @@ struct HeartbeatMainView: View { var body: some View { ZStack { - // Timeline view - accessible by both mom and dad - if viewModel.showTimeline { + // TabView with swipe navigation + TabView(selection: $viewModel.currentPage) { + // Left page: Timeline (default) PregnancyTimelineView( heartbeatSoundManager: viewModel.heartbeatSoundManager, - showTimeline: $viewModel.showTimeline, + showTimeline: .constant(true), onSelectRecording: viewModel.handleRecordingSelection, isMother: isMother ) - .transition(.opacity) - } else { - // Orb view - for playback (both) and recording (mother only) + .tag(0) + + // Right page: Orb Live Listen OrbLiveListenView( heartbeatSoundManager: viewModel.heartbeatSoundManager, - showTimeline: $viewModel.showTimeline + showTimeline: Binding( + get: { viewModel.currentPage == 0 }, + set: { if $0 { viewModel.currentPage = 0 } else { viewModel.currentPage = 1 } } + ) ) - .transition(.opacity) + .tag(1) } + .tabViewStyle(.page(indexDisplayMode: .never)) + .ignoresSafeArea() // Room Code Button (Top Right) VStack { @@ -119,13 +125,6 @@ struct HeartbeatMainView: View { ) isInitialized = true } - - // For fathers, start in timeline view - if !isMother { - await MainActor.run { - viewModel.showTimeline = true - } - } } } } diff --git a/Tiny/Features/Preview/OnboardingToTimelinePreview.swift b/Tiny/Features/Preview/OnboardingToTimelinePreview.swift new file mode 100644 index 0000000..4cfdcf9 --- /dev/null +++ b/Tiny/Features/Preview/OnboardingToTimelinePreview.swift @@ -0,0 +1,60 @@ +// +// OnboardingToTimelinePreview.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 28/11/25. +// + +import SwiftUI + +struct OnboardingToTimelinePreview: View { + @State private var showWeekInput = true + @State private var selectedWeek: Int? + @State private var showTimeline = false + + var body: some View { + ZStack { + if showWeekInput { + // Week Input Screen + WeekInputView(onComplete: { week in + selectedWeek = week + + // Reset animation flag to see it every time + UserDefaults.standard.set(false, forKey: "hasSeenTimelineAnimation") + + // Transition to timeline + withAnimation(.easeInOut(duration: 0.5)) { + showWeekInput = false + showTimeline = true + } + }) + .transition(.opacity) + } + + if showTimeline, let week = selectedWeek { + // Timeline with animation + PregnancyTimelineView( + heartbeatSoundManager: createMockManager(), + showTimeline: .constant(true), + onSelectRecording: { recording in + print("Selected: \(recording.fileURL.lastPathComponent)") + }, + isMother: true, + inputWeek: week + ) + .transition(.opacity) + } + } + .preferredColorScheme(.dark) + } + + private func createMockManager() -> HeartbeatSoundManager { + let manager = HeartbeatSoundManager() + // Empty recordings to show placeholder dots + return manager + } +} + +#Preview { + OnboardingToTimelinePreview() +} diff --git a/Tiny/Features/Profile/Models/UserProfileManager.swift b/Tiny/Features/Profile/Models/UserProfileManager.swift new file mode 100644 index 0000000..60a0209 --- /dev/null +++ b/Tiny/Features/Profile/Models/UserProfileManager.swift @@ -0,0 +1,106 @@ +// +// UserProfileManager.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 27/11/25. +// + +import SwiftUI +internal import Combine + +class UserProfileManager: ObservableObject { + static let shared = UserProfileManager() + + @Published var profileImage: UIImage? + @Published var userName: String = "Guest" + @Published var userEmail: String? + @Published var isSignedIn: Bool = false + + // Persistence Keys + private let kUserName = "savedUserName" + private let kUserEmail = "savedUserEmail" + private let kIsSignedIn = "isUserSignedIn" + + private init() { + loadUserData() + } + + // MARK: - Data Persistence + + func loadUserData() { + let defaults = UserDefaults.standard + isSignedIn = defaults.bool(forKey: kIsSignedIn) + + if let savedName = defaults.string(forKey: kUserName) { + userName = savedName + } + + if let savedEmail = defaults.string(forKey: kUserEmail) { + userEmail = savedEmail + } + + loadProfileImageFromDisk() + } + + func saveUserData() { + let defaults = UserDefaults.standard + defaults.set(isSignedIn, forKey: kIsSignedIn) + defaults.set(userName, forKey: kUserName) + defaults.set(userEmail, forKey: kUserEmail) + } + + func saveProfileImage(_ image: UIImage?) { + profileImage = image + + if let image = image { + saveProfileImageToDisk(image) + } else { + deleteProfileImageFromDisk() + } + } + + private func saveProfileImageToDisk(_ image: UIImage) { + guard let data = image.jpegData(compressionQuality: 0.8) else { return } + let fileURL = getProfileImageURL() + try? data.write(to: fileURL) + } + + private func loadProfileImageFromDisk() { + let fileURL = getProfileImageURL() + guard let data = try? Data(contentsOf: fileURL), + let image = UIImage(data: data) else { + profileImage = nil + return + } + profileImage = image + } + + private func deleteProfileImageFromDisk() { + let fileURL = getProfileImageURL() + try? FileManager.default.removeItem(at: fileURL) + } + + private func getProfileImageURL() -> URL { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return documentsPath.appendingPathComponent("profileImage.jpg") + } + + // MARK: - Actions + + func signOut() { + isSignedIn = false + userName = "Guest" + userEmail = nil + profileImage = nil + + saveUserData() + deleteProfileImageFromDisk() + } + + func signInDummy() { + isSignedIn = true + userName = "John Doe" + userEmail = "john.doe@example.com" + saveUserData() + } +} diff --git a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift index 71f2b09..0bb02bb 100644 --- a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift +++ b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift @@ -8,112 +8,69 @@ import SwiftUI internal import Combine -@MainActor -class ProfileViewModel: ObservableObject { - @Published var isSignedIn: Bool = false - @Published var profileImage: UIImage? - @Published var userName: String = "Guest" - @Published var userEmail: String? +// +// ProfileViewModel.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 26/11/25. +// - // Settings - @AppStorage("appTheme") var appTheme: String = "System" - @AppStorage("isUserSignedIn") private var storedSignInStatus: Bool = false - @AppStorage("savedUserName") private var savedUserName: String? - @AppStorage("savedUserEmail") private var savedUserEmail: String? +import SwiftUI +internal import Combine +@MainActor +class ProfileViewModel: ObservableObject { + // Observe the singleton manager so this ViewModel publishes changes when manager changes + var manager = UserProfileManager.shared private var cancellables = Set() - + + @AppStorage("appTheme") var appTheme: String = "System" + init() { - loadUserData() - } - - // MARK: - Data Persistence - - private func loadUserData() { - isSignedIn = storedSignInStatus - - if let savedName = savedUserName { - userName = savedName - } - - if let savedEmail = savedUserEmail { - userEmail = savedEmail - } - - loadProfileImageFromDisk() + // Propagate manager changes to this ViewModel + manager.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) } - - func saveName() { - savedUserName = userName - print("Name saved: \(userName)") + + // MARK: - Computed Properties for View Bindings + + var isSignedIn: Bool { + manager.isSignedIn } - - private func saveProfileImageToDisk() { - guard let image = profileImage, - let data = image.jpegData(compressionQuality: 0.8) else { return } - - let fileURL = getProfileImageURL() - try? data.write(to: fileURL) + + var userName: String { + get { manager.userName } + set { manager.userName = newValue } } - - private func loadProfileImageFromDisk() { - let fileURL = getProfileImageURL() - guard let data = try? Data(contentsOf: fileURL), - let image = UIImage(data: data) else { return } - - profileImage = image + + var userEmail: String? { + manager.userEmail } - - private func deleteProfileImageFromDisk() { - let fileURL = getProfileImageURL() - try? FileManager.default.removeItem(at: fileURL) + + // For ImagePicker binding + var profileImage: UIImage? { + get { manager.profileImage } + set { manager.saveProfileImage(newValue) } } + + // MARK: - Actions - private func getProfileImageURL() -> URL { - let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - return documentsPath.appendingPathComponent("profileImage.jpg") + func saveName() { + manager.saveUserData() + print("Name saved: \(manager.userName)") } - // MARK: - Authentication (Dummy Implementation) - - /// Dummy sign in - Replace this with real Apple Sign In implementation func signIn() { - // TODO: Implement real Apple Sign In here - // This is a placeholder for demonstration - isSignedIn = true - userName = "John Doe" - userEmail = "john.doe@example.com" - - // Persist sign in status - storedSignInStatus = true - savedUserName = userName - savedUserEmail = userEmail - + manager.signInDummy() print("User signed in (dummy)") } - /// Sign out user and clear all data func signOut() { - isSignedIn = false - profileImage = nil - userName = "Guest" - userEmail = nil - - // Clear persisted data - storedSignInStatus = false - savedUserName = nil - savedUserEmail = nil - deleteProfileImageFromDisk() - + manager.signOut() print("User signed out") } - - // MARK: - Profile Image Management - - func updateProfileImage(_ image: UIImage?) { - profileImage = image - if image != nil { - saveProfileImageToDisk() - } - } } + diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift index dc3870b..0e45d41 100644 --- a/Tiny/Features/Profile/Views/ProfileView.swift +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -12,26 +12,24 @@ struct ProfileView: View { @State private var showingSignOutConfirmation = false var body: some View { - NavigationStack { - ZStack { - Image("backgroundPurple") - .resizable() - .scaledToFill() - .ignoresSafeArea() + ZStack { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .ignoresSafeArea() - VStack(spacing: 0) { - // HEADER - profileHeader - .padding(.bottom, 30) + VStack(spacing: 0) { + // HEADER + profileHeader + .padding(.bottom, 30) - // FEATURE CARDS - featureCards - .padding(.horizontal, 16) - .frame(height: 160) + // FEATURE CARDS + featureCards + .padding(.horizontal, 16) + .frame(height: 160) - // SETTINGS LIST - settingsList - } + // SETTINGS LIST + settingsList } } } @@ -301,10 +299,16 @@ struct ProfilePhotoDetailView: View { ) } .sheet(isPresented: $showingImagePicker) { - ImagePicker(image: $viewModel.profileImage, sourceType: .photoLibrary) + ImagePicker(image: Binding( + get: { viewModel.profileImage }, + set: { viewModel.profileImage = $0 } + ), sourceType: .photoLibrary) } .fullScreenCover(isPresented: $showingCamera) { - ImagePicker(image: $viewModel.profileImage, sourceType: .camera) + ImagePicker(image: Binding( + get: { viewModel.profileImage }, + set: { viewModel.profileImage = $0 } + ), sourceType: .camera) } } diff --git a/Tiny/Features/SignUp/Views/WeekInputView.swift b/Tiny/Features/SignUp/Views/WeekInputView.swift new file mode 100644 index 0000000..dbbcca0 --- /dev/null +++ b/Tiny/Features/SignUp/Views/WeekInputView.swift @@ -0,0 +1,228 @@ +// +// WeekInputView.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 26/11/25. +// + +import SwiftUI + +struct WeekDistancePreference: Equatable { + let week: Int + let distance: CGFloat +} + +struct WeekDistanceKey: PreferenceKey { + static var defaultValue: [WeekDistancePreference] = [] + + static func reduce(value: inout [WeekDistancePreference], nextValue: () -> [WeekDistancePreference]) { + value.append(contentsOf: nextValue()) + } +} + +enum PregnancyStage { + case early // 0–12 + case midEarly // 12–20 + case midLate // 20–28 + case late // 28–HPL + + static func stage(for week: Int) -> PregnancyStage { + switch week { + case 0..<12: return .early + case 12..<20: return .midEarly + case 20..<28: return .midLate + default: return .late + } + } + + var description: String { + switch self { + case .early: + return "Tiny can let you hear the sounds from your belly." + case .midEarly: + return "At this stage, the heartbeat may be harder to capture." + case .midLate: + return "At this stage, Tiny is able to capture the heartbeat." + case .late: + return "At this stage, the heartbeat is usually clearer." + } + } +} + +struct WeekInputView: View { + @State private var selectedWeek: Int = 20 + var onComplete: ((Int) -> Void)? = nil // Callback when user completes + + var body: some View { + ZStack { + backgroundView + VStack(spacing: 148) { + VStack(spacing: 33) { + TitleDescView(selectedWeek: $selectedWeek) + CustomWeekPicker( + selectedWeek: $selectedWeek, + weeks: Array(1...42), + height: 254 + ) + } + + Button { + // Save selected week to UserDefaults + UserDefaults.standard.set(selectedWeek, forKey: "pregnancyWeek") + print("💾 Saved pregnancy week: \(selectedWeek)") + + // Call completion handler + onComplete?(selectedWeek) + } label: { + Text("Let's begin") + .font(.body) + .foregroundStyle(.white) + .frame(maxWidth: 152) + .padding(.vertical, 13) + .background( + Color(hex: "393953") + ) + .clipShape(Capsule()) + } + .glassEffect(.clear.interactive()) + } + } + } +} + + +struct TitleDescView: View { + @Binding var selectedWeek: Int + + var body: some View { + VStack(spacing: 0) { + Text("How far along are you?") + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(Color(hex: "E6E6E6")) + + Text(PregnancyStage.stage(for: selectedWeek).description) + .padding() + .font(.caption) + .foregroundStyle(Color(hex: "D1CCFF")) + } + } +} + +private var backgroundView: some View { + ZStack { + Color.black.ignoresSafeArea() + Image("backgroundPurple") + .resizable() + .ignoresSafeArea() + } +} + +struct CustomWeekPicker: View { + @Binding var selectedWeek: Int + let weeks: [Int] + let height: CGFloat + + private let itemHeight: CGFloat = 40 + + init(selectedWeek: Binding, weeks: [Int] = Array(14...20), height: CGFloat = 250) { + self._selectedWeek = selectedWeek + self.weeks = weeks + self.height = height + } + + var body: some View { + ScrollViewReader { proxy in + GeometryReader { geometry in + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(spacing: 0) { + ForEach(weeks, id: \.self) { week in + GeometryReader { itemGeometry in + WeekRow( + week: week, + geometry: geometry, + itemGeometry: itemGeometry + ) + } + .frame(height: itemHeight) + .id(week) + } + } + .padding(.vertical, geometry.size.height / 2 - itemHeight / 2) + } + .coordinateSpace(name: "scroll") + .onPreferenceChange(WeekDistanceKey.self) { preferences in + if let closest = preferences.min(by: { $0.distance < $1.distance }) { + if closest.week != selectedWeek { + DispatchQueue.main.async { + self.selectedWeek = closest.week + } + } + } + } + .onAppear { + proxy.scrollTo(selectedWeek, anchor: .center) + } + .simultaneousGesture( + DragGesture() + .onEnded { _ in + snapToNearestWeek(geometry: geometry, proxy: proxy) + } + ) + } + } + .frame(height: height) + } + + private func snapToNearestWeek(geometry: GeometryProxy, proxy: ScrollViewProxy) { + withAnimation(.spring(response: 0.8, dampingFraction: 0.8)) { + proxy.scrollTo(selectedWeek, anchor: .center) + } + } +} + +struct WeekRow: View { + let week: Int + let geometry: GeometryProxy + let itemGeometry: GeometryProxy + + private var distanceFromCenter: CGFloat { + let itemCenter = itemGeometry.frame(in: .named("scroll")).midY + let screenCenter = geometry.size.height / 2 + return abs(itemCenter - screenCenter) + } + + private var scale: CGFloat { + let maxDistance: CGFloat = 100 + let normalizedDistance = min(distanceFromCenter / maxDistance, 1.0) + return 1.0 - (normalizedDistance * 0.4) + } + + private var opacity: CGFloat { + let maxDistance: CGFloat = 120 + let normalizedDistance = min(distanceFromCenter / maxDistance, 1.0) + return 1.0 - (normalizedDistance * 0.7) + } + + private var textColor: Color { + distanceFromCenter < 25 ? Color(hex: "9595E8") : .white + } + + var body: some View { + Text("\(week) \(week == 1 ? "week" : "weeks")") + .font(.system(size: 31, weight: .regular)) + .foregroundColor(textColor) + .scaleEffect(scale) + .opacity(opacity) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + .preference( + key: WeekDistanceKey.self, + value: [WeekDistancePreference(week: week, distance: distanceFromCenter)] + ) + } +} + +#Preview { + WeekInputView() +} diff --git a/Tiny/Features/Timeline/Models/TimelineModel.swift b/Tiny/Features/Timeline/Models/TimelineModel.swift index bcfb347..93640ae 100644 --- a/Tiny/Features/Timeline/Models/TimelineModel.swift +++ b/Tiny/Features/Timeline/Models/TimelineModel.swift @@ -10,10 +10,22 @@ import Foundation import SwiftUI // MARK: - Data Model +enum WeekType { + case recorded // Week with actual recordings + case placeholder // Future week placeholder +} + struct WeekSection: Identifiable, Equatable { let id = UUID() let weekNumber: Int let recordings: [Recording] + let type: WeekType + + init(weekNumber: Int, recordings: [Recording], type: WeekType = .recorded) { + self.weekNumber = weekNumber + self.recordings = recordings + self.type = type + } } // MARK: - Shared Helper @@ -46,3 +58,42 @@ struct ContinuousWave: Shape { return path } } + +// MARK: - Wave with Gaps for Orbs +struct SegmentedWave: Shape { + var totalHeight: CGFloat + var period: CGFloat + var amplitude: CGFloat + var gapPositions: [CGFloat] // Y positions where gaps should be + var gapSize: CGFloat = 30 // Size of gap around each position + + func path(in rect: CGRect) -> Path { + var path = Path() + let centerX = rect.width / 2 + var isDrawing = false + + // Draw sine wave with gaps at orb positions + for yCoord in stride(from: 0, through: totalHeight, by: 5) { + // Check if we're in a gap + let inGap = gapPositions.contains { abs(yCoord - $0) < gapSize } + + let angle = (yCoord / period) * .pi * 2 + let xCoord = centerX + sin(angle) * amplitude + let point = CGPoint(x: xCoord, y: yCoord) + + if inGap { + // We're in a gap, stop drawing + isDrawing = false + } else { + // We're not in a gap, continue or start drawing + if !isDrawing { + path.move(to: point) + isDrawing = true + } else { + path.addLine(to: point) + } + } + } + return path + } +} diff --git a/Tiny/Features/Timeline/Views/MainTimelineListView.swift b/Tiny/Features/Timeline/Views/MainTimelineListView.swift index 8a4400e..3e90567 100644 --- a/Tiny/Features/Timeline/Views/MainTimelineListView.swift +++ b/Tiny/Features/Timeline/Views/MainTimelineListView.swift @@ -12,11 +12,15 @@ struct MainTimelineListView: View { @Binding var selectedWeek: WeekSection? var animation: Namespace.ID + // Animation support + @ObservedObject var animationController: TimelineAnimationController + var isFirstTimeVisit: Bool = false + // Configuration private let itemSpacing: CGFloat = 160 private let wavePeriod: CGFloat = 600 - private let topPadding: CGFloat = 150 - private let bottomPadding: CGFloat = 200 + private let topPadding: CGFloat = 80 + private let bottomPadding: CGFloat = 100 var body: some View { GeometryReader { geometry in @@ -26,77 +30,131 @@ struct MainTimelineListView: View { 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) + ZStack { + ScrollView(showsIndicators: false) { + ScrollViewReader { proxy in + ZStack(alignment: .top) { + let orbPositions = groupedData.enumerated().map { index, _ in + topPadding + (CGFloat(index) * itemSpacing) + } - let xPos = TimelineLayout.calculateX( - yCoor: yPos, - width: geometry.size.width, + SegmentedWave( + totalHeight: contentHeight, period: wavePeriod, - amplitude: geometry.size.width * 0.35 + amplitude: geometry.size.width * 0.35, + gapPositions: orbPositions, + gapSize: 20 + ) + .trim(from: 1.0 - animationController.pathProgress, to: 1.0) + .stroke( + Color.white.opacity(0.3), + style: StrokeStyle(lineWidth: 2, lineCap: .round) ) + .frame(width: geometry.size.width, height: contentHeight) - 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 + ForEach(Array(groupedData.enumerated()), id: \.element.id) { index, week in + + 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) { + if week.type == .placeholder { + let lastIndex = groupedData.count - 1 + + if isFirstTimeVisit && index == lastIndex && animationController.orbVisible { + 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 + } + } + } else if isFirstTimeVisit { + let reversedIndex = lastIndex - index + if reversedIndex < animationController.dotsVisible.count && animationController.dotsVisible[reversedIndex] { + PlaceholderDot() + } + } else if !isFirstTimeVisit { + PlaceholderDot() + } + } else { + let shouldShowOrb = !isFirstTimeVisit || (isFirstTimeVisit && animationController.orbVisible) + + if shouldShowOrb { + 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 + } + } + } + } + + let shouldShowLabel: Bool = { + if week.type == .recorded { + return true + } else if week.type == .placeholder { + let lastIndex = groupedData.count - 1 + return isFirstTimeVisit && index == lastIndex && animationController.orbVisible + } + return false + }() + + if shouldShowLabel { + Text("Week \(week.weekNumber)") + .font(.headline) + .foregroundColor(.white) + .padding(6) + .matchedGeometryEffect(id: "label_\(week.weekNumber)", in: animation) } } - - // 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) } - .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) + .frame(width: geometry.size.width, height: contentHeight) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + if let last = groupedData.last { + withAnimation(.easeOut(duration: 0.5)) { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } } } } + + LinearGradient( + stops: [ + .init(color: .clear, location: 0.0), + .init(color: .black.opacity(0.2), location: 0.2), + .init(color: .black.opacity(0.5), location: 0.4), + .init(color: .black.opacity(0.8), location: 0.6), + .init(color: .black.opacity(0.95), location: 0.8), + .init(color: .black, location: 1.0) + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: geometry.size.height * 0.3) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .allowsHitTesting(false) + .ignoresSafeArea() } } } diff --git a/Tiny/Features/Timeline/Views/PlaceholderDot.swift b/Tiny/Features/Timeline/Views/PlaceholderDot.swift new file mode 100644 index 0000000..0d187ef --- /dev/null +++ b/Tiny/Features/Timeline/Views/PlaceholderDot.swift @@ -0,0 +1,43 @@ +// +// PlaceholderDot.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 28/11/25. +// + +import SwiftUI + +struct PlaceholderDot: View { + @State private var isPulsing = false + + var body: some View { + ZStack { + // Outer glow + Circle() + .fill(Color.white.opacity(0.2)) + .frame(width: 16, height: 16) + .blur(radius: 4) + .scaleEffect(isPulsing ? 1.2 : 1.0) + + // Inner dot + Circle() + .fill(Color.white.opacity(0.8)) + .frame(width: 8, height: 8) + } + .onAppear { + withAnimation( + .easeInOut(duration: 1.5) + .repeatForever(autoreverses: true) + ) { + isPulsing = true + } + } + } +} + +#Preview { + ZStack { + Color.black.ignoresSafeArea() + PlaceholderDot() + } +} diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index 9ba34a1..f0d4c98 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -12,48 +12,66 @@ struct PregnancyTimelineView: View { @Binding var showTimeline: Bool let onSelectRecording: (Recording) -> Void let isMother: Bool // Add this parameter + var inputWeek: Int? = nil // Week from onboarding input @Namespace private var animation @State private var selectedWeek: WeekSection? @State private var groupedData: [WeekSection] = [] + // Animation support + @StateObject private var animationController = TimelineAnimationController() + @State private var isFirstTimeVisit: Bool = false + + @ObservedObject private var userProfile = UserProfileManager.shared + var body: some View { - ZStack { - LinearGradient(colors: [Color(red: 0.05, green: 0.05, blue: 0.15), Color.black], startPoint: .top, endPoint: .bottom) - .ignoresSafeArea() - + NavigationStack { ZStack { + Color.black.ignoresSafeArea() + Image("backgroundPurple") + .resizable() + .ignoresSafeArea() + if let week = selectedWeek { TimelineDetailView( week: week, animation: animation, onSelectRecording: onSelectRecording, - isMother: isMother // Pass it here + isMother: isMother ) .transition(.opacity) } else { - MainTimelineListView(groupedData: groupedData, selectedWeek: $selectedWeek, animation: animation) - .transition(.opacity) + MainTimelineListView( + groupedData: groupedData, + selectedWeek: $selectedWeek, + animation: animation, + animationController: animationController, + isFirstTimeVisit: isFirstTimeVisit + ) + .transition(.opacity) } + + navigationButtons } - - navigationButtons + .onAppear(perform: groupRecordings) } .onAppear { - print("📱 Timeline appeared - grouping recordings") - groupRecordings() + print("Timeline appeared") + initializeTimeline() } .onChange(of: heartbeatSoundManager.savedRecordings) { oldValue, newValue in - print("🔄 Recordings changed: \(oldValue.count) -> \(newValue.count)") + print("Recordings changed: \(oldValue.count) -> \(newValue.count)") groupRecordings() } } private var navigationButtons: some View { VStack { - if selectedWeek != nil { - // Back Button (Detail -> List) - HStack { + // Top Bar + HStack { + if selectedWeek != nil { + // Back Button (Detail -> List) + Button { withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { selectedWeek = nil } } label: { @@ -65,48 +83,76 @@ struct PregnancyTimelineView: View { } .glassEffect(.clear) .matchedGeometryEffect(id: "navButton", in: animation) - .padding(.leading, 20) - .padding(.top, 0) + } else { Spacer() } - Spacer() - } else { - // Sync Button (Top Right) - HStack { - Spacer() - Button { - print("🔄 Manual sync triggered") - heartbeatSoundManager.loadFromSwiftData() - } label: { - Image(systemName: "arrow.clockwise") - .font(.system(size: 20, weight: .bold)) - .foregroundColor(.white) - .frame(width: 50, height: 50) - .clipShape(Circle()) - } - .glassEffect(.clear) - .padding(.trailing, 20) - .padding(.top, 50) - } Spacer() - // Book Button (List -> Close to Orb) - Button { - withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { - showTimeline = false + // Profile Button (Top Right) + if selectedWeek == nil { + NavigationLink { + ProfileView() + } label: { + Group { + if let image = userProfile.profileImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.white.opacity(0.8)) + } + } + .frame(width: 44, height: 44) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) + .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) } - } label: { - Image(systemName: "book.fill").font(.system(size: 28)).foregroundColor(.white).frame(width: 77, height: 77).clipShape(Circle()) + .opacity(isFirstTimeVisit ? (animationController.profileVisible ? 1.0 : 0.0) : 1.0) } - .glassEffect(.clear) - .matchedGeometryEffect(id: "navButton", in: animation) - .padding(.bottom, 50) } + .padding(.horizontal, 20) + .padding(.top, 60) + + Spacer() } .ignoresSafeArea(.all, edges: .bottom) } + private func initializeTimeline() { + // Check if this is first time visit + isFirstTimeVisit = !UserDefaults.standard.bool(forKey: "hasSeenTimelineAnimation") + + // Get week from parameter or UserDefaults + let week = inputWeek ?? UserDefaults.standard.integer(forKey: "pregnancyWeek") + + if isFirstTimeVisit, week > 0 { + // First time: Create initial data with placeholder dots + print("🎬 First time visit - creating initial timeline for week \(week)") + + // Create 3 weeks: reversed order (newest at bottom) + groupedData = [ + WeekSection(weekNumber: week + 2, recordings: [], type: .placeholder), + WeekSection(weekNumber: week + 1, recordings: [], type: .placeholder), + WeekSection(weekNumber: week, recordings: [], type: .placeholder) + ] + + // Start animation after a brief delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.animationController.startAnimation() + } + + // Mark as seen + UserDefaults.standard.set(true, forKey: "hasSeenTimelineAnimation") + } else { + // Normal visit: Group recordings + groupRecordings() + } + } + private func groupRecordings() { let raw = heartbeatSoundManager.savedRecordings print("📊 Grouping \(raw.count) recordings") @@ -117,13 +163,112 @@ struct PregnancyTimelineView: View { 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 }) + var recordedWeeks = grouped.map { + WeekSection(weekNumber: $0.key, recordings: $0.value.sorted(by: { $0.createdAt > $1.createdAt }), type: .recorded) + }.sorted(by: { $0.weekNumber > $1.weekNumber }) // Reversed: newest (highest week) at bottom + + // Add placeholder weeks if we have inputWeek and no recordings yet + if let week = inputWeek, recordedWeeks.isEmpty { + recordedWeeks = [ + WeekSection(weekNumber: week + 2, recordings: [], type: .placeholder), + WeekSection(weekNumber: week + 1, recordings: [], type: .placeholder), + WeekSection(weekNumber: week, recordings: [], type: .placeholder) + ] + } + + self.groupedData = recordedWeeks print("📊 Created \(groupedData.count) week sections") for section in groupedData { - print(" Week \(section.weekNumber): \(section.recordings.count) recordings") + print(" Week \(section.weekNumber): \(section.recordings.count) recordings (\(section.type))") } } } + +#Preview { + @Previewable @State var showTimeline = true + + // Create mock HeartbeatSoundManager with sample recordings + let mockManager = HeartbeatSoundManager() + + // Create sample recordings across different weeks + let calendar = Calendar.current + let now = Date() + + // 10 weeks of data going back in time + // Week 1 (9 weeks ago): 2 recordings + if let weekDate = calendar.date(byAdding: .day, value: -63, to: now) { + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week1-rec1.wav"), createdAt: weekDate)) + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week1-rec2.wav"), createdAt: weekDate.addingTimeInterval(3600))) + } + + // Week 2 (8 weeks ago): 3 recordings + if let weekDate = calendar.date(byAdding: .day, value: -56, to: now) { + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week2-rec1.wav"), createdAt: weekDate)) + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week2-rec2.wav"), createdAt: weekDate.addingTimeInterval(7200))) + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week2-rec3.wav"), createdAt: weekDate.addingTimeInterval(14400))) + } + + // Week 3 (7 weeks ago): 1 recording + if let weekDate = calendar.date(byAdding: .day, value: -49, to: now) { + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week3-rec1.wav"), createdAt: weekDate)) + } + + // Week 4 (6 weeks ago): 4 recordings + if let weekDate = calendar.date(byAdding: .day, value: -42, to: now) { + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week4-rec1.wav"), createdAt: weekDate)) + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week4-rec2.wav"), createdAt: weekDate.addingTimeInterval(3600))) + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week4-rec3.wav"), createdAt: weekDate.addingTimeInterval(7200))) + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week4-rec4.wav"), createdAt: weekDate.addingTimeInterval(10800))) + } + + // Week 5 (5 weeks ago): 2 recordings + if let weekDate = calendar.date(byAdding: .day, value: -35, to: now) { + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week5-rec1.wav"), createdAt: weekDate)) + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week5-rec2.wav"), createdAt: weekDate.addingTimeInterval(5400))) + } + + // Week 6 (4 weeks ago): 3 recordings + if let weekDate = calendar.date(byAdding: .day, value: -28, to: now) { + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week6-rec1.wav"), createdAt: weekDate)) + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week6-rec2.wav"), createdAt: weekDate.addingTimeInterval(3600))) + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week6-rec3.wav"), createdAt: weekDate.addingTimeInterval(7200))) + } + + // Week 7 (3 weeks ago): 2 recordings + if let weekDate = calendar.date(byAdding: .day, value: -21, to: now) { + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week7-rec1.wav"), createdAt: weekDate)) + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week7-rec2.wav"), createdAt: weekDate.addingTimeInterval(4800))) + } + + // Week 8 (2 weeks ago): 3 recordings + if let weekDate = calendar.date(byAdding: .day, value: -14, to: now) { + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week8-rec1.wav"), createdAt: weekDate)) + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week8-rec2.wav"), createdAt: weekDate.addingTimeInterval(3600))) + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week8-rec3.wav"), createdAt: weekDate.addingTimeInterval(7200))) + } + + // Week 9 (1 week ago): 1 recording + if let weekDate = calendar.date(byAdding: .day, value: -7, to: now) { + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week9-rec1.wav"), createdAt: weekDate)) + } + + // Week 10 (current week): 4 recordings + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week10-rec1.wav"), createdAt: now.addingTimeInterval(-10800))) + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week10-rec2.wav"), createdAt: now.addingTimeInterval(-7200))) + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week10-rec3.wav"), createdAt: now.addingTimeInterval(-3600))) + mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week10-rec4.wav"), createdAt: now)) + + // Reset animation flag to see the animation every time + UserDefaults.standard.set(false, forKey: "hasSeenTimelineAnimation") + + return PregnancyTimelineView( + heartbeatSoundManager: mockManager, + showTimeline: $showTimeline, + onSelectRecording: { recording in + print("Selected recording: \(recording.fileURL.lastPathComponent)") + }, + isMother: true, + inputWeek: 20 // Test with week 20 + ) +} diff --git a/Tiny/Features/Timeline/Views/TimelineDetailView.swift b/Tiny/Features/Timeline/Views/TimelineDetailView.swift index 8812f4e..24a42ea 100644 --- a/Tiny/Features/Timeline/Views/TimelineDetailView.swift +++ b/Tiny/Features/Timeline/Views/TimelineDetailView.swift @@ -33,20 +33,10 @@ struct TimelineDetailView: View { 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) } } @@ -56,7 +46,17 @@ struct TimelineDetailView: View { let recHeight = max(geometry.size.height - 300, CGFloat(recordings.count) * recSpacing + 200) return ScrollView(showsIndicators: false) { + // The "Hero" Orb (Animated from previous screen) + ZStack { + AnimatedOrbView(size: 115) + .shadow(color: .orange.opacity(0.6), radius: 30) + } + .matchedGeometryEffect(id: "orb_\(week.weekNumber)", in: animation) + .frame(height: 115) + .padding(.vertical, 20) + ZStack(alignment: .top) { + // Tighter Wavy Path for details ContinuousWave( totalHeight: recHeight, @@ -99,7 +99,6 @@ struct TimelineDetailView: View { } } - // MARK: - Components var glowingDot: some View { ZStack { Circle().fill(Color.white).frame(width: 8, height: 8) diff --git a/Tiny/Resources/Assets.xcassets/bgPurple.imageset/Background Main.png b/Tiny/Resources/Assets.xcassets/bgPurple.imageset/Background Main.png new file mode 100644 index 0000000000000000000000000000000000000000..30f900e3c7e0fb64f31c0850ae4f8cdafcd13da2 GIT binary patch literal 146572 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 aD&w@)I3)S26yV?-q@@nr22p+f=KlfH7}$3J literal 0 HcmV?d00001 diff --git a/Tiny/Resources/Assets.xcassets/bgPurple.imageset/Contents.json b/Tiny/Resources/Assets.xcassets/bgPurple.imageset/Contents.json new file mode 100644 index 0000000..15ef0ca --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/bgPurple.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Background Main.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Localizable.xcstrings b/Tiny/Resources/Localizable.xcstrings index dd4f0b9..ad76b34 100644 --- a/Tiny/Resources/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -16,6 +16,18 @@ "comment" : "A text view displaying a number. The text inside the parentheses is the number to be displayed.", "isCommentAutoGenerated" : true }, + "%lld %@" : { + "comment" : "A text label displaying a week number, with an optional scale and opacity effect. The first argument is the week number. The second argument is a Boolean value indicating whether the text should be in a highlighted state. The third argument is a Boolean value indicating whether the text should be in a selected state. The fourth argument is a Boolean value indicating whether the text should be in a disabled state. The fifth argument is a", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld %2$@" + } + } + } + }, "%lld/%lld" : { "comment" : "A label showing the ratio of high-quality heartbeat detection samples to the total number of samples. The first argument is the count of high-quality samples. The second argument is the total count of samples.", "isCommentAutoGenerated" : true, @@ -72,6 +84,10 @@ "comment" : "A caption displayed alongside the slider for adjusting the gain of the microphone.", "isCommentAutoGenerated" : true }, + "20" : { + "comment" : "The number \"20\" displayed in the \"Pregnancy Age\" feature card.", + "isCommentAutoGenerated" : true + }, "20Hz" : { }, @@ -83,6 +99,10 @@ "comment" : "A label displayed next to the rightmost text in the \"Frequency Spectrum\" section of the audio visualization view.", "isCommentAutoGenerated" : true }, + "Account" : { + "comment" : "A section header in the profile view that reads \"Account\".", + "isCommentAutoGenerated" : true + }, "Adaptive Gain" : { "comment" : "A label displayed above a toggle switch that controls adaptive gain.", "isCommentAutoGenerated" : true @@ -150,6 +170,10 @@ "comment" : "A button label that dismisses an alert.", "isCommentAutoGenerated" : true }, + "Change Profile Photo" : { + "comment" : "A title for the bottom sheet that appears when the user taps on their profile photo.", + "isCommentAutoGenerated" : true + }, "Confidence" : { "comment" : "A label describing the confidence level of a heartbeat reading.", "isCommentAutoGenerated" : true @@ -158,6 +182,10 @@ "comment" : "A button label that, when tapped, will direct the user to configure Bluetooth on their device.", "isCommentAutoGenerated" : true }, + "Connect with Your Partner" : { + "comment" : "A call-to-action text that encourages users to connect with their partners.", + "isCommentAutoGenerated" : true + }, "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 @@ -166,6 +194,10 @@ "comment" : "A label displayed next to the name of the currently connected audio device.", "isCommentAutoGenerated" : true }, + "Connected Journey" : { + "comment" : "A feature card title.", + "isCommentAutoGenerated" : true + }, "Continue" : { "comment" : "A button label that says \"Continue\" when a user pauses listening to a podcast.", "isCommentAutoGenerated" : true @@ -198,6 +230,18 @@ "comment" : "A text label that appears when the user drags their finger over the orb to save a new recording.", "isCommentAutoGenerated" : true }, + "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.", + "isCommentAutoGenerated" : true + }, "Enhanced proximity audio" : { "comment" : "A description of the spatial audio mode.", "isCommentAutoGenerated" : true @@ -207,13 +251,20 @@ "isCommentAutoGenerated" : true }, "Enter the code from Mom to join your shared parent space." : { - + "comment" : "A description below the text field where the user inputs the room code.", + "isCommentAutoGenerated" : true + }, + "Enter your name" : { + "comment" : "A placeholder text for a text field where a user can enter their name.", + "isCommentAutoGenerated" : true }, "EQ Configuration" : { - + "comment" : "A heading for the section of the audio post-processing test interface that lists the user's EQ settings.", + "isCommentAutoGenerated" : true }, "EQ Settings Applied" : { - + "comment" : "A description of the EQ settings that have been applied to the audio.", + "isCommentAutoGenerated" : true }, "EQ Test" : { "comment" : "A tab label for the audio processing test view.", @@ -232,7 +283,8 @@ "isCommentAutoGenerated" : true }, "Finish session" : { - + "comment" : "A label displayed next to a button that pauses a session.", + "isCommentAutoGenerated" : true }, "Frequency Spectrum" : { "comment" : "A title for the frequency spectrum visualization.", @@ -278,6 +330,10 @@ "comment" : "A label describing the number of high-quality heartbeat detections.", "isCommentAutoGenerated" : true }, + "Hold sphere to stop session" : { + "comment" : "A text displayed below the \"Listening...\" text, instructing the user to hold the orb to stop the current recording session.", + "isCommentAutoGenerated" : true + }, "Hold then drag the sphere" : { "comment" : "A description of how to save or delete a recording.", "isCommentAutoGenerated" : true @@ -286,6 +342,10 @@ "comment" : "A label displayed in the center of the countdown view, instructing the user to hold their finger to stop the countdown.", "isCommentAutoGenerated" : true }, + "How far along are you?" : { + "comment" : "A title displayed above the view that asks the user how far along they are.", + "isCommentAutoGenerated" : true + }, "I can call you..." : { "comment" : "A placeholder text for a text field where the user can input their name.", "isCommentAutoGenerated" : true @@ -313,6 +373,10 @@ "comment" : "A label displayed above the option to share the user's last recording.", "isCommentAutoGenerated" : true }, + "Let's begin" : { + "comment" : "A button label that says \"Let's begin\".", + "isCommentAutoGenerated" : true + }, "Let's go" : { "comment" : "A button label that says \"Let's go\" and is used to proceed to the next page of the onboarding flow.", "isCommentAutoGenerated" : true @@ -337,12 +401,17 @@ "comment" : "The title of an alert that appears when microphone access is denied.", "isCommentAutoGenerated" : true }, + "Name" : { + "comment" : "A label describing the user's name.", + "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." : { - + "comment" : "A message displayed when there is no recorded heartbeat sound to process. It encourages the user to record a heartbeat sound first.", + "isCommentAutoGenerated" : true }, "No room code" : { "comment" : "A message displayed when a user is not in a room, explaining that they should ask their partner for the room code.", @@ -373,17 +442,20 @@ "isCommentAutoGenerated" : true }, "Play Last Recording with EQ" : { - + "comment" : "A button label that allows the user to play a recording with equalization applied.", + "isCommentAutoGenerated" : true }, "Play or Pause" : { "comment" : "A label displayed next to a play/pause button in the tutorial overlay.", "isCommentAutoGenerated" : true }, "Playing with EQ" : { - + "comment" : "A message indicating that audio is being played with equalization applied.", + "isCommentAutoGenerated" : true }, "Playing..." : { - + "comment" : "A label indicating that audio is currently playing.", + "isCommentAutoGenerated" : true }, "Please enable microphone access in Settings to use this feature." : { "comment" : "An alert message explaining that microphone access is needed to use the app.", @@ -397,6 +469,10 @@ "comment" : "A description of how to finish a session by pressing and holding a sphere.", "isCommentAutoGenerated" : true }, + "Privacy Policy" : { + "comment" : "A label for a privacy policy option in the profile settings.", + "isCommentAutoGenerated" : true + }, "Proximity Gain" : { "comment" : "A label displayed next to the value of the proximity gain slider.", "isCommentAutoGenerated" : true @@ -406,7 +482,8 @@ "isCommentAutoGenerated" : true }, "Range" : { - + "comment" : "A label describing the range of a statistic.", + "isCommentAutoGenerated" : true }, "Recent Detections" : { "comment" : "A header for the most recent heartbeat detections.", @@ -452,6 +529,10 @@ "comment" : "A label for the number of samples taken in the current session.", "isCommentAutoGenerated" : true }, + "Save" : { + "comment" : "A button to save changes made to the user's profile.", + "isCommentAutoGenerated" : true + }, "Save or Delete" : { "comment" : "A caption displayed underneath a coach mark in the tutorial overlay.", "isCommentAutoGenerated" : true @@ -473,7 +554,18 @@ "isCommentAutoGenerated" : true }, "Share this code with your partner" : { - "comment" : "A description under the \"Your Room Code\" title, instructing the user to share their room code with their partner.", + + }, + "Sign in to sync your pregnancy journey across devices" : { + "comment" : "A description below the \"Sign in with Apple\" button in the Profile view, explaining that it allows users to sync their pregnancy journey across different devices.", + "isCommentAutoGenerated" : true + }, + "Sign in with Apple" : { + "comment" : "A button label that says \"Sign in with Apple\".", + "isCommentAutoGenerated" : true + }, + "Sign Out" : { + "comment" : "A button that signs the user out of their account.", "isCommentAutoGenerated" : true }, "Signal Amplitude" : { @@ -575,6 +667,14 @@ "comment" : "A description below the text field asking the user to input their name.", "isCommentAutoGenerated" : true }, + "Terms and Conditions" : { + "comment" : "A link to the terms and conditions of the app.", + "isCommentAutoGenerated" : true + }, + "Theme" : { + "comment" : "A button that allows the user to change the app's theme.", + "isCommentAutoGenerated" : true + }, "Time" : { "comment" : "Data point on the heart rate trend chart.", "isCommentAutoGenerated" : true @@ -587,6 +687,10 @@ "comment" : "A description under the title of the second onboarding page.", "isCommentAutoGenerated" : true }, + "Tutorial" : { + "comment" : "A link to a view that displays a tutorial.", + "isCommentAutoGenerated" : true + }, "Variability" : { "comment" : "A label for the variability of a user's heart rate.", "isCommentAutoGenerated" : true @@ -595,16 +699,28 @@ "comment" : "A label inside the bottom pocket of a folder, showing the current week.", "isCommentAutoGenerated" : true }, + "Weeks" : { + "comment" : "A unit of measurement for pregnancy weeks.", + "isCommentAutoGenerated" : true + }, "You can always find guides and info in your profile later." : { - "comment" : "A description below the \"Let's Begin!\" title, explaining that users can find more information in their profiles later.", + "comment" : "A text displayed below the \"Let's Begin!\" title, instructing the user that they can find more information in their profile later.", "isCommentAutoGenerated" : true }, "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 }, + "You'll need to sign in again to sync your data and access personalized features." : { + "comment" : "A message displayed in the confirmation dialog when signing out.", + "isCommentAutoGenerated" : true + }, "You're connected to this room" : { - "comment" : "A description below a code display, indicating that they are connected to a room.", + "comment" : "A description below a code display, indicating that the user is connected to a room.", + "isCommentAutoGenerated" : true + }, + "Your privacy is protected. We only use your Apple ID to securely save your data." : { + "comment" : "A footer text in the profile view that explains data privacy.", "isCommentAutoGenerated" : true }, "Your recording will be saved automatically in your library." : { From 1760e9f6c1532c027ad7a40943a64bbb8817f326 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 00:24:00 +0700 Subject: [PATCH 27/44] feat: add profile navigation in PregnancyTimelineView --- Tiny/Core/Animation/TimelineAnimationController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tiny/Core/Animation/TimelineAnimationController.swift b/Tiny/Core/Animation/TimelineAnimationController.swift index cdd4590..e0370f3 100644 --- a/Tiny/Core/Animation/TimelineAnimationController.swift +++ b/Tiny/Core/Animation/TimelineAnimationController.swift @@ -26,8 +26,8 @@ class TimelineAnimationController: ObservableObject { // Timing constants (in seconds) - Slowed down for better visibility private let pathDuration: Double = 2.5 // Was 1.5 - private let dotDelay: Double = 0.5 // Was 0.33 - private let dotDuration: Double = 0.5 // Was 0.3 + private let dotDelay: Double = 0.9 // Was 0.33 + private let dotDuration: Double = 0.9 // Was 0.3 private let transformDuration: Double = 1.0 // Was 0.7 private let profileDuration: Double = 0.8 // Was 0.6 From d87bcccecc9ddf54b249d993aba7da93c9f5088a Mon Sep 17 00:00:00 2001 From: Revanza Narendra Date: Sat, 29 Nov 2025 18:13:52 +0700 Subject: [PATCH 28/44] fix: playback issue --- .../Authentication/Views/NameInputView.swift | 20 ++- .../Views/OnboardingCoordinator.swift | 11 +- .../ViewModels/HeartbeatMainViewModel.swift | 6 +- .../LiveListen/Views/HeartbeatMainView.swift | 33 ++-- .../LiveListen/Views/OrbLiveListenView.swift | 58 +++++-- .../Timeline/Views/MainTimelineListView.swift | 56 +++++-- .../Views/PregnancyTimelineView.swift | 157 +++++++++++------- .../Timeline/Views/TimelineDetailView.swift | 101 +++++++---- Tiny/Resources/Localizable.xcstrings | 5 +- 9 files changed, 289 insertions(+), 158 deletions(-) diff --git a/Tiny/Features/Authentication/Views/NameInputView.swift b/Tiny/Features/Authentication/Views/NameInputView.swift index e2fbb9e..50a539d 100644 --- a/Tiny/Features/Authentication/Views/NameInputView.swift +++ b/Tiny/Features/Authentication/Views/NameInputView.swift @@ -97,18 +97,20 @@ struct NameInputView: View { Task { do { try await authService.updateUserName(name: name.trimmingCharacters(in: .whitespaces)) - if selectedRole == .mother { - try await authService.updateUserRole(role: selectedRole, pregnancyWeeks: 5) - } else { + // For mothers, proceed to week input before setting role + // For fathers, set role now and proceed to room code input + if selectedRole == .father { + try await authService.updateUserRole(role: selectedRole) + } + await MainActor.run { + isLoading = false onContinue() } } catch { - errorMessage = error.localizedDescription - isLoading = false - } - - if selectedRole == .father { - isLoading = false + await MainActor.run { + errorMessage = error.localizedDescription + isLoading = false + } } } } diff --git a/Tiny/Features/Authentication/Views/OnboardingCoordinator.swift b/Tiny/Features/Authentication/Views/OnboardingCoordinator.swift index d99f402..82cfb87 100644 --- a/Tiny/Features/Authentication/Views/OnboardingCoordinator.swift +++ b/Tiny/Features/Authentication/Views/OnboardingCoordinator.swift @@ -44,8 +44,15 @@ struct OnboardingCoordinator: View { ) case .weekInput: WeekInputView(onComplete: { week in - // Week is automatically saved in WeekInputView - // Onboarding is complete, RootView will navigate to timeline + Task { + do { + // Update user role and pregnancy week in Firebase + try await authService.updateUserRole(role: .mother, pregnancyWeeks: week) + print("✅ Successfully saved pregnancy week: \(week)") + } catch { + print("❌ Error saving pregnancy week: \(error.localizedDescription)") + } + } }) case .roomCodeInput: RoomCodeInputView() diff --git a/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift b/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift index 232e4c8..058b4c9 100644 --- a/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift +++ b/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift @@ -31,13 +31,11 @@ class HeartbeatMainViewModel: ObservableObject { func handleRecordingSelection(_ recording: Recording) { print("🎵 Recording selected: \(recording.fileURL.lastPathComponent)") - // Set as last recording + // Set as last recording (but don't auto-play) heartbeatSoundManager.lastRecording = recording - // Play the recording - heartbeatSoundManager.togglePlayback(recording: recording) - // Switch to orb view (page 1) for playback + // User will need to tap the orb to start playback withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { currentPage = 1 } diff --git a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift index c3f2572..edab16a 100644 --- a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift +++ b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift @@ -31,9 +31,14 @@ struct HeartbeatMainView: View { heartbeatSoundManager: viewModel.heartbeatSoundManager, showTimeline: .constant(true), onSelectRecording: viewModel.handleRecordingSelection, - isMother: isMother + isMother: isMother, + inputWeek: authService.currentUser?.pregnancyWeeks ) .tag(0) + .transition(.asymmetric( + insertion: .scale(scale: 0.95).combined(with: .opacity), + removal: .scale(scale: 1.05).combined(with: .opacity) + )) // Right page: Orb Live Listen OrbLiveListenView( @@ -44,31 +49,13 @@ struct HeartbeatMainView: View { ) ) .tag(1) + .transition(.asymmetric( + insertion: .scale(scale: 0.95).combined(with: .opacity), + removal: .scale(scale: 1.05).combined(with: .opacity) + )) } .tabViewStyle(.page(indexDisplayMode: .never)) .ignoresSafeArea() - - // Room Code Button (Top Right) - VStack { - HStack { - Spacer() - - Button(action: { - showRoomCode.toggle() - }) { - Image(systemName: "person.2.fill") - .font(.system(size: 20)) - .foregroundColor(.white) - .padding(12) - .background(Color.white.opacity(0.2)) - .clipShape(Circle()) - } - .padding(.trailing, 20) - .padding(.top, 50) - } - - Spacer() - } } .preferredColorScheme(.dark) .onAppear { diff --git a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift index 4eb2b7c..48a5462 100644 --- a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift +++ b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift @@ -65,23 +65,26 @@ struct OrbLiveListenView: View { } 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)) + GeometryReader { geometry in + 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) + .padding(.bottom, 50) + .transition(.opacity.animation(.easeInOut)) + } + Spacer() } + .padding() Spacer() } - .padding() - Spacer() } } @@ -315,10 +318,29 @@ struct OrbLiveListenView: View { } } -#Preview { - OrbLiveListenView( - heartbeatSoundManager: HeartbeatSoundManager(), - showTimeline: .constant(false) +//#Preview("Normal Mode") { +// OrbLiveListenView( +// heartbeatSoundManager: HeartbeatSoundManager(), +// showTimeline: .constant(true) +// ) +// .environmentObject(ThemeManager()) +// .modelContainer(for: SavedHeartbeat.self, inMemory: true) +//} + +#Preview("Playback Mode") { + let manager = HeartbeatSoundManager() + + // Create a mock recording + let mockURL = URL(fileURLWithPath: "/mock/heartbeat-\(Date().timeIntervalSince1970).m4a") + let mockRecording = Recording(fileURL: mockURL, createdAt: Date()) + + // Set it as the last recording to trigger playback mode + manager.lastRecording = mockRecording + + return OrbLiveListenView( + heartbeatSoundManager: manager, + showTimeline: .constant(true) ) + .environmentObject(ThemeManager()) .modelContainer(for: SavedHeartbeat.self, inMemory: true) } diff --git a/Tiny/Features/Timeline/Views/MainTimelineListView.swift b/Tiny/Features/Timeline/Views/MainTimelineListView.swift index 3e90567..ddfe8ae 100644 --- a/Tiny/Features/Timeline/Views/MainTimelineListView.swift +++ b/Tiny/Features/Timeline/Views/MainTimelineListView.swift @@ -67,24 +67,45 @@ struct MainTimelineListView: View { if week.type == .placeholder { let lastIndex = groupedData.count - 1 - if isFirstTimeVisit && index == lastIndex && animationController.orbVisible { - 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 + if isFirstTimeVisit { + // First time visit: animate dots and orb transformation + if index == lastIndex && animationController.orbVisible { + // Show orb for the current week (last/bottom item) + 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 + } + } + } else { + // Show dots for future weeks during animation + let reversedIndex = lastIndex - index + if reversedIndex < animationController.dotsVisible.count && animationController.dotsVisible[reversedIndex] { + PlaceholderDot() } } - } else if isFirstTimeVisit { - let reversedIndex = lastIndex - index - if reversedIndex < animationController.dotsVisible.count && animationController.dotsVisible[reversedIndex] { + } else { + // Non-first visit: show orb for current week, dots for future weeks + if index == lastIndex { + // Current week gets an 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 + } + } + } else { + // Future weeks get dots PlaceholderDot() } - } else if !isFirstTimeVisit { - PlaceholderDot() } } else { let shouldShowOrb = !isFirstTimeVisit || (isFirstTimeVisit && animationController.orbVisible) @@ -108,7 +129,12 @@ struct MainTimelineListView: View { return true } else if week.type == .placeholder { let lastIndex = groupedData.count - 1 - return isFirstTimeVisit && index == lastIndex && animationController.orbVisible + // Show label if it's the current week (last/bottom item) + if isFirstTimeVisit { + return index == lastIndex && animationController.orbVisible + } else { + return index == lastIndex + } } return false }() diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index 66f7d38..e414abe 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -17,6 +17,7 @@ struct PregnancyTimelineView: View { @Namespace private var animation @State private var selectedWeek: WeekSection? @State private var groupedData: [WeekSection] = [] + @EnvironmentObject var themeManager: ThemeManager // Animation support @StateObject private var animationController = TimelineAnimationController() @@ -27,9 +28,9 @@ struct PregnancyTimelineView: View { var body: some View { NavigationStack { ZStack { - Color.black.ignoresSafeArea() - Image("backgroundPurple") + Image(themeManager.selectedBackground.imageName) .resizable() + .scaledToFill() .ignoresSafeArea() if let week = selectedWeek { @@ -65,59 +66,60 @@ struct PregnancyTimelineView: View { } private var navigationButtons: some View { - VStack { - // Top Bar - HStack { - if selectedWeek != nil { - // Back Button (Detail -> List) - Button { - withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { - selectedWeek = nil + GeometryReader { geometry in + VStack { + // Top Bar + HStack { + if selectedWeek != nil { + // Back Button (Detail -> List) + 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()) } - } 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) + } else { + Spacer() } - .glassEffect(.clear) - .matchedGeometryEffect(id: "navButton", in: animation) - } else { + Spacer() - } - - Spacer() - - // Profile Button (Top Right) - if selectedWeek == nil { - NavigationLink { - ProfileView() - } label: { - Group { - if let image = userProfile.profileImage { - Image(uiImage: image) - .resizable() - .scaledToFill() - } else { - Image(systemName: "person.crop.circle.fill") - .resizable() - .scaledToFit() - .foregroundColor(.white.opacity(0.8)) + + if selectedWeek == nil { + NavigationLink { + ProfileView() + } label: { + Group { + if let image = userProfile.profileImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.white.opacity(0.8)) + } } + .frame(width: 50, height: 50) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) + .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) } - .frame(width: 44, height: 44) - .clipShape(Circle()) - .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) - .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) + .opacity(isFirstTimeVisit ? (animationController.profileVisible ? 1.0 : 0.0) : 1.0) + .padding() } - .opacity(isFirstTimeVisit ? (animationController.profileVisible ? 1.0 : 0.0) : 1.0) } - } - .padding(.horizontal, 20) - .padding(.top, 20) + .padding() - Spacer() + Spacer() + } } .ignoresSafeArea(.all, edges: .bottom) } @@ -129,6 +131,12 @@ struct PregnancyTimelineView: View { // Get week from parameter or UserDefaults let week = inputWeek ?? UserDefaults.standard.integer(forKey: "pregnancyWeek") + print("🎬 Timeline initialization:") + print(" inputWeek parameter: \(inputWeek ?? -1)") + print(" UserDefaults week: \(UserDefaults.standard.integer(forKey: "pregnancyWeek"))") + print(" Final week: \(week)") + print(" isFirstTimeVisit: \(isFirstTimeVisit)") + if isFirstTimeVisit, week > 0 { // First time: Create initial data with placeholder dots print("🎬 First time visit - creating initial timeline for week \(week)") @@ -142,40 +150,74 @@ struct PregnancyTimelineView: View { // Start animation after a brief delay DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + print("🎬 Starting timeline animation!") self.animationController.startAnimation() } // Mark as seen UserDefaults.standard.set(true, forKey: "hasSeenTimelineAnimation") } else { - // Normal visit: Group recordings + // Normal visit: Group recordings and show everything immediately + print("📊 Normal visit - grouping recordings") + + // Set animation controller to complete state so path and orbs are visible + animationController.skipAnimation() + groupRecordings() } } + private func groupRecordings() { let raw = heartbeatSoundManager.savedRecordings print("📊 Grouping \(raw.count) recordings") + guard let currentPregnancyWeek = inputWeek else { + print("⚠️ No pregnancy week available, showing empty timeline") + groupedData = [] + return + } + let calendar = Calendar.current + let now = Date() + + // Calculate when the pregnancy started (in weeks ago) + // If current pregnancy week is 20, pregnancy started 20 weeks ago + guard let pregnancyStartDate = calendar.date(byAdding: .weekOfYear, value: -currentPregnancyWeek, to: now) else { + print("⚠️ Could not calculate pregnancy start date") + groupedData = [] + return + } + + print("📅 Pregnancy started approximately: \(pregnancyStartDate)") + print("📅 Current pregnancy week: \(currentPregnancyWeek)") + // Group recordings by pregnancy week let grouped = Dictionary(grouping: raw) { recording -> Int in - return calendar.component(.weekOfYear, from: recording.createdAt) + // Calculate how many weeks since pregnancy started + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: recording.createdAt).weekOfYear ?? 0 + let pregnancyWeek = weeksSinceStart + print(" Recording from \(recording.createdAt) -> Pregnancy week \(pregnancyWeek)") + return pregnancyWeek } var recordedWeeks = grouped.map { WeekSection(weekNumber: $0.key, recordings: $0.value.sorted(by: { $0.createdAt > $1.createdAt }), type: .recorded) }.sorted(by: { $0.weekNumber > $1.weekNumber }) // Reversed: newest (highest week) at bottom - // Add placeholder weeks if we have inputWeek and no recordings yet - if let week = inputWeek, recordedWeeks.isEmpty { - recordedWeeks = [ - WeekSection(weekNumber: week + 2, recordings: [], type: .placeholder), - WeekSection(weekNumber: week + 1, recordings: [], type: .placeholder), - WeekSection(weekNumber: week, recordings: [], type: .placeholder) - ] + // Always ensure we show the current week and next 2 weeks + let weeksToShow = [currentPregnancyWeek, currentPregnancyWeek + 1, currentPregnancyWeek + 2] + + for week in weeksToShow { + // If this week doesn't have recordings, add it as placeholder + if !recordedWeeks.contains(where: { $0.weekNumber == week }) { + recordedWeeks.append(WeekSection(weekNumber: week, recordings: [], type: .placeholder)) + } } + // Sort again after adding placeholders + recordedWeeks.sort(by: { $0.weekNumber > $1.weekNumber }) + self.groupedData = recordedWeeks print("📊 Created \(groupedData.count) week sections") @@ -191,6 +233,8 @@ struct PregnancyTimelineView: View { // Create mock HeartbeatSoundManager with sample recordings let mockManager = HeartbeatSoundManager() + let themeManager = ThemeManager() + // Create sample recordings across different weeks let calendar = Calendar.current let now = Date() @@ -271,4 +315,5 @@ struct PregnancyTimelineView: View { isMother: true, inputWeek: 20 // Test with week 20 ) + .environmentObject(themeManager) } diff --git a/Tiny/Features/Timeline/Views/TimelineDetailView.swift b/Tiny/Features/Timeline/Views/TimelineDetailView.swift index 24a42ea..4452511 100644 --- a/Tiny/Features/Timeline/Views/TimelineDetailView.swift +++ b/Tiny/Features/Timeline/Views/TimelineDetailView.swift @@ -16,45 +16,56 @@ struct TimelineDetailView: View { 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) - .padding(.top, 10) + // Background content: recordings list + recordingsScrollView(geometry: geometry) + .padding(.top, 180) // Make space for header + orb + + // Foreground: Header with back button and title + VStack(spacing: 0) { + // Top navigation bar + HStack { + // Back button placeholder (actual button is in PregnancyTimelineView) + Color.clear + .frame(width: 50, height: 50) + + Spacer() + + // Week title + Text("Week \(week.weekNumber)") + .font(.system(size: 28, weight: .bold)) + .foregroundColor(.white) + + Spacer() + + // Right side spacer for balance + Color.clear + .frame(width: 50, height: 50) + } + .padding(.horizontal, 20) + .padding(.top, geometry.safeAreaInsets.top + 20) + + // Hero Orb + ZStack { + AnimatedOrbView(size: 115) + .shadow(color: .orange.opacity(0.6), radius: 30) + } + .matchedGeometryEffect(id: "orb_\(week.weekNumber)", in: animation) + .frame(height: 115) + .padding(.top, 20) + + Spacer() + } } - .frame(maxWidth: .infinity) - } } 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) + let recHeight = max(geometry.size.height - 180, CGFloat(recordings.count) * recSpacing + 200) return ScrollView(showsIndicators: false) { - // The "Hero" Orb (Animated from previous screen) - ZStack { - AnimatedOrbView(size: 115) - .shadow(color: .orange.opacity(0.6), radius: 30) - } - .matchedGeometryEffect(id: "orb_\(week.weekNumber)", in: animation) - .frame(height: 115) - .padding(.vertical, 20) - ZStack(alignment: .top) { // Tighter Wavy Path for details @@ -130,3 +141,35 @@ struct TimelineDetailView: View { return raw } } + +#Preview { + struct PreviewWrapper: View { + @Namespace var animation + + var mockWeek: WeekSection { + let dummyURL = URL(fileURLWithPath: "Heartbeat-1715421234.m4a") + + let rec1 = Recording(fileURL: dummyURL, createdAt: Date()) + let rec2 = Recording(fileURL: dummyURL, createdAt: Date().addingTimeInterval(-3600)) // 1 hour ago + + // Assuming WeekSection is a simple struct. Adjust if needed. + return WeekSection(weekNumber: 24, recordings: [rec1, rec2, rec1]) + } + + var body: some View { + TimelineDetailView( + week: mockWeek, + animation: animation, + onSelectRecording: { recording in + print("Selected: \(recording.createdAt)") + }, + isMother: true + ) + // Dark background to visualize the white text/glowing effects + .background(Color(red: 0.1, green: 0.1, blue: 0.2)) + .environmentObject(ThemeManager()) + } + } + + return PreviewWrapper() +} diff --git a/Tiny/Resources/Localizable.xcstrings b/Tiny/Resources/Localizable.xcstrings index 3257b87..869b307 100644 --- a/Tiny/Resources/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -550,7 +550,8 @@ "isCommentAutoGenerated" : true }, "Share this code with your partner" : { - + "comment" : "A description under the \"Your Room Code\" title, instructing the user to share their room code with their partner.", + "isCommentAutoGenerated" : true }, "Sign in with Apple" : { "comment" : "A button label that says \"Sign in with Apple\".", @@ -708,7 +709,7 @@ "isCommentAutoGenerated" : true }, "You're connected to this room" : { - "comment" : "A description below a code display, indicating that the user is connected to a room.", + "comment" : "A description below a code display, explaining that they are connected to a room.", "isCommentAutoGenerated" : true }, "Your recording will be saved automatically in your library." : { From f3fa61272b752eeebab4b28c02189902e7b331ff Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Sun, 30 Nov 2025 12:38:10 +0700 Subject: [PATCH 29/44] fix: swiftlint violation --- .../LiveListen/Views/OrbLiveListenView.swift | 6 +- .../Features/SignUp/Views/WeekInputView.swift | 3 +- .../Timeline/Views/MainTimelineListView.swift | 7 +- .../Views/PregnancyTimelineView.swift | 139 ++++++++++++++---- 4 files changed, 118 insertions(+), 37 deletions(-) diff --git a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift index 48a5462..6ce0548 100644 --- a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift +++ b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift @@ -65,7 +65,7 @@ struct OrbLiveListenView: View { } private var topControlsView: some View { - GeometryReader { geometry in + GeometryReader { _ in VStack { HStack { if viewModel.isPlaybackMode { @@ -318,14 +318,14 @@ struct OrbLiveListenView: View { } } -//#Preview("Normal Mode") { +// #Preview("Normal Mode") { // OrbLiveListenView( // heartbeatSoundManager: HeartbeatSoundManager(), // showTimeline: .constant(true) // ) // .environmentObject(ThemeManager()) // .modelContainer(for: SavedHeartbeat.self, inMemory: true) -//} +// } #Preview("Playback Mode") { let manager = HeartbeatSoundManager() diff --git a/Tiny/Features/SignUp/Views/WeekInputView.swift b/Tiny/Features/SignUp/Views/WeekInputView.swift index dbbcca0..9dce18e 100644 --- a/Tiny/Features/SignUp/Views/WeekInputView.swift +++ b/Tiny/Features/SignUp/Views/WeekInputView.swift @@ -51,7 +51,7 @@ enum PregnancyStage { struct WeekInputView: View { @State private var selectedWeek: Int = 20 - var onComplete: ((Int) -> Void)? = nil // Callback when user completes + var onComplete: ((Int) -> Void)? // Callback when user completes var body: some View { ZStack { @@ -90,7 +90,6 @@ struct WeekInputView: View { } } - struct TitleDescView: View { @Binding var selectedWeek: Int diff --git a/Tiny/Features/Timeline/Views/MainTimelineListView.swift b/Tiny/Features/Timeline/Views/MainTimelineListView.swift index ddfe8ae..f7df76e 100644 --- a/Tiny/Features/Timeline/Views/MainTimelineListView.swift +++ b/Tiny/Features/Timeline/Views/MainTimelineListView.swift @@ -34,10 +34,10 @@ struct MainTimelineListView: View { ScrollView(showsIndicators: false) { ScrollViewReader { proxy in ZStack(alignment: .top) { - let orbPositions = groupedData.enumerated().map { index, _ in + let orbPositions = groupedData.indices.map { index in topPadding + (CGFloat(index) * itemSpacing) } - + SegmentedWave( totalHeight: contentHeight, period: wavePeriod, @@ -84,7 +84,8 @@ struct MainTimelineListView: View { } else { // Show dots for future weeks during animation let reversedIndex = lastIndex - index - if reversedIndex < animationController.dotsVisible.count && animationController.dotsVisible[reversedIndex] { + if reversedIndex < animationController.dotsVisible.count && + animationController.dotsVisible[reversedIndex] { PlaceholderDot() } } diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index e414abe..5332949 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -66,7 +66,7 @@ struct PregnancyTimelineView: View { } private var navigationButtons: some View { - GeometryReader { geometry in + GeometryReader { _ in VStack { // Top Bar HStack { @@ -166,8 +166,7 @@ struct PregnancyTimelineView: View { groupRecordings() } } - - + private func groupRecordings() { let raw = heartbeatSoundManager.savedRecordings print("📊 Grouping \(raw.count) recordings") @@ -208,13 +207,12 @@ struct PregnancyTimelineView: View { // Always ensure we show the current week and next 2 weeks let weeksToShow = [currentPregnancyWeek, currentPregnancyWeek + 1, currentPregnancyWeek + 2] - for week in weeksToShow { - // If this week doesn't have recordings, add it as placeholder - if !recordedWeeks.contains(where: { $0.weekNumber == week }) { - recordedWeeks.append(WeekSection(weekNumber: week, recordings: [], type: .placeholder)) - } + for week in weeksToShow where !recordedWeeks.contains(where: { $0.weekNumber == week }) { + recordedWeeks.append( + WeekSection(weekNumber: week, recordings: [], type: .placeholder) + ) } - + // Sort again after adding placeholders recordedWeeks.sort(by: { $0.weekNumber > $1.weekNumber }) @@ -242,15 +240,35 @@ struct PregnancyTimelineView: View { // 10 weeks of data going back in time // Week 1 (9 weeks ago): 2 recordings if let weekDate = calendar.date(byAdding: .day, value: -63, to: now) { - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week1-rec1.wav"), createdAt: weekDate)) - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week1-rec2.wav"), createdAt: weekDate.addingTimeInterval(3600))) + mockManager.savedRecordings.append( + Recording( + fileURL: URL(fileURLWithPath: "/mock/week1-rec1.wav"), + createdAt: weekDate + ) + ) + mockManager.savedRecordings.append( + Recording( + fileURL: URL(fileURLWithPath: "/mock/week1-rec2.wav"), + createdAt: weekDate.addingTimeInterval(3600) + ) + ) } // Week 2 (8 weeks ago): 3 recordings if let weekDate = calendar.date(byAdding: .day, value: -56, to: now) { mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week2-rec1.wav"), createdAt: weekDate)) - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week2-rec2.wav"), createdAt: weekDate.addingTimeInterval(7200))) - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week2-rec3.wav"), createdAt: weekDate.addingTimeInterval(14400))) + mockManager.savedRecordings.append( + Recording( + fileURL: URL(fileURLWithPath: "/mock/week2-rec2.wav"), + createdAt: weekDate.addingTimeInterval(7200) + ) + ) + mockManager.savedRecordings.append( + Recording( + fileURL: URL(fileURLWithPath: "/mock/week2-rec3.wav"), + createdAt: weekDate.addingTimeInterval(14400) + ) + ) } // Week 3 (7 weeks ago): 1 recording @@ -261,35 +279,98 @@ struct PregnancyTimelineView: View { // Week 4 (6 weeks ago): 4 recordings if let weekDate = calendar.date(byAdding: .day, value: -42, to: now) { mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week4-rec1.wav"), createdAt: weekDate)) - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week4-rec2.wav"), createdAt: weekDate.addingTimeInterval(3600))) - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week4-rec3.wav"), createdAt: weekDate.addingTimeInterval(7200))) - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week4-rec4.wav"), createdAt: weekDate.addingTimeInterval(10800))) + mockManager.savedRecordings.append( + Recording( + fileURL: URL(fileURLWithPath: "/mock/week4-rec2.wav"), + createdAt: weekDate.addingTimeInterval(3600) + ) + ) + mockManager.savedRecordings.append( + Recording( + fileURL: URL(fileURLWithPath: "/mock/week4-rec3.wav"), + createdAt: weekDate.addingTimeInterval(7200))) + mockManager.savedRecordings.append( + Recording( + fileURL: URL(fileURLWithPath: "/mock/week4-rec4.wav"), + createdAt: weekDate.addingTimeInterval(10800) + ) + ) } // Week 5 (5 weeks ago): 2 recordings if let weekDate = calendar.date(byAdding: .day, value: -35, to: now) { - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week5-rec1.wav"), createdAt: weekDate)) - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week5-rec2.wav"), createdAt: weekDate.addingTimeInterval(5400))) + mockManager.savedRecordings.append( + Recording( + fileURL: URL(fileURLWithPath: "/mock/week5-rec1.wav"), + createdAt: weekDate + ) + ) + mockManager.savedRecordings.append( + Recording( + fileURL: URL(fileURLWithPath: "/mock/week5-rec2.wav"), + createdAt: weekDate.addingTimeInterval(5400) + ) + ) } - + // Week 6 (4 weeks ago): 3 recordings if let weekDate = calendar.date(byAdding: .day, value: -28, to: now) { - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week6-rec1.wav"), createdAt: weekDate)) - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week6-rec2.wav"), createdAt: weekDate.addingTimeInterval(3600))) - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week6-rec3.wav"), createdAt: weekDate.addingTimeInterval(7200))) + mockManager.savedRecordings.append( + Recording( + fileURL: URL(fileURLWithPath: "/mock/week6-rec1.wav"), + createdAt: weekDate + ) + ) + mockManager.savedRecordings.append( + Recording( + fileURL: URL(fileURLWithPath: "/mock/week6-rec2.wav"), + createdAt: weekDate.addingTimeInterval(3600) + ) + ) + mockManager.savedRecordings.append( + Recording( + fileURL: URL(fileURLWithPath: "/mock/week6-rec3.wav"), + createdAt: weekDate.addingTimeInterval(7200) + ) + ) } - + // Week 7 (3 weeks ago): 2 recordings if let weekDate = calendar.date(byAdding: .day, value: -21, to: now) { - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week7-rec1.wav"), createdAt: weekDate)) - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week7-rec2.wav"), createdAt: weekDate.addingTimeInterval(4800))) + mockManager.savedRecordings.append( + Recording( + fileURL: URL(fileURLWithPath: "/mock/week7-rec1.wav"), + createdAt: weekDate + ) + ) + mockManager.savedRecordings.append( + Recording( + fileURL: URL(fileURLWithPath: "/mock/week7-rec2.wav"), + createdAt: weekDate.addingTimeInterval(4800) + ) + ) } - + // Week 8 (2 weeks ago): 3 recordings if let weekDate = calendar.date(byAdding: .day, value: -14, to: now) { - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week8-rec1.wav"), createdAt: weekDate)) - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week8-rec2.wav"), createdAt: weekDate.addingTimeInterval(3600))) - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week8-rec3.wav"), createdAt: weekDate.addingTimeInterval(7200))) + mockManager.savedRecordings.append( + Recording( + fileURL: URL(fileURLWithPath: "/mock/week8-rec1.wav"), + createdAt: weekDate + ) + ) + mockManager.savedRecordings.append( + Recording( + fileURL: URL(fileURLWithPath: "/mock/week8-rec2.wav"), + createdAt: weekDate.addingTimeInterval(3600) + ) + ) + mockManager.savedRecordings.append( + Recording( + fileURL: URL(fileURLWithPath: "/mock/week8-rec3.wav"), + createdAt: weekDate.addingTimeInterval(7200) + ) + ) } // Week 9 (1 week ago): 1 recording From d9b361235fd8efd4fc238514ad3c919065413bef Mon Sep 17 00:00:00 2001 From: Revanza Narendra Date: Sun, 30 Nov 2025 19:58:01 +0700 Subject: [PATCH 30/44] feat: add edit name and delete functionality for recording --- Tiny/Core/Models/SavedHeartbeatModel.swift | 7 + .../Audio/HeartbeatSoundManager.swift | 88 +++++- .../Storage/FirebaseStorageService.swift | 2 +- .../Storage/HeartbeatSyncManager.swift | 40 ++- .../ViewModels/HeartbeatMainViewModel.swift | 12 +- .../ViewModels/OrbLiveListenViewModel.swift | 128 ++++++-- .../SavedRecordingPlaybackViewModel.swift | 235 ++++++++++++++ .../LiveListen/Views/HeartbeatMainView.swift | 47 ++- .../LiveListen/Views/OrbLiveListenView.swift | 50 ++- .../Views/SavedRecordingPlaybackView.swift | 299 ++++++++++++++++++ .../Preview/OnboardingToTimelinePreview.swift | 106 +++---- Tiny/Features/Profile/Views/ProfileView.swift | 17 +- .../Views/PregnancyTimelineView.swift | 160 ++++++---- .../Timeline/Views/TimelineDetailView.swift | 132 ++++---- Tiny/Resources/Localizable.xcstrings | 27 +- 15 files changed, 1106 insertions(+), 244 deletions(-) create mode 100644 Tiny/Features/LiveListen/ViewModels/SavedRecordingPlaybackViewModel.swift create mode 100644 Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift diff --git a/Tiny/Core/Models/SavedHeartbeatModel.swift b/Tiny/Core/Models/SavedHeartbeatModel.swift index a77cae0..a7d1135 100644 --- a/Tiny/Core/Models/SavedHeartbeatModel.swift +++ b/Tiny/Core/Models/SavedHeartbeatModel.swift @@ -14,6 +14,7 @@ class SavedHeartbeat { @Attribute(.unique) var id: UUID var filePath: String // Local file path var timestamp: Date + var displayName: String? // Custom name for the recording // Firebase fields var firebaseId: String? // Document ID in Firestore (for metadata) @@ -26,6 +27,7 @@ class SavedHeartbeat { init(filePath: String, timestamp: Date = Date(), + displayName: String? = nil, motherUserId: String? = nil, roomCode: String? = nil, isShared: Bool = true, // Changed default from false to true @@ -36,6 +38,7 @@ class SavedHeartbeat { self.id = UUID() self.filePath = filePath self.timestamp = timestamp + self.displayName = displayName self.motherUserId = motherUserId self.roomCode = roomCode self.isShared = isShared @@ -55,6 +58,9 @@ extension SavedHeartbeat { "isSyncedToCloud": isSyncedToCloud ] + if let displayName = displayName { + dict["displayName"] = displayName + } if let motherUserId = motherUserId { dict["motherUserId"] = motherUserId } @@ -79,6 +85,7 @@ extension SavedHeartbeat { return SavedHeartbeat( filePath: "", // Will be set after downloading timestamp: timestamp, + displayName: data["displayName"] as? String, motherUserId: data["motherUserId"] as? String, roomCode: data["roomCode"] as? String, isShared: data["isShared"] as? Bool ?? false, diff --git a/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift b/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift index b1d0929..7231d01 100644 --- a/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift +++ b/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift @@ -22,6 +22,7 @@ struct Recording: Identifiable, Equatable { let id = UUID() let fileURL: URL let createdAt: Date + var displayName: String? // Custom name from SwiftData var isPlaying: Bool = false } @@ -109,7 +110,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { guard let modelContext = modelContext else { return } do { let results = try modelContext.fetch(FetchDescriptor()) - let documentsURL = getDocumentsDirectory() + let _ = getDocumentsDirectory() DispatchQueue.main.async { // Map the REAL timestamp from SwiftData @@ -124,10 +125,14 @@ class HeartbeatSoundManager: NSObject, ObservableObject { } print("✅ Found recording: \(fileURL.lastPathComponent)") + if let displayName = savedItem.displayName { + print(" Custom name: \(displayName)") + } return Recording( fileURL: fileURL, - createdAt: savedItem.timestamp + createdAt: savedItem.timestamp, + displayName: savedItem.displayName ) } print("✅ Loaded \(self.savedRecordings.count) recordings from SwiftData") @@ -141,7 +146,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { let isMother = currentUserRole == .mother // Fetch heartbeats from cloud - let syncedHeartbeats = try await syncManager.syncHeartbeatsFromCloud( + _ = try await syncManager.syncHeartbeatsFromCloud( roomCode: roomCode, modelContext: modelContext, isMother: isMother @@ -163,7 +168,8 @@ class HeartbeatSoundManager: NSObject, ObservableObject { return Recording( fileURL: fileURL, - createdAt: savedItem.timestamp + createdAt: savedItem.timestamp, + displayName: savedItem.displayName ) } print("✅ Reloaded \(self.savedRecordings.count) recordings after sync (isMother: \(isMother))") @@ -674,11 +680,22 @@ class HeartbeatSoundManager: NSObject, ObservableObject { guard let modelContext = modelContext else { return } - // Create entry with isShared = true by default + // Calculate current pregnancy week + let pregnancyWeek: Int? = { + guard let pregnancyStartDate = UserDefaults.standard.object(forKey: "pregnancyStartDate") as? Date else { + return nil + } + let calendar = Calendar.current + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: recording.createdAt).weekOfYear ?? 0 + return weeksSinceStart + }() + + // Create entry with isShared = true by default and pregnancy week let entry = SavedHeartbeat( filePath: recording.fileURL.path, timestamp: recording.createdAt, - isShared: true // Auto-share all heartbeats + isShared: true, // Auto-share all heartbeats + pregnancyWeeks: pregnancyWeek ) modelContext.insert(entry) @@ -719,5 +736,64 @@ class HeartbeatSoundManager: NSObject, ObservableObject { print("❌ SwiftData save failed: \(error)") } } + + func deleteRecording(_ recording: Recording) { + guard let modelContext = modelContext else { return } + + print("🗑️ Deleting recording: \(recording.fileURL.lastPathComponent)") + + // Remove from savedRecordings array + if let index = savedRecordings.firstIndex(where: { $0.id == recording.id }) { + savedRecordings.remove(at: index) + print("✅ Removed from savedRecordings array. New count: \(savedRecordings.count)") + // Force UI update + objectWillChange.send() + } + + // Remove from SwiftData + do { + let filePath = recording.fileURL.path + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.filePath == filePath } + ) + let results = try modelContext.fetch(descriptor) + + for entry in results { + modelContext.delete(entry) + } + + try modelContext.save() + print("✅ Deleted from SwiftData") + + // Delete the audio file from disk + let fileManager = FileManager.default + if fileManager.fileExists(atPath: filePath) { + try fileManager.removeItem(atPath: filePath) + print("✅ Deleted audio file from disk") + } + + // Clear lastRecording if it's the one being deleted + if lastRecording?.id == recording.id { + lastRecording = nil + } + + // Delete from Firebase if it was synced + if let firebaseId = results.first?.firebaseId, + let syncManager = syncManager, + let entry = results.first { + Task { @MainActor in + do { + try await syncManager.deleteHeartbeat(entry) + print("✅ Deleted from Firebase") + } catch { + print("❌ Firebase deletion failed: \(error.localizedDescription)") + } + } + } + + } catch { + print("❌ Delete failed: \(error)") + } + } } // swiftlint:enable type_body_length diff --git a/Tiny/Core/Services/Storage/FirebaseStorageService.swift b/Tiny/Core/Services/Storage/FirebaseStorageService.swift index 7e8237f..2e64515 100644 --- a/Tiny/Core/Services/Storage/FirebaseStorageService.swift +++ b/Tiny/Core/Services/Storage/FirebaseStorageService.swift @@ -71,7 +71,7 @@ class FirebaseStorageService: ObservableObject { heartbeatId: String, timestamp: Date ) async throws -> URL { - guard let url = URL(string: downloadURL) else { + guard let _ = URL(string: downloadURL) else { throw StorageError.invalidURL } diff --git a/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift b/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift index 40905d1..abf5587 100644 --- a/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift +++ b/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift @@ -56,7 +56,7 @@ class HeartbeatSyncManager: ObservableObject { // Keep the isShared value from the heartbeat (should be true by default) // 3. Save metadata to Firestore - let metadata: [String: Any] = [ + var metadata: [String: Any] = [ "heartbeatId": heartbeatId, "motherUserId": motherUserId, "roomCode": roomCode, @@ -67,6 +67,10 @@ class HeartbeatSyncManager: ObservableObject { "createdAt": Timestamp(date: Date()) ] + if let displayName = heartbeat.displayName { + metadata["displayName"] = displayName + } + print("💾 Saving metadata to Firestore...") print(" Metadata: \(metadata)") @@ -95,6 +99,28 @@ class HeartbeatSyncManager: ObservableObject { heartbeat.isShared = true } + // MARK: - Update Heartbeat Name + + /// Updates the display name of a heartbeat in Firestore + func updateHeartbeatName(_ heartbeat: SavedHeartbeat, newName: String) async throws { + guard let firebaseId = heartbeat.firebaseId else { + throw SyncError.notSynced + } + + print("📝 Updating heartbeat name in Firestore...") + print(" ID: \(firebaseId)") + print(" New Name: \(newName)") + + // Update Firestore + try await dbf.collection("heartbeats").document(firebaseId).updateData([ + "displayName": newName + ]) + + // Update local model + heartbeat.displayName = newName + print("✅ Heartbeat name updated in Firestore") + } + // MARK: - Unshare Heartbeat /// Marks a heartbeat as not shared @@ -154,7 +180,8 @@ class HeartbeatSyncManager: ObservableObject { firebaseStorageURL: data["firebaseStorageURL"] as? String ?? "", timestamp: (data["timestamp"] as? Timestamp)?.dateValue() ?? Date(), isShared: isShared, - pregnancyWeeks: data["pregnancyWeeks"] as? Int + pregnancyWeeks: data["pregnancyWeeks"] as? Int, + displayName: data["displayName"] as? String ) print(" ✅ Including heartbeat: \(metadata.id)") @@ -190,7 +217,8 @@ class HeartbeatSyncManager: ObservableObject { firebaseStorageURL: data["firebaseStorageURL"] as? String ?? "", timestamp: (data["timestamp"] as? Timestamp)?.dateValue() ?? Date(), isShared: data["isShared"] as? Bool ?? false, - pregnancyWeeks: data["pregnancyWeeks"] as? Int + pregnancyWeeks: data["pregnancyWeeks"] as? Int, + displayName: data["displayName"] as? String ) } @@ -293,6 +321,11 @@ class HeartbeatSyncManager: ObservableObject { firebaseId: metadata.id ) + // Set display name if available + if let displayName = metadata.displayName { + heartbeat.displayName = displayName + } + modelContext.insert(heartbeat) syncedHeartbeats.append(heartbeat) print(" ✅ Saved heartbeat to SwiftData: \(metadata.id)") @@ -320,6 +353,7 @@ struct HeartbeatMetadata: Identifiable { let timestamp: Date let isShared: Bool let pregnancyWeeks: Int? + let displayName: String? } enum SyncError: LocalizedError { diff --git a/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift b/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift index 058b4c9..eba9ac4 100644 --- a/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift +++ b/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift @@ -10,7 +10,9 @@ import SwiftData internal import Combine class HeartbeatMainViewModel: ObservableObject { - @Published var currentPage = 0 // 0 = Timeline (left), 1 = Orb (right) + @Published var currentPage: Int = 0 + @Published var allowTabViewSwipe: Bool = true + @Published var selectedRecording: Recording? = nil let heartbeatSoundManager = HeartbeatSoundManager() func setupManager( @@ -31,13 +33,9 @@ class HeartbeatMainViewModel: ObservableObject { func handleRecordingSelection(_ recording: Recording) { print("🎵 Recording selected: \(recording.fileURL.lastPathComponent)") - // Set as last recording (but don't auto-play) - heartbeatSoundManager.lastRecording = recording - - // Switch to orb view (page 1) for playback - // User will need to tap the orb to start playback + // Show SavedRecordingPlaybackView withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { - currentPage = 1 + selectedRecording = recording } } } diff --git a/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift b/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift index 8866098..e45ca0d 100644 --- a/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift +++ b/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift @@ -19,7 +19,9 @@ class OrbLiveListenViewModel: ObservableObject { @Published var longPressScale: CGFloat = 1.0 @Published var dragOffset: CGFloat = 0 @Published var isDraggingToSave = false + @Published var isDraggingToDelete = false @Published var saveButtonScale: CGFloat = 1.0 + @Published var deleteButtonScale: CGFloat = 1.0 @Published var orbDragScale: CGFloat = 1.0 @Published var canSaveCurrentRecording = false @Published var currentTime: TimeInterval = 0 @@ -60,6 +62,7 @@ class OrbLiveListenViewModel: ObservableObject { } func setupPlayback(for recording: Recording) { + // This is only called for fresh recordings after countdown isPlaybackMode = true animateOrb = true audioPostProcessingManager.stop() @@ -68,30 +71,55 @@ class OrbLiveListenViewModel: ObservableObject { } func handleDragChange(value: SequenceGesture.Value, geometry: GeometryProxy) { - guard canSaveCurrentRecording else { return } + guard canSaveCurrentRecording || isPlaybackMode 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) + let translation = drag?.translation.height ?? 0 + + if translation > 0 { + // Dragging DOWN - Save + isDraggingToSave = true + isDraggingToDelete = false + 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) + deleteButtonScale = 1.0 + } + } else if translation < 0 { + // Dragging UP - Delete + isDraggingToDelete = true + isDraggingToSave = false + dragOffset = translation + let maxDragDistance = geometry.size.height / 2 + let dragProgress = min(abs(translation) / maxDragDistance, 1.0) + withAnimation(.interactiveSpring(response: 0.3, dampingFraction: 0.7)) { + orbDragScale = 1.0 - (dragProgress * 0.4) + deleteButtonScale = 1.0 + (dragProgress * 0.4) + saveButtonScale = 1.0 + } } default: break } } - func handleDragEnd(value: SequenceGesture.Value, geometry: GeometryProxy, onSave: @escaping () -> Void) { - guard canSaveCurrentRecording else { return } + func handleDragEnd(value: SequenceGesture.Value, geometry: GeometryProxy, onSave: @escaping () -> Void, onDelete: @escaping () -> Void) { + guard canSaveCurrentRecording || isPlaybackMode else { return } switch value { case .second(true, let drag): let translation = drag?.translation.height ?? 0 - if translation > geometry.size.height / 4 { + let threshold = geometry.size.height / 4 + + if translation > threshold { + // Dragged down enough - Save handleSaveRecording(onSave: onSave) + } else if translation < -threshold { + // Dragged up enough - Delete/Dismiss + handleDeleteRecording(onDelete: onDelete) } else { + // Not dragged enough - Reset resetDragState() } default: resetDragState() @@ -103,7 +131,9 @@ class OrbLiveListenViewModel: ObservableObject { dragOffset = 0 orbDragScale = 1.0 saveButtonScale = 1.0 + deleteButtonScale = 1.0 isDraggingToSave = false + isDraggingToDelete = false } } @@ -113,10 +143,41 @@ class OrbLiveListenViewModel: ObservableObject { saveButtonScale = 1.6 orbDragScale = 0.05 } - onSave() - canSaveCurrentRecording = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.resetDragState() + + // Wait for animation to complete before saving and navigating + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + onSave() + self.canSaveCurrentRecording = false + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.resetDragState() + } + } + } + + func handleDeleteRecording(onDelete: @escaping () -> Void) { + withAnimation(.interpolatingSpring(mass: 1, stiffness: 200, damping: 15)) { + deleteButtonScale = 1.6 + orbDragScale = 0.05 + } + + // Wait for animation to complete before deleting and navigating + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + // Call the delete callback + onDelete() + + // Stop playback and reset state + self.audioPostProcessingManager.stop() + self.stopPlaybackTimer() + self.canSaveCurrentRecording = false + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + self.isPlaybackMode = false + self.animateOrb = false + self.resetDragState() + } + } } } @@ -133,6 +194,7 @@ class OrbLiveListenViewModel: ObservableObject { func handleDoubleTap(onStart: @escaping () -> Void) { guard !isLongPressing, !isListening, !isPlaybackMode else { return } + withAnimation(.interpolatingSpring(mass: 2, stiffness: 100, damping: 20)) { animateOrb = true isListening = true @@ -222,31 +284,29 @@ class OrbLiveListenViewModel: ObservableObject { } func handleLongPressComplete(onStop: @escaping () -> Void) { - cancelLongPressCountdown() + // Stop the timer but don't trigger separate animation + longPressTimer?.invalidate() + longPressTimer = nil + longPressCountdown = 3 - withAnimation(.interpolatingSpring(mass: 2, stiffness: 100, damping: 20)) { + // 1. First animation: Move orb to center and reset scale + withAnimation(.spring(response: 0.8, dampingFraction: 0.8)) { isListening = false + isLongPressing = false + longPressScale = 1.0 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 + // 2. Second phase: Switch to playback mode after movement starts + // We delay this slightly to prevent the GestureModifier from reconstructing the view + // and cancelling the spring animation mid-flight. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + withAnimation(.easeIn(duration: 0.3)) { + self.isPlaybackMode = true + self.canSaveCurrentRecording = true + } } - - audioPostProcessingManager.stop() - audioPostProcessingManager.loadAndPlay(fileURL: recording.fileURL) - startPlaybackTimer() - - onSelect(recording) } } diff --git a/Tiny/Features/LiveListen/ViewModels/SavedRecordingPlaybackViewModel.swift b/Tiny/Features/LiveListen/ViewModels/SavedRecordingPlaybackViewModel.swift new file mode 100644 index 0000000..f6bf571 --- /dev/null +++ b/Tiny/Features/LiveListen/ViewModels/SavedRecordingPlaybackViewModel.swift @@ -0,0 +1,235 @@ +// +// SavedRecordingPlaybackViewModel.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 30/11/25. +// + +import Foundation +import SwiftUI +internal import Combine +import AudioKit +import SwiftData + +@MainActor +class SavedRecordingPlaybackViewModel: ObservableObject { + @Published var isPlaying = false + @Published var currentTime: TimeInterval = 0 + @Published var recordingName = "Heartbeat Recording" + @Published var editedName = "Heartbeat Recording" + @Published var isEditingName = false + @Published var showSuccessAlert = false + @Published var formattedDate = "" + + // Drag state + @Published var dragOffset: CGFloat = 0 + @Published var orbDragScale: CGFloat = 1.0 + @Published var isDraggingToDelete = false + @Published var deleteButtonScale: CGFloat = 1.0 + + private var playbackTimer: Timer? + private var audioManager: HeartbeatSoundManager? + private var currentRecording: Recording? + private var modelContext: ModelContext? + private var onRecordingUpdated: (() -> Void)? + + func setupPlayback(for recording: Recording, manager: HeartbeatSoundManager, modelContext: ModelContext, onRecordingUpdated: @escaping () -> Void) { + self.audioManager = manager + self.currentRecording = recording + self.modelContext = modelContext + self.onRecordingUpdated = onRecordingUpdated + + // Try to find the SavedHeartbeat entry to get custom name + let filePath = recording.fileURL.path + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.filePath == filePath } + ) + + if let savedHeartbeat = try? modelContext.fetch(descriptor).first { + // Use custom name if available, otherwise use filename + if let customName = savedHeartbeat.displayName, !customName.isEmpty { + self.recordingName = customName + } else { + let fileName = recording.fileURL.deletingPathExtension().lastPathComponent + self.recordingName = fileName.replacingOccurrences(of: "recording-", with: "Recording ") + } + } else { + // Fallback to filename + let fileName = recording.fileURL.deletingPathExtension().lastPathComponent + self.recordingName = fileName.replacingOccurrences(of: "recording-", with: "Recording ") + } + + self.editedName = recordingName + + // Format date + let formatter = DateFormatter() + formatter.dateFormat = "d MMMM yyyy" + self.formattedDate = formatter.string(from: recording.createdAt) + + // Start playback + manager.togglePlayback(recording: recording) + startPlaybackTimer(manager: manager) + } + + func togglePlayback(manager: HeartbeatSoundManager, recording: Recording) { + manager.togglePlayback(recording: recording) + + if manager.isPlayingPlayback { + startPlaybackTimer(manager: manager) + } else { + stopPlaybackTimer() + } + } + + func cleanup() { + stopPlaybackTimer() + audioManager?.player?.stop() + } + + func handleDragChange(value: DragGesture.Value, geometry: GeometryProxy) { + let translation = value.translation.height + dragOffset = translation + + // Only handle upward drag (delete) + if translation < 0 { + isDraggingToDelete = true + let progress = min(abs(translation) / (geometry.size.height / 4), 1.0) + orbDragScale = 1.0 - (progress * 0.3) + deleteButtonScale = 1.0 + (progress * 0.6) + } else { + resetDragState() + } + } + + func handleDragEnd(value: DragGesture.Value, geometry: GeometryProxy, onDelete: @escaping () -> Void) { + let translation = value.translation.height + let threshold = geometry.size.height / 4 + + if translation < -threshold { + // Dragged up enough - Delete + handleDelete(onDelete: onDelete) + } else { + // Not dragged enough - Reset + resetDragState() + } + } + + private func handleDelete(onDelete: @escaping () -> Void) { + withAnimation(.interpolatingSpring(mass: 1, stiffness: 200, damping: 15)) { + deleteButtonScale = 1.6 + orbDragScale = 0.05 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + onDelete() + } + } + + private func resetDragState() { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + dragOffset = 0 + orbDragScale = 1.0 + isDraggingToDelete = false + deleteButtonScale = 1.0 + } + } + + private func startPlaybackTimer(manager: HeartbeatSoundManager) { + stopPlaybackTimer() + isPlaying = true + + playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self, weak manager] _ in + guard let self = self, let manager = manager else { return } + + self.isPlaying = manager.isPlayingPlayback + + if !manager.isPlayingPlayback { + self.stopPlaybackTimer() + } + } + } + + func startEditing() { + isEditingName = true + } + + func saveName() { + recordingName = editedName + isEditingName = false + + // Show success alert + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = true + } + + // Hide alert after 2 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + self.showSuccessAlert = false + } + } + + // Save to SwiftData + guard let recording = currentRecording, + let modelContext = modelContext else { + print("❌ Cannot save: missing recording or context") + return + } + + let filePath = recording.fileURL.path + print("🔍 Looking for SavedHeartbeat with path: \(filePath)") + + let descriptor = FetchDescriptor( + predicate: #Predicate { heartbeat in + heartbeat.filePath == filePath + } + ) + + do { + let results = try modelContext.fetch(descriptor) + print("📊 Found \(results.count) matching recordings") + + if let savedHeartbeat = results.first { + print("✏️ Updating recording: \(savedHeartbeat.filePath)") + print(" Old name: \(savedHeartbeat.displayName ?? "nil")") + print(" New name: \(editedName)") + + savedHeartbeat.displayName = editedName + try modelContext.save() + + print("✅ Saved recording name: \(editedName)") + + // Trigger reload to update UI + onRecordingUpdated?() + + // Sync to Firebase + if let syncManager = self.audioManager?.syncManager { + Task { + do { + try await syncManager.updateHeartbeatName(savedHeartbeat, newName: editedName) + } catch { + print("❌ Failed to sync name update: \(error)") + } + } + } + } else { + print("⚠️ Could not find SavedHeartbeat entry for path: \(filePath)") + // List all recordings for debugging + let allDescriptor = FetchDescriptor() + let allRecordings = try modelContext.fetch(allDescriptor) + print("📝 All recordings in database:") + for (index, rec) in allRecordings.enumerated() { + print(" [\(index)] \(rec.filePath)") + } + } + } catch { + print("❌ Error saving recording name: \(error)") + } + } + + private func stopPlaybackTimer() { + playbackTimer?.invalidate() + playbackTimer = nil + isPlaying = false + } +} diff --git a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift index edab16a..1bc5f4a 100644 --- a/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift +++ b/Tiny/Features/LiveListen/Views/HeartbeatMainView.swift @@ -17,20 +17,21 @@ struct HeartbeatMainView: View { @State private var showRoomCode = false @State private var isInitialized = false - // Check if user is a mother private var isMother: Bool { authService.currentUser?.role == .mother } var body: some View { ZStack { - // TabView with swipe navigation TabView(selection: $viewModel.currentPage) { - // Left page: Timeline (default) PregnancyTimelineView( heartbeatSoundManager: viewModel.heartbeatSoundManager, showTimeline: .constant(true), onSelectRecording: viewModel.handleRecordingSelection, + onDisableSwipe: { disable in + print("🚫 Swipe disable requested: \(disable), allowTabViewSwipe will be: \(!disable)") + viewModel.allowTabViewSwipe = !disable + }, isMother: isMother, inputWeek: authService.currentUser?.pregnancyWeeks ) @@ -39,8 +40,6 @@ struct HeartbeatMainView: View { insertion: .scale(scale: 0.95).combined(with: .opacity), removal: .scale(scale: 1.05).combined(with: .opacity) )) - - // Right page: Orb Live Listen OrbLiveListenView( heartbeatSoundManager: viewModel.heartbeatSoundManager, showTimeline: Binding( @@ -55,7 +54,44 @@ struct HeartbeatMainView: View { )) } .tabViewStyle(.page(indexDisplayMode: .never)) + .highPriorityGesture( + // Block TabView swipe when navigation is active + !viewModel.allowTabViewSwipe ? DragGesture() : nil + ) .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) + } + + // SavedRecordingPlaybackView overlay + if let recording = viewModel.selectedRecording { + SavedRecordingPlaybackView( + recording: recording, + heartbeatSoundManager: viewModel.heartbeatSoundManager, + showTimeline: Binding( + get: { false }, + set: { if $0 { viewModel.selectedRecording = nil } } + ) + ) + .transition(.move(edge: .trailing).combined(with: .opacity)) + .zIndex(10) + } } .preferredColorScheme(.dark) .onAppear { @@ -121,4 +157,5 @@ struct HeartbeatMainView: View { .modelContainer(for: SavedHeartbeat.self, inMemory: true) .environmentObject(AuthenticationService()) .environmentObject(HeartbeatSyncManager()) + .environmentObject(ThemeManager()) } diff --git a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift index 48a5462..a37bb69 100644 --- a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift +++ b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift @@ -18,16 +18,33 @@ struct OrbLiveListenView: View { GeometryReader { geometry in ZStack { backgroundView + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToSave) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + topControlsView + .opacity(viewModel.isDraggingToSave || viewModel.isDraggingToDelete ? 0.0 : 1.0) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToSave) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + statusTextView + .opacity(viewModel.isDraggingToSave || viewModel.isDraggingToDelete ? 0.0 : 1.0) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToSave) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + orbView(geometry: geometry) // Save/Library Button (Only visible when dragging) saveButton(geometry: geometry) + // Delete Button (Only visible when dragging up) + deleteButton(geometry: geometry) + // Floating Button to Open Timeline manually - if !viewModel.isListening && !viewModel.isDraggingToSave { + if !viewModel.isListening && !viewModel.isDraggingToSave && !viewModel.isDraggingToDelete { libraryOpenButton(geometry: geometry) + .opacity(viewModel.isDraggingToSave || viewModel.isDraggingToDelete ? 0.0 : 1.0) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToSave) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) } coachMarkView @@ -132,15 +149,29 @@ struct OrbLiveListenView: View { .font(.system(size: 28)) .foregroundColor(.white) .frame(width: 77, height: 77) - .background(Circle().fill(Color.white.opacity(0.1))) .clipShape(Circle()) + .glassEffect(.clear) .scaleEffect(viewModel.saveButtonScale) - .position(x: geometry.size.width / 2, y: geometry.size.height - 100) + .position(x: geometry.size.width / 2, y: geometry.size.height - 46) .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 func deleteButton(geometry: GeometryProxy) -> some View { + Image(systemName: "trash.fill") + .font(.system(size: 28)) + .foregroundColor(.red) + .frame(width: 77, height: 77) + .clipShape(Circle()) + .glassEffect(.clear) + .scaleEffect(viewModel.deleteButtonScale) + .position(x: geometry.size.width / 2, y: 50) + .opacity(viewModel.isDraggingToDelete ? min(abs(viewModel.dragOffset) / 150.0, 1.0) : 0.0) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + .animation(.easeOut(duration: 0.2), value: viewModel.dragOffset) + } + private var statusTextView: some View { VStack { Group { @@ -192,6 +223,7 @@ struct OrbLiveListenView: View { .animation(.easeInOut(duration: 0.2), value: viewModel.longPressScale) .animation(.easeInOut(duration: 0.2), value: viewModel.orbDragScale) .offset(y: viewModel.orbOffset(geometry: geometry) + viewModel.dragOffset) + .animation(.spring(response: 1.0, dampingFraction: 0.8), value: viewModel.isListening) .onTapGesture(count: 2) { viewModel.handleDoubleTap { heartbeatSoundManager.start() @@ -208,12 +240,16 @@ struct OrbLiveListenView: View { viewModel.handleDragChange(value: value, geometry: geometry) }, handleDragEnd: { value in - viewModel.handleDragEnd(value: value, geometry: geometry) { + viewModel.handleDragEnd(value: value, geometry: geometry, onSave: { heartbeatSoundManager.saveRecording() - withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { - showTimeline = true + showTimeline = true + }, onDelete: { + // Delete the recording if it's saved, or just dismiss if it's unsaved + if let lastRecording = heartbeatSoundManager.lastRecording { + heartbeatSoundManager.deleteRecording(lastRecording) } - } + showTimeline = true + }) }, handleLongPressChange: viewModel.handleLongPressChange, handleLongPressComplete: { diff --git a/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift b/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift new file mode 100644 index 0000000..126b415 --- /dev/null +++ b/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift @@ -0,0 +1,299 @@ +// +// SavedRecordingPlaybackView.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 30/11/25. +// + +import SwiftUI +import SwiftData + +struct SavedRecordingPlaybackView: View { + let recording: Recording + @ObservedObject var heartbeatSoundManager: HeartbeatSoundManager + @Binding var showTimeline: Bool + + @StateObject private var viewModel = SavedRecordingPlaybackViewModel() + @StateObject var themeManager = ThemeManager() + @FocusState private var isNameFieldFocused: Bool + @Environment(\.modelContext) private var modelContext + + var body: some View { + GeometryReader { geometry in + ZStack { + backgroundView + + topControlsView(geometry: geometry) + + orbView(geometry: geometry) + + nameAndDateView + .zIndex(10) + + statusTextView + + deleteButton(geometry: geometry) + + if viewModel.showSuccessAlert { + successAlertView + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(100) + } + } + .ignoresSafeArea() + + } + .onAppear { + viewModel.setupPlayback( + for: recording, + manager: heartbeatSoundManager, + modelContext: modelContext, + onRecordingUpdated: { + heartbeatSoundManager.loadFromSwiftData() + } + ) + } + .onDisappear { + viewModel.cleanup() + } + } + + private var backgroundView: some View { + ZStack { + Image(themeManager.selectedBackground.imageName) + .resizable() + .scaledToFill() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + + if viewModel.isPlaying { + BokehEffectView(amplitude: .constant(0.8)) + .opacity(0.5) + } + } + } + + private func topControlsView(geometry: GeometryProxy) -> some View { + VStack { + HStack { + Button { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + showTimeline = true + } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + .frame(width: 50, height: 50) + } + .glassEffect(.clear) + + Spacer() + + if viewModel.isEditingName { + Button { + viewModel.saveName() + isNameFieldFocused = false + } label: { + Text("Save") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background( + Capsule() + .fill(Color(hex: "6B5B95")) + ) + } + .transition(.opacity.animation(.easeInOut)) + } else { + // Normal buttons + HStack { + Button { + } label: { + Image(systemName: "iphone.gen3.radiowaves.left.and.right") + .font(.body) + .foregroundColor(.white) + .frame(width: 48, height: 48) + } + .glassEffect(.clear) + + Button { + } label: { + Image(systemName: "square.and.arrow.up") + .font(.body) + .foregroundColor(.white) + .frame(width: 48, height: 48) + } + .glassEffect(.clear) + } + .transition(.opacity.animation(.easeInOut)) + } + } + .padding(.horizontal, 20) + .padding(.top, geometry.safeAreaInsets.top + 20) + + Spacer() + } + } + + private func orbView(geometry: GeometryProxy) -> some View { + ZStack { + AnimatedOrbView() + + if viewModel.isPlaying { + BokehEffectView(amplitude: .constant(0.8)) + .frame(width: 18, height: 18) + } + } + .frame(width: 200, height: 200) + .scaleEffect(viewModel.isPlaying ? 1.3 : 0.8) + .opacity(viewModel.isPlaying ? 1.0 : 0.4) + .animation(.easeInOut(duration: 0.5), value: viewModel.isPlaying) + .scaleEffect(viewModel.orbDragScale) + .offset(y: viewModel.dragOffset) + .gesture( + DragGesture() + .onChanged { value in + viewModel.handleDragChange(value: value, geometry: geometry) + } + .onEnded { value in + viewModel.handleDragEnd(value: value, geometry: geometry) { + + heartbeatSoundManager.deleteRecording(recording) + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + showTimeline = true + } + } + } + ) + .onTapGesture { + viewModel.togglePlayback(manager: heartbeatSoundManager, recording: recording) + } + } + + private var statusTextView: some View { + VStack { + Spacer() + VStack(spacing: 8) { + Text(viewModel.isPlaying ? "Playing" : "Tap to play") + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.7)) + + if !viewModel.isDraggingToDelete { + Text("Drag up to delete") + .font(.caption) + .foregroundColor(.white.opacity(0.5)) + } + } + .padding(.bottom, 60) + } + .opacity(viewModel.isDraggingToDelete ? 0.0 : 1.0) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + } + + private func deleteButton(geometry: GeometryProxy) -> some View { + Image(systemName: "trash.fill") + .font(.system(size: 28)) + .foregroundColor(.red) + .frame(width: 77, height: 77) + .clipShape(Circle()) + .glassEffect(.clear) + .scaleEffect(viewModel.deleteButtonScale) + .position(x: geometry.size.width / 2, y: 100) + .opacity(viewModel.isDraggingToDelete ? min(abs(viewModel.dragOffset) / 150.0, 1.0) : 0.0) + .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + .animation(.easeOut(duration: 0.2), value: viewModel.dragOffset) + } + + private var nameAndDateView: some View { + VStack { + Spacer() + .frame(height: 150) + + VStack(spacing: 8) { + // Editable name + TextField("Recording Name", text: $viewModel.editedName) + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + .padding(.vertical, 12) + .focused($isNameFieldFocused) + .submitLabel(.done) + .background( + viewModel.isEditingName ? + RoundedRectangle(cornerRadius: 20) + .fill(Color.white.opacity(0.15)) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + : nil + ) + .onChange(of: isNameFieldFocused) { _, isFocused in + if isFocused { + viewModel.startEditing() + } else { + if viewModel.isEditingName { + } + } + } + .onSubmit { + viewModel.saveName() + isNameFieldFocused = false + } + + Text(viewModel.formattedDate) + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.6)) + } + + Spacer() + } + } + + private var successAlertView: some View { + VStack { + HStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 28)) + .foregroundColor(.white) + + VStack(alignment: .leading, spacing: 4) { + Text("Changes saved!") + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.white) + + Text("Your changes is saved.") + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + + Spacer() + } + .padding(20) + .glassEffect(.clear) + .padding(.horizontal, 20) + .padding(.top, 60) + + Spacer() + } + } +} + +#Preview { + let manager = HeartbeatSoundManager() + let mockRecording = Recording( + fileURL: URL(fileURLWithPath: "/tmp/test.caf"), + createdAt: Date() + ) + + return SavedRecordingPlaybackView( + recording: mockRecording, + heartbeatSoundManager: manager, + showTimeline: .constant(false) + ) + .environmentObject(ThemeManager()) +} diff --git a/Tiny/Features/Preview/OnboardingToTimelinePreview.swift b/Tiny/Features/Preview/OnboardingToTimelinePreview.swift index 4cfdcf9..c20d452 100644 --- a/Tiny/Features/Preview/OnboardingToTimelinePreview.swift +++ b/Tiny/Features/Preview/OnboardingToTimelinePreview.swift @@ -5,56 +5,56 @@ // Created by Tm Revanza Narendra Pradipta on 28/11/25. // -import SwiftUI - -struct OnboardingToTimelinePreview: View { - @State private var showWeekInput = true - @State private var selectedWeek: Int? - @State private var showTimeline = false - - var body: some View { - ZStack { - if showWeekInput { - // Week Input Screen - WeekInputView(onComplete: { week in - selectedWeek = week - - // Reset animation flag to see it every time - UserDefaults.standard.set(false, forKey: "hasSeenTimelineAnimation") - - // Transition to timeline - withAnimation(.easeInOut(duration: 0.5)) { - showWeekInput = false - showTimeline = true - } - }) - .transition(.opacity) - } - - if showTimeline, let week = selectedWeek { - // Timeline with animation - PregnancyTimelineView( - heartbeatSoundManager: createMockManager(), - showTimeline: .constant(true), - onSelectRecording: { recording in - print("Selected: \(recording.fileURL.lastPathComponent)") - }, - isMother: true, - inputWeek: week - ) - .transition(.opacity) - } - } - .preferredColorScheme(.dark) - } - - private func createMockManager() -> HeartbeatSoundManager { - let manager = HeartbeatSoundManager() - // Empty recordings to show placeholder dots - return manager - } -} - -#Preview { - OnboardingToTimelinePreview() -} +//import SwiftUI +// +//struct OnboardingToTimelinePreview: View { +// @State private var showWeekInput = true +// @State private var selectedWeek: Int? +// @State private var showTimeline = false +// +// var body: some View { +// ZStack { +// if showWeekInput { +// // Week Input Screen +// WeekInputView(onComplete: { week in +// selectedWeek = week +// +// // Reset animation flag to see it every time +// UserDefaults.standard.set(false, forKey: "hasSeenTimelineAnimation") +// +// // Transition to timeline +// withAnimation(.easeInOut(duration: 0.5)) { +// showWeekInput = false +// showTimeline = true +// } +// }) +// .transition(.opacity) +// } +// +// if showTimeline, let week = selectedWeek { +// // Timeline with animation +// PregnancyTimelineView( +// heartbeatSoundManager: createMockManager(), +// showTimeline: .constant(true), +// onSelectRecording: { recording in +// print("Selected: \(recording.fileURL.lastPathComponent)") +// }, onDisableSwipe: <#(Bool) -> Void#>, +// isMother: true, +// inputWeek: week +// ) +// .transition(.opacity) +// } +// } +// .preferredColorScheme(.dark) +// } +// +// private func createMockManager() -> HeartbeatSoundManager { +// let manager = HeartbeatSoundManager() +// // Empty recordings to show placeholder dots +// return manager +// } +//} +// +//#Preview { +// OnboardingToTimelinePreview() +//} diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift index 4876d07..728c705 100644 --- a/Tiny/Features/Profile/Views/ProfileView.swift +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -246,7 +246,20 @@ struct ProfileView: View { } private func featureCardRight(width: CGFloat) -> some View { - VStack(alignment: .leading, spacing: 8) { + // Calculate current pregnancy week dynamically + let currentWeek: Int = { + guard let pregnancyStartDate = UserDefaults.standard.object(forKey: "pregnancyStartDate") as? Date else { + // Fallback to user's stored pregnancy week if start date not available + return authService.currentUser?.pregnancyWeeks ?? 0 + } + + let calendar = Calendar.current + let now = Date() + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: now).weekOfYear ?? 0 + return weeksSinceStart + }() + + return VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: "calendar") .font(.caption) @@ -259,7 +272,7 @@ struct ProfileView: View { Spacer() VStack(alignment: .center, spacing: 4) { - Text("20") + Text("\(currentWeek)") .font(.title) .fontWeight(.bold) .foregroundColor(Color("mainViolet")) diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index e414abe..73612de 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -11,12 +11,14 @@ struct PregnancyTimelineView: View { @ObservedObject var heartbeatSoundManager: HeartbeatSoundManager @Binding var showTimeline: Bool let onSelectRecording: (Recording) -> Void + let onDisableSwipe: (Bool) -> Void // Callback to disable TabView swipe let isMother: Bool // Add this parameter var inputWeek: Int? // Week from onboarding input @Namespace private var animation @State private var selectedWeek: WeekSection? @State private var groupedData: [WeekSection] = [] + @State private var isProfilePresented = false @EnvironmentObject var themeManager: ThemeManager // Animation support @@ -31,6 +33,7 @@ struct PregnancyTimelineView: View { Image(themeManager.selectedBackground.imageName) .resizable() .scaledToFill() + .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() if let week = selectedWeek { @@ -38,6 +41,7 @@ struct PregnancyTimelineView: View { week: week, animation: animation, onSelectRecording: onSelectRecording, + heartbeatSoundManager: heartbeatSoundManager, isMother: isMother ) .transition(.opacity) @@ -47,11 +51,68 @@ struct PregnancyTimelineView: View { selectedWeek: $selectedWeek, animation: animation, animationController: animationController, - isFirstTimeVisit: isFirstTimeVisit + isFirstTimeVisit: isFirstTimeVisit, +// isMother: isMother ) .transition(.opacity) } - navigationButtons + + // Top navigation bar + GeometryReader { geometry in + VStack { + HStack { + if selectedWeek != nil { + Button { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + selectedWeek = nil + } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + .frame(width: 50, height: 50) + } + .glassEffect(.clear) + .matchedGeometryEffect(id: "navButton", in: animation) + } else { + Spacer() + } + + Spacer() + + if selectedWeek == nil { + Button { + isProfilePresented = true + } label: { + Group { + if let image = userProfile.profileImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.white.opacity(0.8)) + } + } + .frame(width: 50, height: 50) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) + .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) + .padding(.top, geometry.safeAreaInsets.top + 39) + + Spacer() + } + } } .onAppear(perform: groupRecordings) } @@ -63,65 +124,11 @@ struct PregnancyTimelineView: View { print("Recordings changed: \(oldValue.count) -> \(newValue.count)") groupRecordings() } - } - - private var navigationButtons: some View { - GeometryReader { geometry in - VStack { - // Top Bar - HStack { - if selectedWeek != nil { - // Back Button (Detail -> List) - 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) - } else { - Spacer() - } - - Spacer() - - if selectedWeek == nil { - NavigationLink { - ProfileView() - } label: { - Group { - if let image = userProfile.profileImage { - Image(uiImage: image) - .resizable() - .scaledToFill() - } else { - Image(systemName: "person.crop.circle.fill") - .resizable() - .scaledToFit() - .foregroundColor(.white.opacity(0.8)) - } - } - .frame(width: 50, height: 50) - .clipShape(Circle()) - .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) - .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) - } - .opacity(isFirstTimeVisit ? (animationController.profileVisible ? 1.0 : 0.0) : 1.0) - .padding() - } - } - .padding() - - Spacer() - } + .onChange(of: isProfilePresented) { _, newValue in + // Disable TabView swipe when ProfileView is presented + print("🔄 Profile presented changed: \(newValue)") + onDisableSwipe(newValue) } - .ignoresSafeArea(.all, edges: .bottom) } private func initializeTimeline() { @@ -167,12 +174,11 @@ struct PregnancyTimelineView: View { } } - private func groupRecordings() { let raw = heartbeatSoundManager.savedRecordings print("📊 Grouping \(raw.count) recordings") - guard let currentPregnancyWeek = inputWeek else { + guard let initialPregnancyWeek = inputWeek else { print("⚠️ No pregnancy week available, showing empty timeline") groupedData = [] return @@ -181,16 +187,31 @@ struct PregnancyTimelineView: View { let calendar = Calendar.current let now = Date() - // Calculate when the pregnancy started (in weeks ago) - // If current pregnancy week is 20, pregnancy started 20 weeks ago - guard let pregnancyStartDate = calendar.date(byAdding: .weekOfYear, value: -currentPregnancyWeek, to: now) else { - print("⚠️ Could not calculate pregnancy start date") + // Store initial pregnancy week and date in UserDefaults if not already stored + if UserDefaults.standard.object(forKey: "pregnancyStartDate") == nil { + let pregnancyStartDate = calendar.date(byAdding: .weekOfYear, value: -initialPregnancyWeek, to: now)! + UserDefaults.standard.set(pregnancyStartDate, forKey: "pregnancyStartDate") + UserDefaults.standard.set(initialPregnancyWeek, forKey: "initialPregnancyWeek") + print("💾 Stored pregnancy start date: \(pregnancyStartDate)") + } + + // Get the stored pregnancy start date + guard let storedDate = UserDefaults.standard.object(forKey: "pregnancyStartDate") as? Date else { + print("⚠️ Could not get pregnancy start date") groupedData = [] return } + // Normalize to start of day to avoid time-based drift + let pregnancyStartDate = calendar.startOfDay(for: storedDate) + + // Calculate CURRENT pregnancy week based on time elapsed since pregnancy start + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: now).weekOfYear ?? 0 + let currentPregnancyWeek = weeksSinceStart - print("📅 Pregnancy started approximately: \(pregnancyStartDate)") - print("📅 Current pregnancy week: \(currentPregnancyWeek)") + print("📅 Pregnancy started: \(pregnancyStartDate)") + print("📅 Initial pregnancy week: \(initialPregnancyWeek)") + print("📅 Current pregnancy week (calculated): \(currentPregnancyWeek)") + print("📅 Weeks elapsed: \(currentPregnancyWeek - initialPregnancyWeek)") // Group recordings by pregnancy week let grouped = Dictionary(grouping: raw) { recording -> Int in @@ -205,7 +226,7 @@ struct PregnancyTimelineView: View { WeekSection(weekNumber: $0.key, recordings: $0.value.sorted(by: { $0.createdAt > $1.createdAt }), type: .recorded) }.sorted(by: { $0.weekNumber > $1.weekNumber }) // Reversed: newest (highest week) at bottom - // Always ensure we show the current week and next 2 weeks + // Show current week + next 2 weeks (as placeholders if no recordings) let weeksToShow = [currentPregnancyWeek, currentPregnancyWeek + 1, currentPregnancyWeek + 2] for week in weeksToShow { @@ -312,6 +333,7 @@ struct PregnancyTimelineView: View { onSelectRecording: { recording in print("Selected recording: \(recording.fileURL.lastPathComponent)") }, + onDisableSwipe: { _ in }, isMother: true, inputWeek: 20 // Test with week 20 ) diff --git a/Tiny/Features/Timeline/Views/TimelineDetailView.swift b/Tiny/Features/Timeline/Views/TimelineDetailView.swift index 4452511..e78b78d 100644 --- a/Tiny/Features/Timeline/Views/TimelineDetailView.swift +++ b/Tiny/Features/Timeline/Views/TimelineDetailView.swift @@ -9,21 +9,33 @@ import SwiftUI struct TimelineDetailView: View { let week: WeekSection - var animation: Namespace.ID // Passed from parent + var animation: Namespace.ID let onSelectRecording: (Recording) -> Void + @ObservedObject var heartbeatSoundManager: HeartbeatSoundManager + let isMother: Bool + private var currentRecordings: [Recording] { + heartbeatSoundManager.savedRecordings.filter { recording in + let calendar = Calendar.current + guard let storedDate = UserDefaults.standard.object(forKey: "pregnancyStartDate") as? Date else { + return false + } + let pregnancyStartDate = calendar.startOfDay(for: storedDate) + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: recording.createdAt).weekOfYear ?? 0 + return weeksSinceStart == week.weekNumber + }.sorted { $0.createdAt < $1.createdAt } // Oldest first (newest at bottom) + } + var body: some View { GeometryReader { geometry in ZStack { - // Background content: recordings list + // Scrollable content with orb and recordings recordingsScrollView(geometry: geometry) - .padding(.top, 180) // Make space for header + orb - // Foreground: Header with back button and title + // Fixed header with back button and week title VStack(spacing: 0) { - // Top navigation bar HStack { // Back button placeholder (actual button is in PregnancyTimelineView) Color.clear @@ -43,16 +55,14 @@ struct TimelineDetailView: View { .frame(width: 50, height: 50) } .padding(.horizontal, 20) - .padding(.top, geometry.safeAreaInsets.top + 20) - - // Hero Orb - ZStack { - AnimatedOrbView(size: 115) - .shadow(color: .orange.opacity(0.6), radius: 30) - } - .matchedGeometryEffect(id: "orb_\(week.weekNumber)", in: animation) - .frame(height: 115) - .padding(.top, 20) + .padding(.top, geometry.safeAreaInsets.top + 40) + .background( + LinearGradient( + colors: [Color.black.opacity(0.6), Color.clear], + startPoint: .top, + endPoint: .bottom + ) + ) Spacer() } @@ -61,28 +71,43 @@ struct TimelineDetailView: View { } private func recordingsScrollView(geometry: GeometryProxy) -> some View { - let recordings = week.recordings + let recordings = currentRecordings let recSpacing: CGFloat = 100 - let recHeight = max(geometry.size.height - 180, CGFloat(recordings.count) * recSpacing + 200) + let orbHeight: CGFloat = 115 + let topPadding: CGFloat = geometry.safeAreaInsets.top + 100 + + // Calculate total height + let contentHeight = orbHeight + CGFloat(recordings.count) * recSpacing + 200 return ScrollView(showsIndicators: false) { ZStack(alignment: .top) { - // Tighter Wavy Path for details + // Continuous Wave Path ContinuousWave( - totalHeight: recHeight, - period: 400, // Faster wave - amplitude: 60 // Smaller width + totalHeight: contentHeight - (orbHeight / 2), + period: 400, + amplitude: 60 ) .stroke( Color.white.opacity(0.15), style: StrokeStyle(lineWidth: 1, lineCap: .round) ) - .frame(width: geometry.size.width, height: recHeight) + .frame(width: geometry.size.width, height: contentHeight - (orbHeight / 2)) + .offset(y: orbHeight / 2) // Start from center of orb (visually bottom due to ZStack alignment) - // Glowing Dots (Recordings) + // Orb at the top + ZStack { + AnimatedOrbView(size: orbHeight) + .shadow(color: .orange.opacity(0.6), radius: 30) + } + .matchedGeometryEffect(id: "orb_\(week.weekNumber)", in: animation) + .frame(height: orbHeight) + .frame(maxWidth: .infinity) // Center horizontally + // No top padding here, it sits at y=0 of the ZStack (start of wave) + + // Recordings ForEach(Array(recordings.enumerated()), id: \.element.id) { index, recording in - let yPos: CGFloat = 40 + (CGFloat(index) * recSpacing) + let yPos = orbHeight + CGFloat(index) * recSpacing + 40 let xPos = TimelineLayout.calculateX( yCoor: yPos, width: geometry.size.width, @@ -90,44 +115,46 @@ struct TimelineDetailView: View { 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) - } + HStack(spacing: 16) { + // Glowing dot + glowingDot + .onTapGesture { onSelectRecording(recording) } + + // Label + recordingLabel(for: recording) + + Spacer() } - .frame(width: 300, height: 60) - .position(x: xPos, y: yPos) + .padding(.leading, xPos - 6) // Position dot center at xPos (6 is half dot width) + .frame(width: geometry.size.width, alignment: .leading) + .position(x: geometry.size.width / 2, y: yPos) } } - .frame(width: geometry.size.width, height: recHeight) + .frame(width: geometry.size.width, height: contentHeight) + .padding(.top, topPadding) } } 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) + Circle() + .fill(Color.white) + .frame(width: 12, height: 12) + .shadow(color: .white.opacity(0.8), radius: 8, x: 0, y: 0) // Glow effect } } 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) + VStack(alignment: .leading, spacing: 4) { + Text(recording.displayName ?? "Baby's Heartbeat") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + .fixedSize(horizontal: false, vertical: true) + + Text(recording.createdAt.formatted(date: .long, time: .omitted)) + .font(.system(size: 13)) + .foregroundColor(.white.opacity(0.6)) + } } private func formatTimestamp(_ raw: String) -> String { @@ -150,9 +177,8 @@ struct TimelineDetailView: View { let dummyURL = URL(fileURLWithPath: "Heartbeat-1715421234.m4a") let rec1 = Recording(fileURL: dummyURL, createdAt: Date()) - let rec2 = Recording(fileURL: dummyURL, createdAt: Date().addingTimeInterval(-3600)) // 1 hour ago + let rec2 = Recording(fileURL: dummyURL, createdAt: Date().addingTimeInterval(-3600)) - // Assuming WeekSection is a simple struct. Adjust if needed. return WeekSection(weekNumber: 24, recordings: [rec1, rec2, rec1]) } @@ -163,9 +189,9 @@ struct TimelineDetailView: View { onSelectRecording: { recording in print("Selected: \(recording.createdAt)") }, + heartbeatSoundManager: HeartbeatSoundManager(), isMother: true ) - // Dark background to visualize the white text/glowing effects .background(Color(red: 0.1, green: 0.1, blue: 0.2)) .environmentObject(ThemeManager()) } diff --git a/Tiny/Resources/Localizable.xcstrings b/Tiny/Resources/Localizable.xcstrings index 869b307..0acf79d 100644 --- a/Tiny/Resources/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -84,10 +84,6 @@ "comment" : "A caption displayed alongside the slider for adjusting the gain of the microphone.", "isCommentAutoGenerated" : true }, - "20" : { - "comment" : "The number \"20\" displayed in the \"Pregnancy Age\" feature card.", - "isCommentAutoGenerated" : true - }, "20Hz" : { }, @@ -170,6 +166,10 @@ "comment" : "A title for the bottom sheet that appears when the user taps on their profile photo.", "isCommentAutoGenerated" : true }, + "Changes saved!" : { + "comment" : "A title displayed in an alert when a user successfully saves their changes to a recording.", + "isCommentAutoGenerated" : true + }, "Confidence" : { "comment" : "A label describing the confidence level of a heartbeat reading.", "isCommentAutoGenerated" : true @@ -225,6 +225,9 @@ "Drag to save" : { "comment" : "A text label that appears when the user drags their finger over the orb to save a new recording.", "isCommentAutoGenerated" : true + }, + "Drag up to delete" : { + }, "Dummy Theme View" : { "comment" : "A placeholder view representing a theme-related screen.", @@ -445,6 +448,10 @@ "comment" : "A label displayed next to a play/pause button in the tutorial overlay.", "isCommentAutoGenerated" : true }, + "Playing" : { + "comment" : "A text that appears when a recording is being played.", + "isCommentAutoGenerated" : true + }, "Playing with EQ" : { "comment" : "A message indicating that audio is being played with equalization applied.", "isCommentAutoGenerated" : true @@ -485,6 +492,10 @@ "comment" : "A header for the most recent heartbeat detections.", "isCommentAutoGenerated" : true }, + "Recording Name" : { + "comment" : "A label above the text field for editing a saved recording's name.", + "isCommentAutoGenerated" : true + }, "Recording: %@" : { "comment" : "A text label displaying the name of the most recently recorded audio file.", "isCommentAutoGenerated" : true @@ -653,6 +664,10 @@ "comment" : "A call-to-action label that appears at the bottom of the tutorial overlay, instructing the user to tap to continue.", "isCommentAutoGenerated" : true }, + "Tap to play" : { + "comment" : "A text displayed when a user taps on a recording to play it.", + "isCommentAutoGenerated" : true + }, "Tap twice" : { }, @@ -712,6 +727,10 @@ "comment" : "A description below a code display, explaining that they are connected to a room.", "isCommentAutoGenerated" : true }, + "Your changes is saved." : { + "comment" : "A description of what happens when they save their name edit.", + "isCommentAutoGenerated" : true + }, "Your recording will be saved automatically in your library." : { "comment" : "A description of what happens when the user stops a recording and it is saved.", "isCommentAutoGenerated" : true From 8548b6a82b1a23f284112be0d5cc0b8a98be8cae Mon Sep 17 00:00:00 2001 From: Revanza Narendra Date: Sun, 30 Nov 2025 22:36:26 +0700 Subject: [PATCH 31/44] feat: add image function --- Tiny/App/tinyApp.swift | 3 +- Tiny/Core/Models/SavedMomentModel.swift | 46 +++ .../Audio/HeartbeatSoundManager.swift | 220 ++++++++++ .../Storage/FirebaseStorageService.swift | 59 +++ .../Storage/HeartbeatSyncManager.swift | 181 +++++++- Tiny/Core/Views/ImagePicker.swift | 48 +++ .../LiveListen/Views/OrbLiveListenView.swift | 81 +++- .../Views/SavedRecordingPlaybackView.swift | 59 ++- Tiny/Features/Profile/Views/ProfileView.swift | 39 -- .../Timeline/Models/TimelineModel.swift | 19 + .../Features/Timeline/Views/DeleteAlert.swift | 82 ++++ .../Timeline/Views/TimelineDetailView.swift | 386 +++++++++++++++++- Tiny/Resources/Localizable.xcstrings | 31 ++ 13 files changed, 1179 insertions(+), 75 deletions(-) create mode 100644 Tiny/Core/Models/SavedMomentModel.swift create mode 100644 Tiny/Core/Views/ImagePicker.swift create mode 100644 Tiny/Features/Timeline/Views/DeleteAlert.swift diff --git a/Tiny/App/tinyApp.swift b/Tiny/App/tinyApp.swift index 550ddd8..273c153 100644 --- a/Tiny/App/tinyApp.swift +++ b/Tiny/App/tinyApp.swift @@ -25,7 +25,8 @@ struct TinyApp: App { // Define the container configuration var sharedModelContainer: ModelContainer = { let schema = Schema([ - SavedHeartbeat.self + SavedHeartbeat.self, + SavedMoment.self ]) let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) diff --git a/Tiny/Core/Models/SavedMomentModel.swift b/Tiny/Core/Models/SavedMomentModel.swift new file mode 100644 index 0000000..41240fd --- /dev/null +++ b/Tiny/Core/Models/SavedMomentModel.swift @@ -0,0 +1,46 @@ +// +// SavedMomentModel.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 30/11/25. +// + +import SwiftData +import Foundation + +@Model +class SavedMoment { + @Attribute(.unique) var id: UUID + var filePath: String // Local file path for the image + var timestamp: Date + var pregnancyWeeks: Int? + + // Firebase fields + var firebaseId: String? + var motherUserId: String? + var roomCode: String? + var isShared: Bool + var firebaseStorageURL: String? + var isSyncedToCloud: Bool + + init(filePath: String, + timestamp: Date = Date(), + pregnancyWeeks: Int? = nil, + firebaseId: String? = nil, + motherUserId: String? = nil, + roomCode: String? = nil, + isShared: Bool = true, + firebaseStorageURL: String? = nil, + isSyncedToCloud: Bool = false) { + self.id = UUID() + self.filePath = filePath + self.timestamp = timestamp + self.pregnancyWeeks = pregnancyWeeks + self.firebaseId = firebaseId + self.motherUserId = motherUserId + self.roomCode = roomCode + self.isShared = isShared + self.firebaseStorageURL = firebaseStorageURL + self.isSyncedToCloud = isSyncedToCloud + } +} diff --git a/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift b/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift index 7231d01..509e774 100644 --- a/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift +++ b/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift @@ -26,6 +26,18 @@ struct Recording: Identifiable, Equatable { var isPlaying: Bool = false } +struct Moment: Identifiable, Equatable { + let id: UUID + let fileURL: URL + let createdAt: Date + + init(id: UUID = UUID(), fileURL: URL, createdAt: Date) { + self.id = id + self.fileURL = fileURL + self.createdAt = createdAt + } +} + struct HeartbeatData { let timestamp: Date let bpm: Double @@ -74,6 +86,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { @Published var isRecording = false @Published var lastRecording: Recording? @Published var savedRecordings: [Recording] = [] + @Published var savedMoments: [Moment] = [] @Published var isPlayingPlayback = false @Published var amplitudeVal: Float = 0.0 @Published var blinkAmplitude: Float = 0.0 @@ -138,6 +151,29 @@ class HeartbeatSoundManager: NSObject, ObservableObject { print("✅ Loaded \(self.savedRecordings.count) recordings from SwiftData") } + // Load Moments + let momentResults = try modelContext.fetch(FetchDescriptor()) + DispatchQueue.main.async { + self.savedMoments = momentResults.compactMap { savedItem in + // Fix: Use filename only to reconstruct path, as absolute paths change on container recreation + let storedPath = savedItem.filePath + let fileName = URL(fileURLWithPath: storedPath).lastPathComponent + let fileURL = self.getDocumentsDirectory().appendingPathComponent(fileName) + + if !FileManager.default.fileExists(atPath: fileURL.path) { + print("⚠️ Moment file missing: \(fileURL.path)") + return nil + } + + return Moment( + id: savedItem.id, + fileURL: fileURL, + createdAt: savedItem.timestamp + ) + } + print("✅ Loaded \(self.savedMoments.count) moments from SwiftData") + } + // Sync from cloud if we have the necessary info if let roomCode = currentRoomCode, let syncManager = syncManager { Task { @MainActor in @@ -152,8 +188,15 @@ class HeartbeatSoundManager: NSObject, ObservableObject { isMother: isMother ) + // Fetch moments from cloud + _ = try await syncManager.syncMomentsFromCloud( + roomCode: roomCode, + modelContext: modelContext + ) + // Reload from SwiftData after sync let updatedResults = try modelContext.fetch(FetchDescriptor()) + let updatedMoments = try modelContext.fetch(FetchDescriptor()) // Show all heartbeats for both mothers and fathers (all are shared by default) self.savedRecordings = updatedResults.compactMap { savedItem in @@ -174,6 +217,25 @@ class HeartbeatSoundManager: NSObject, ObservableObject { } print("✅ Reloaded \(self.savedRecordings.count) recordings after sync (isMother: \(isMother))") + // Reload moments with SavedMoment IDs to prevent duplicates + self.savedMoments = updatedMoments.compactMap { savedItem in + // Fix: Use filename only + let storedPath = savedItem.filePath + let fileName = URL(fileURLWithPath: storedPath).lastPathComponent + let fileURL = self.getDocumentsDirectory().appendingPathComponent(fileName) + + if !FileManager.default.fileExists(atPath: fileURL.path) { + return nil + } + + return Moment( + id: savedItem.id, + fileURL: fileURL, + createdAt: savedItem.timestamp + ) + } + print("✅ Reloaded \(self.savedMoments.count) moments after sync") + // Force UI update self.objectWillChange.send() } catch { @@ -795,5 +857,163 @@ class HeartbeatSoundManager: NSObject, ObservableObject { print("❌ Delete failed: \(error)") } } + + // MARK: - Moment Management + + func saveMoment(image: UIImage) { + guard let modelContext = modelContext else { return } + + // Save image to disk + guard let data = image.jpegData(compressionQuality: 0.8) else { return } + let timestamp = Date() + let filename = "moment-\(timestamp.timeIntervalSince1970).jpg" + let fileURL = getDocumentsDirectory().appendingPathComponent(filename) + + do { + try data.write(to: fileURL) + + // Calculate pregnancy week + let pregnancyWeek: Int? = { + guard let pregnancyStartDate = UserDefaults.standard.object(forKey: "pregnancyStartDate") as? Date else { + return nil + } + let calendar = Calendar.current + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: Date()).weekOfYear ?? 0 + return weeksSinceStart + }() + + // Save to SwiftData + // Store just the filename (relative path) to avoid absolute path issues + let savedMoment = SavedMoment( + filePath: filename, + timestamp: timestamp, + pregnancyWeeks: pregnancyWeek + ) + + + modelContext.insert(savedMoment) + try modelContext.save() + + print("✅ Saved moment to SwiftData") + print(" File: \(filename)") + print(" Timestamp: \(timestamp)") + print(" Pregnancy Week: \(pregnancyWeek ?? -1)") + + // Update local array + let moment = Moment(id: savedMoment.id, fileURL: fileURL, createdAt: savedMoment.timestamp) + DispatchQueue.main.async { + self.savedMoments.append(moment) + self.objectWillChange.send() + } + + print("✅ Added moment to local array (count: \(savedMoments.count + 1))") + + // Upload to Firebase if possible + print("🔍 Checking Firebase upload requirements:") + print(" currentUserId: \(currentUserId ?? "nil")") + print(" currentRoomCode: \(currentRoomCode ?? "nil")") + print(" syncManager: \(syncManager != nil ? "available" : "nil")") + + if let userId = currentUserId, let roomCode = currentRoomCode, let syncManager = syncManager { + Task { @MainActor in + do { + print("📤 Uploading moment to Firebase...") + try await syncManager.uploadMoment(savedMoment, motherUserId: userId, roomCode: roomCode) + print("✅ Uploaded moment to Firebase") + print(" Storage URL: \(savedMoment.firebaseStorageURL ?? "not set")") + print(" Firebase ID: \(savedMoment.firebaseId ?? "not set")") + } catch { + print("❌ Failed to upload moment: \(error)") + } + } + } else { + print("⚠️ Skipping Firebase upload - missing required data") + if currentUserId == nil { print(" - User ID is nil") } + if currentRoomCode == nil { print(" - Room Code is nil") } + if syncManager == nil { print(" - Sync Manager is nil") } + } + + } catch { + print("❌ Failed to save moment: \(error)") + } + } + + func deleteMoment(_ moment: Moment) { + guard let modelContext = modelContext else { return } + + print("🗑️ Deleting moment with ID: \(moment.id)") + + // Remove from savedMoments array + if let index = savedMoments.firstIndex(where: { $0.id == moment.id }) { + savedMoments.remove(at: index) + print("✅ Removed from savedMoments array. New count: \(savedMoments.count)") + objectWillChange.send() + } + + // Remove from SwiftData using the Moment's ID (which is the SavedMoment's ID) + do { + // Capture the ID first for use in the Predicate + let momentId = moment.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == momentId } + ) + let results = try modelContext.fetch(descriptor) + + guard let entry = results.first else { + print("⚠️ No SavedMoment found with ID: \(moment.id)") + return + } + + let filePath = entry.filePath + let fileName = URL(fileURLWithPath: filePath).lastPathComponent + let fullPath = getDocumentsDirectory().appendingPathComponent(fileName).path + + // Delete from Firebase FIRST (before deleting from SwiftData) + if let firebaseId = entry.firebaseId, let syncManager = syncManager { + Task { @MainActor in + do { + print("🔥 Deleting moment from Firebase...") + try await syncManager.deleteMoment(entry) + print("✅ Deleted moment from Firebase") + print(" Firebase ID: \(firebaseId)") + + // After Firebase deletion succeeds, delete locally + self.deleteLocalMoment(entry: entry, fullPath: fullPath, modelContext: modelContext) + } catch { + print("❌ Failed to delete moment from Firebase: \(error)") + // Still delete locally even if Firebase delete fails + self.deleteLocalMoment(entry: entry, fullPath: fullPath, modelContext: modelContext) + } + } + } else { + // No Firebase sync, just delete locally + print("ℹ️ No Firebase ID, deleting locally only") + deleteLocalMoment(entry: entry, fullPath: fullPath, modelContext: modelContext) + } + + } catch { + print("❌ Delete moment failed: \(error)") + } + } + + private func deleteLocalMoment(entry: SavedMoment, fullPath: String, modelContext: ModelContext) { + do { + // Delete from SwiftData + modelContext.delete(entry) + try modelContext.save() + print("✅ Deleted moment from SwiftData") + + // Delete file from disk + let fileManager = FileManager.default + if fileManager.fileExists(atPath: fullPath) { + try fileManager.removeItem(atPath: fullPath) + print("✅ Deleted moment file from disk: \(fullPath)") + } else { + print("ℹ️ File not found on disk: \(fullPath)") + } + } catch { + print("❌ Failed to delete local moment: \(error)") + } + } } // swiftlint:enable type_body_length diff --git a/Tiny/Core/Services/Storage/FirebaseStorageService.swift b/Tiny/Core/Services/Storage/FirebaseStorageService.swift index 2e64515..988d386 100644 --- a/Tiny/Core/Services/Storage/FirebaseStorageService.swift +++ b/Tiny/Core/Services/Storage/FirebaseStorageService.swift @@ -109,6 +109,65 @@ class FirebaseStorageService: ObservableObject { try await storageRef.delete() } + // MARK: - Upload Moment Image + + func uploadMomentImage( + localFileURL: URL, + motherUserId: String, + momentId: String + ) async throws -> String { + let storageRef = storage.reference() + let momentRef = storageRef.child("moments/\(motherUserId)/\(momentId).jpg") + + let data = try Data(contentsOf: localFileURL) + + let metadata = StorageMetadata() + metadata.contentType = "image/jpeg" + metadata.customMetadata = [ + "motherUserId": motherUserId, + "momentId": momentId, + "uploadedAt": ISO8601DateFormatter().string(from: Date()) + ] + + _ = try await momentRef.putDataAsync(data, metadata: metadata) + let downloadURL = try await momentRef.downloadURL() + + return downloadURL.absoluteString + } + + // MARK: - Download Moment Image + + func downloadMomentImage( + downloadURL: String, + momentId: String, + timestamp: Date + ) async throws -> URL { + guard let _ = URL(string: downloadURL) else { + throw StorageError.invalidURL + } + + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let timeInterval = timestamp.timeIntervalSince1970 + let fileName = "moment-\(timeInterval).jpg" + let localURL = documentsPath.appendingPathComponent(fileName) + + if FileManager.default.fileExists(atPath: localURL.path) { + return localURL + } + + let storageRef = storage.reference(forURL: downloadURL) + _ = try await storageRef.writeAsync(toFile: localURL) + + return localURL + } + + // MARK: - Delete Moment Image + + func deleteMomentImage(downloadURL: String) async throws { + let storageRef = storage.reference(forURL: downloadURL) + try await storageRef.delete() + } + // MARK: - Get Shared Heartbeats for Room /// Lists all heartbeats shared in a room diff --git a/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift b/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift index abf5587..c114ac4 100644 --- a/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift +++ b/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift @@ -340,6 +340,170 @@ class HeartbeatSyncManager: ObservableObject { return syncedHeartbeats } + + + // MARK: - Moment Sync + + func uploadMoment( + _ moment: SavedMoment, + motherUserId: String, + roomCode: String + ) async throws { + isSyncing = true + defer { isSyncing = false } + + // 1. Reconstruct full path from filename (since we store relative paths) + let fileName = URL(fileURLWithPath: moment.filePath).lastPathComponent + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let localURL = documentsPath.appendingPathComponent(fileName) + let momentId = moment.id.uuidString + + print("📤 Uploading moment...") + print(" Local file: \(localURL.path)") + + let downloadURL = try await storageService.uploadMomentImage( + localFileURL: localURL, + motherUserId: motherUserId, + momentId: momentId + ) + + // 2. Update local model + moment.firebaseStorageURL = downloadURL + moment.isSyncedToCloud = true + moment.motherUserId = motherUserId + moment.roomCode = roomCode + + // 3. Save metadata to Firestore + let metadata: [String: Any] = [ + "momentId": momentId, + "motherUserId": motherUserId, + "roomCode": roomCode, + "firebaseStorageURL": downloadURL, + "timestamp": Timestamp(date: moment.timestamp), + "isShared": moment.isShared, + "pregnancyWeeks": moment.pregnancyWeeks ?? 0, + "createdAt": Timestamp(date: Date()) + ] + + let docRef = try await dbf.collection("moments").addDocument(data: metadata) + moment.firebaseId = docRef.documentID + + print("✅ Moment metadata saved to Firestore") + } + + func deleteMoment(_ moment: SavedMoment) async throws { + // Delete from Storage if synced + if let storageURL = moment.firebaseStorageURL { + try await storageService.deleteMomentImage(downloadURL: storageURL) + } + + // Delete from Firestore + if let firebaseId = moment.firebaseId { + try await dbf.collection("moments").document(firebaseId).delete() + } + } + + func fetchAllMomentsForRoom(roomCode: String) async throws -> [MomentMetadata] { + print("🔍 Fetching ALL moments for room code: '\(roomCode)'") + + let snapshot = try await dbf.collection("moments") + .whereField("roomCode", isEqualTo: roomCode) + .getDocuments() + + let moments = snapshot.documents.compactMap { doc -> MomentMetadata? in + let data = doc.data() + + return MomentMetadata( + id: doc.documentID, + momentId: data["momentId"] as? String ?? "", + motherUserId: data["motherUserId"] as? String ?? "", + roomCode: data["roomCode"] as? String ?? "", + firebaseStorageURL: data["firebaseStorageURL"] as? String ?? "", + timestamp: (data["timestamp"] as? Timestamp)?.dateValue() ?? Date(), + isShared: data["isShared"] as? Bool ?? false, + pregnancyWeeks: data["pregnancyWeeks"] as? Int + ) + } + + return moments.sorted { $0.timestamp > $1.timestamp } + } + + func downloadMoment(metadata: MomentMetadata) async throws -> URL { + return try await storageService.downloadMomentImage( + downloadURL: metadata.firebaseStorageURL, + momentId: metadata.momentId, + timestamp: metadata.timestamp + ) + } + + func syncMomentsFromCloud( + roomCode: String, + modelContext: ModelContext + ) async throws -> [SavedMoment] { + print("🔄 Syncing moments from cloud...") + + let metadataList = try await fetchAllMomentsForRoom(roomCode: roomCode) + var syncedMoments: [SavedMoment] = [] + + for metadata in metadataList { + // Check if we already have this moment locally + let allMoments = try modelContext.fetch(FetchDescriptor()) + let existingMoment = allMoments.first { $0.firebaseId == metadata.id } + + if let existing = existingMoment { + // Fix: Check existence using filename only + let storedPath = existing.filePath + let fileName = URL(fileURLWithPath: storedPath).lastPathComponent + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let localURL = documentsPath.appendingPathComponent(fileName) + + if !FileManager.default.fileExists(atPath: localURL.path) { + // Re-download if file is missing + do { + let downloadedURL = try await downloadMoment(metadata: metadata) + // Update to relative path (filename) + existing.filePath = downloadedURL.lastPathComponent + } catch { + print("❌ Re-download moment failed: \(error)") + } + } else { + // If file exists but path was absolute, update to relative for consistency + if existing.filePath != fileName { + existing.filePath = fileName + } + } + syncedMoments.append(existing) + } else { + // Download new moment + do { + let localURL = try await downloadMoment(metadata: metadata) + + let moment = SavedMoment( + filePath: localURL.lastPathComponent, // Store relative path + timestamp: metadata.timestamp, + pregnancyWeeks: metadata.pregnancyWeeks, + firebaseId: metadata.id, + motherUserId: metadata.motherUserId, + roomCode: metadata.roomCode, + isShared: metadata.isShared, + firebaseStorageURL: metadata.firebaseStorageURL, + isSyncedToCloud: true + ) + + modelContext.insert(moment) + syncedMoments.append(moment) + print(" ✅ Saved moment to SwiftData: \(metadata.id)") + } catch { + print(" ❌ Failed to download moment \(metadata.id): \(error)") + } + } + } + + try modelContext.save() + print("✅ Moment sync complete: \(syncedMoments.count) moments") + + return syncedMoments + } } // MARK: - Supporting Types @@ -356,6 +520,17 @@ struct HeartbeatMetadata: Identifiable { let displayName: String? } +struct MomentMetadata: Identifiable { + let id: String + let momentId: String + let motherUserId: String + let roomCode: String + let firebaseStorageURL: String + let timestamp: Date + let isShared: Bool + let pregnancyWeeks: Int? +} + enum SyncError: LocalizedError { case notSynced case uploadFailed @@ -364,11 +539,11 @@ enum SyncError: LocalizedError { var errorDescription: String? { switch self { case .notSynced: - return "Heartbeat has not been synced to cloud yet" + return "Item has not been synced to cloud yet" case .uploadFailed: - return "Failed to upload heartbeat" + return "Failed to upload item" case .downloadFailed: - return "Failed to download heartbeat" + return "Failed to download item" } } } diff --git a/Tiny/Core/Views/ImagePicker.swift b/Tiny/Core/Views/ImagePicker.swift new file mode 100644 index 0000000..61e9d76 --- /dev/null +++ b/Tiny/Core/Views/ImagePicker.swift @@ -0,0 +1,48 @@ +// +// ImagePicker.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 30/11/25. +// + +import SwiftUI +import UIKit + +struct ImagePicker: UIViewControllerRepresentable { + @Binding var image: UIImage? + @Environment(\.presentationMode) private var presentationMode + var sourceType: UIImagePickerController.SourceType = .photoLibrary + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = sourceType + picker.delegate = context.coordinator + picker.allowsEditing = false + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + let parent: ImagePicker + + init(_ parent: ImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + if let uiImage = info[.originalImage] as? UIImage { + parent.image = uiImage + } + parent.presentationMode.wrappedValue.dismiss() + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.presentationMode.wrappedValue.dismiss() + } + } +} diff --git a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift index a37bb69..679dc36 100644 --- a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift +++ b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift @@ -4,6 +4,9 @@ import SwiftData struct OrbLiveListenView: View { @State private var showThemeCustomization = false @EnvironmentObject var themeManager: ThemeManager + + @State private var showSuccessAlert = false + @State private var successMessage = (title: "", subtitle: "") @Environment(\.modelContext) private var modelContext @ObservedObject var heartbeatSoundManager: HeartbeatSoundManager @@ -49,6 +52,45 @@ struct OrbLiveListenView: View { coachMarkView + // Success Alert with dark overlay + if showSuccessAlert { + ZStack { + // Dark overlay + Color.black.opacity(0.6) + .ignoresSafeArea() + + // Alert on top + VStack { + HStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 28)) + .foregroundColor(.white) + + VStack(alignment: .leading, spacing: 4) { + Text(successMessage.title) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.white) + + Text(successMessage.subtitle) + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + + Spacer() + } + .padding(20) + .glassEffect(.clear) + .padding(.horizontal, 20) + .padding(.top, 60) + + Spacer() + } + } + .transition(.opacity) + .zIndex(300) + } + if let context = tutorialViewModel.activeTutorial { TutorialOverlay(viewModel: tutorialViewModel, context: context) } @@ -241,14 +283,41 @@ struct OrbLiveListenView: View { }, handleDragEnd: { value in viewModel.handleDragEnd(value: value, geometry: geometry, onSave: { - heartbeatSoundManager.saveRecording() - showTimeline = true + // Show alert first + successMessage = (title: "Saved!", subtitle: "Your recording is saved on timeline.") + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = true + } + // Then save after alert is visible + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + heartbeatSoundManager.saveRecording() + // Navigate after another delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = false + showTimeline = true + } + } + } }, onDelete: { - // Delete the recording if it's saved, or just dismiss if it's unsaved - if let lastRecording = heartbeatSoundManager.lastRecording { - heartbeatSoundManager.deleteRecording(lastRecording) + // Show alert first + successMessage = (title: "Deleted.", subtitle: "Your recording is deleted.") + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = true + } + // Then delete after alert is visible + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + if let lastRecording = heartbeatSoundManager.lastRecording { + heartbeatSoundManager.deleteRecording(lastRecording) + } + // Navigate after another delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = false + showTimeline = true + } + } } - showTimeline = true }) }, handleLongPressChange: viewModel.handleLongPressChange, diff --git a/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift b/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift index 126b415..7b2452c 100644 --- a/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift +++ b/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift @@ -18,6 +18,8 @@ struct SavedRecordingPlaybackView: View { @FocusState private var isNameFieldFocused: Bool @Environment(\.modelContext) private var modelContext + @State private var showDeleteSuccessAlert = false + var body: some View { GeometryReader { geometry in ZStack { @@ -39,6 +41,45 @@ struct SavedRecordingPlaybackView: View { .transition(.move(edge: .top).combined(with: .opacity)) .zIndex(100) } + + // Delete Success Alert with dark overlay + if showDeleteSuccessAlert { + ZStack { + // Dark overlay + Color.black.opacity(0.6) + .ignoresSafeArea() + + // Alert on top + VStack { + HStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 28)) + .foregroundColor(.white) + + VStack(alignment: .leading, spacing: 4) { + Text("Deleted.") + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.white) + + Text("Your recording is deleted.") + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + + Spacer() + } + .padding(20) + .glassEffect(.clear) + .padding(.horizontal, 20) + .padding(.top, 60) + + Spacer() + } + } + .transition(.opacity) + .zIndex(300) + } } .ignoresSafeArea() @@ -159,10 +200,20 @@ struct SavedRecordingPlaybackView: View { } .onEnded { value in viewModel.handleDragEnd(value: value, geometry: geometry) { - - heartbeatSoundManager.deleteRecording(recording) - withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { - showTimeline = true + // Show alert first + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showDeleteSuccessAlert = true + } + // Then delete after alert is visible + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + heartbeatSoundManager.deleteRecording(recording) + // Navigate after another delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + showDeleteSuccessAlert = false + showTimeline = true + } + } } } } diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift index 728c705..c28f0de 100644 --- a/Tiny/Features/Profile/Views/ProfileView.swift +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -500,45 +500,6 @@ private struct PhotoPickerButton: View { } } -struct ImagePicker: UIViewControllerRepresentable { - @Binding var image: UIImage? - let sourceType: UIImagePickerController.SourceType - @Environment(\.dismiss) var dismiss - - func makeUIViewController(context: Context) -> UIImagePickerController { - let picker = UIImagePickerController() - picker.sourceType = sourceType - picker.delegate = context.coordinator - picker.allowsEditing = false - return picker - } - - func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { - let parent: ImagePicker - - init(_ parent: ImagePicker) { - self.parent = parent - } - - func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { - if let image = info[.originalImage] as? UIImage { - parent.image = image - } - parent.dismiss() - } - - func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { - parent.dismiss() - } - } -} - struct ThemeDummy: View { var body: some View { Text("Dummy Theme View") diff --git a/Tiny/Features/Timeline/Models/TimelineModel.swift b/Tiny/Features/Timeline/Models/TimelineModel.swift index 93640ae..dfd5cf1 100644 --- a/Tiny/Features/Timeline/Models/TimelineModel.swift +++ b/Tiny/Features/Timeline/Models/TimelineModel.swift @@ -15,6 +15,25 @@ enum WeekType { case placeholder // Future week placeholder } +enum TimelineItem: Identifiable, Equatable { + case recording(Recording) + case moment(Moment) + + var id: UUID { + switch self { + case .recording(let recording): return recording.id + case .moment(let moment): return moment.id + } + } + + var createdAt: Date { + switch self { + case .recording(let recording): return recording.createdAt + case .moment(let moment): return moment.createdAt + } + } +} + struct WeekSection: Identifiable, Equatable { let id = UUID() let weekNumber: Int diff --git a/Tiny/Features/Timeline/Views/DeleteAlert.swift b/Tiny/Features/Timeline/Views/DeleteAlert.swift new file mode 100644 index 0000000..393c50a --- /dev/null +++ b/Tiny/Features/Timeline/Views/DeleteAlert.swift @@ -0,0 +1,82 @@ +// +// delete alert.swift +// Tiny +// +// Created by Tm Revanza Narendra Pradipta on 30/11/25. +// + +import SwiftUI + +struct DeleteAlert: View { + var body: some View { + VStack(alignment: .leading, spacing: 10) { + + VStack(alignment: .leading, spacing: 10) { + // Title + Text("Delete this moment?") + .font(.body) + .fontWeight(.bold) + .foregroundColor(.white) + + // Message + Text("This action is permanent and can't be undone.") + .font(.callout) + .foregroundColor(.white) + } + .padding(8) + .padding(.bottom, 24) + + // Buttons (side by side) + HStack(spacing: 12) { + // Delete Button (left) + Button { + } label: { + Text("Delete") + .font(.headline) + .foregroundColor(.red) + .frame(maxWidth: .infinity) + .frame(height: 48) + .cornerRadius(25) + } + .glassEffect(.regular.tint(.black.opacity(0.20))) + + // Keep Button (right, with gradient) + Button { + } label: { + Text("Keep") + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background( + RadialGradient( + colors: [ + Color(hex: "8376DB"), + Color(hex: "705AB1") + ], + center: .center, + startRadius: 5, + endRadius: 100 + ) + ) + .cornerRadius(25) + } + } + } + .frame(width: 300) + .padding(14) + .cornerRadius(20) + .glassEffect(.regular.tint(.black.opacity(0.50)), in: .rect(cornerRadius: 20.0)) + + } +} + +#Preview { + ZStack { + Image("librarySample1") + .resizable() + .frame(maxWidth: .infinity) + DeleteAlert() + } +} diff --git a/Tiny/Features/Timeline/Views/TimelineDetailView.swift b/Tiny/Features/Timeline/Views/TimelineDetailView.swift index e78b78d..d82eff1 100644 --- a/Tiny/Features/Timeline/Views/TimelineDetailView.swift +++ b/Tiny/Features/Timeline/Views/TimelineDetailView.swift @@ -16,16 +16,35 @@ struct TimelineDetailView: View { let isMother: Bool - private var currentRecordings: [Recording] { - heartbeatSoundManager.savedRecordings.filter { recording in - let calendar = Calendar.current - guard let storedDate = UserDefaults.standard.object(forKey: "pregnancyStartDate") as? Date else { - return false - } - let pregnancyStartDate = calendar.startOfDay(for: storedDate) + @State private var showMenu = false + @State private var showImagePicker = false + @State private var sourceType: UIImagePickerController.SourceType = .photoLibrary + @State private var selectedImage: UIImage? + @State private var selectedMoment: Moment? + @State private var showSuccessAlert = false + @State private var successMessage = (title: "", subtitle: "") + + private var currentItems: [TimelineItem] { + let calendar = Calendar.current + guard let storedDate = UserDefaults.standard.object(forKey: "pregnancyStartDate") as? Date else { + return [] + } + let pregnancyStartDate = calendar.startOfDay(for: storedDate) + + // Filter recordings + let recordings = heartbeatSoundManager.savedRecordings.compactMap { recording -> TimelineItem? in let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: recording.createdAt).weekOfYear ?? 0 - return weeksSinceStart == week.weekNumber - }.sorted { $0.createdAt < $1.createdAt } // Oldest first (newest at bottom) + return weeksSinceStart == week.weekNumber ? .recording(recording) : nil + } + + // Filter moments + let moments = heartbeatSoundManager.savedMoments.compactMap { moment -> TimelineItem? in + let weeksSinceStart = calendar.dateComponents([.weekOfYear], from: pregnancyStartDate, to: moment.createdAt).weekOfYear ?? 0 + return weeksSinceStart == week.weekNumber ? .moment(moment) : nil + } + + // Combine and sort (Oldest first) + return (recordings + moments).sorted { $0.createdAt < $1.createdAt } } var body: some View { @@ -51,8 +70,75 @@ struct TimelineDetailView: View { Spacer() // Right side spacer for balance - Color.clear - .frame(width: 50, height: 50) + // Right side spacer or Add Button + // Right side spacer or Add Button + VStack(alignment: .trailing, spacing: 0) { + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + showMenu.toggle() + } + } label: { + Image(systemName: "plus") + .font(.system(size: 20, weight: .medium)) + .foregroundColor(.white) + .frame(width: 44, height: 44) + .background(.ultraThinMaterial) + .clipShape(Circle()) + .overlay( + Circle() + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) + } + .rotationEffect(.degrees(showMenu ? 45 : 0)) + + // Custom Popover Menu + if showMenu { + VStack(spacing: 0) { + Button { + sourceType = .photoLibrary + showImagePicker = true + showMenu = false + } label: { + HStack { + Image(systemName: "photo.on.rectangle") + Text("Photo Library") + Spacer() + } + .padding() + .foregroundColor(.white) + } + + Divider() + .background(Color.white.opacity(0.2)) + + Button { + sourceType = .camera + showImagePicker = true + showMenu = false + } label: { + HStack { + Image(systemName: "camera") + Text("Take Photo") + Spacer() + } + .padding() + .foregroundColor(.white) + } + } + .frame(width: 200) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5) + .padding(.top, 8) + .transition(.scale(scale: 0.8, anchor: .topTrailing).combined(with: .opacity)) + } + } + .zIndex(100) // Ensure menu is on top } .padding(.horizontal, 20) .padding(.top, geometry.safeAreaInsets.top + 40) @@ -66,18 +152,102 @@ struct TimelineDetailView: View { Spacer() } + + // Moment Overlay + if let moment = selectedMoment { + MomentOverlayView( + moment: moment, + onDismiss: { selectedMoment = nil }, + onDelete: { + // Show alert first + successMessage = (title: "Deleted.", subtitle: "Your moment is deleted.") + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = true + } + // Then delete after alert is visible + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + heartbeatSoundManager.deleteMoment(moment) + selectedMoment = nil + // Hide alert after another delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + showSuccessAlert = false + } + } + } + } + ) + .transition(.opacity) + .zIndex(100) + } + + // Success Alert with dark overlay + if showSuccessAlert { + ZStack { + // Dark overlay + Color.black.opacity(0.6) + .ignoresSafeArea() + + // Alert on top + VStack { + HStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 28)) + .foregroundColor(.white) + + VStack(alignment: .leading, spacing: 4) { + Text(successMessage.title) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.white) + + Text(successMessage.subtitle) + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + + Spacer() + } + .padding(20) + .glassEffect(.clear) + .padding(.horizontal, 20) + .padding(.top, 60) + + Spacer() + } + } + .transition(.opacity) + .zIndex(300) + } + } + .onChange(of: selectedImage) { _, newImage in + if let image = newImage { + heartbeatSoundManager.saveMoment(image: image) + } + } + + .onTapGesture { + // Close menu when tapping outside + if showMenu { + withAnimation { + showMenu = false + } + } + } + .sheet(isPresented: $showImagePicker) { + ImagePicker(image: $selectedImage, sourceType: sourceType) } } } private func recordingsScrollView(geometry: GeometryProxy) -> some View { - let recordings = currentRecordings + let items = currentItems let recSpacing: CGFloat = 100 let orbHeight: CGFloat = 115 let topPadding: CGFloat = geometry.safeAreaInsets.top + 100 // Calculate total height - let contentHeight = orbHeight + CGFloat(recordings.count) * recSpacing + 200 + let contentHeight = orbHeight + CGFloat(items.count) * recSpacing + 200 return ScrollView(showsIndicators: false) { ZStack(alignment: .top) { @@ -105,8 +275,8 @@ struct TimelineDetailView: View { .frame(maxWidth: .infinity) // Center horizontally // No top padding here, it sits at y=0 of the ZStack (start of wave) - // Recordings - ForEach(Array(recordings.enumerated()), id: \.element.id) { index, recording in + // Items (Recordings & Moments) + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in let yPos = orbHeight + CGFloat(index) * recSpacing + 40 let xPos = TimelineLayout.calculateX( yCoor: yPos, @@ -116,16 +286,33 @@ struct TimelineDetailView: View { ) HStack(spacing: 16) { - // Glowing dot - glowingDot - .onTapGesture { onSelectRecording(recording) } - - // Label - recordingLabel(for: recording) + switch item { + case .recording(let recording): + // Glowing dot + glowingDot + .onTapGesture { onSelectRecording(recording) } + + // Label + recordingLabel(for: recording) + + case .moment(let moment): + // Moment Thumbnail + momentThumbnail(for: moment) + .onTapGesture { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + selectedMoment = moment + } + } + + // Date Label + Text(moment.createdAt.formatted(date: .long, time: .omitted)) + .font(.system(size: 13)) + .foregroundColor(.white.opacity(0.6)) + } Spacer() } - .padding(.leading, xPos - 6) // Position dot center at xPos (6 is half dot width) + .padding(.leading, xPos - (itemIsMoment(item) ? 25 : 6)) // Adjust padding based on item size .frame(width: geometry.size.width, alignment: .leading) .position(x: geometry.size.width / 2, y: yPos) } @@ -135,6 +322,27 @@ struct TimelineDetailView: View { } } + private func itemIsMoment(_ item: TimelineItem) -> Bool { + if case .moment = item { return true } + return false + } + + func momentThumbnail(for moment: Moment) -> some View { + AsyncImage(url: moment.fileURL) { phase in + if let image = phase.image { + image + .resizable() + .scaledToFill() + } else { + Color.gray.opacity(0.3) + } + } + .frame(width: 50, height: 50) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white, lineWidth: 2)) + .shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2) + } + var glowingDot: some View { ZStack { Circle() @@ -169,6 +377,140 @@ struct TimelineDetailView: View { } } +struct MomentOverlayView: View { + let moment: Moment + let onDismiss: () -> Void + let onDelete: () -> Void + @State private var showDeleteAlert = false + + var body: some View { + ZStack { + // Darkened background + Color.black.opacity(0.8) + .ignoresSafeArea() + .onTapGesture { + onDismiss() + } + + VStack(spacing: 20) { + // Photo with overlay + ZStack(alignment: .bottom) { + AsyncImage(url: moment.fileURL) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(maxWidth: .infinity) + .frame(height: 400) + .clipped() + } else { + ProgressView() + .tint(.white) + .frame(maxWidth: .infinity) + .frame(height: 400) + } + } + .frame(maxWidth: .infinity) + .frame(height: 400) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .padding(.horizontal, 20) + + // Date overlay - no background, body font + Text(moment.createdAt.formatted(date: .long, time: .omitted)) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.white) + .padding(.bottom, 20) + } + + // Delete Button + Button { + showDeleteAlert = true + } label: { + Image(systemName: "trash.fill") + .font(.system(size: 24)) + .foregroundColor(.red) + .frame(width: 50, height: 50) + .background(Circle().fill(Color.white.opacity(0.1))) + } + .padding(.top, 10) + } + + // Custom Alert (matching screenshot) + if showDeleteAlert { + Color.black.opacity(0.4) + .ignoresSafeArea() + .onTapGesture { + showDeleteAlert = false + } + + VStack(alignment: .leading, spacing: 10) { + + VStack(alignment: .leading, spacing: 10) { + // Title + Text("Delete this moment?") + .font(.body) + .fontWeight(.bold) + .foregroundColor(.white) + + // Message + Text("This action is permanent and can't be undone.") + .font(.callout) + .foregroundColor(.white) + } + .padding(8) + .padding(.bottom, 24) + + // Buttons (side by side) + HStack(spacing: 12) { + // Delete Button (left) + Button { + showDeleteAlert = false + onDelete() + } label: { + Text("Delete") + .font(.headline) + .foregroundColor(.red) + .frame(maxWidth: .infinity) + .frame(height: 48) + .cornerRadius(25) + } + .glassEffect(.regular.tint(.black.opacity(0.20))) + + // Keep Button (right, with gradient) + Button { + showDeleteAlert = false + } label: { + Text("Keep") + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background( + RadialGradient( + colors: [ + Color(hex: "8376DB"), + Color(hex: "705AB1") + ], + center: .center, + startRadius: 5, + endRadius: 100 + ) + ) + .cornerRadius(25) + } + } + } + .frame(width: 300) + .padding(14) + .cornerRadius(20) + .glassEffect(.regular.tint(.black.opacity(0.50)), in: .rect(cornerRadius: 20.0)) + } + } + } +} + #Preview { struct PreviewWrapper: View { @Namespace var animation diff --git a/Tiny/Resources/Localizable.xcstrings b/Tiny/Resources/Localizable.xcstrings index 0acf79d..2622d11 100644 --- a/Tiny/Resources/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -213,6 +213,17 @@ "Current Status" : { "comment" : "The title of the section that displays the user's current heartbeat status.", "isCommentAutoGenerated" : true + }, + "Delete" : { + "comment" : "The text on a button that deletes an item.", + "isCommentAutoGenerated" : true + }, + "Delete this moment?" : { + "comment" : "The title of the alert that appears when the user tries to delete a moment.", + "isCommentAutoGenerated" : true + }, + "Deleted." : { + }, "Detection Confidence" : { "comment" : "A section header describing the confidence of the heartbeat data analysis.", @@ -361,6 +372,10 @@ "comment" : "A button label that says \"Join Room\".", "isCommentAutoGenerated" : true }, + "Keep" : { + "comment" : "A button that cancels the deletion of a moment.", + "isCommentAutoGenerated" : true + }, "Key" : { "extractionState" : "manual" }, @@ -436,6 +451,10 @@ "comment" : "A button label that pauses a recording.", "isCommentAutoGenerated" : true }, + "Photo Library" : { + "comment" : "A button that allows the user to select an image from their photo library.", + "isCommentAutoGenerated" : true + }, "Play" : { "comment" : "A button label that says \"Play\".", "isCommentAutoGenerated" : true @@ -648,6 +667,10 @@ "comment" : "A label indicating that the playback has been stopped.", "isCommentAutoGenerated" : true }, + "Take Photo" : { + "comment" : "The text for a button that allows the user to take a photo.", + "isCommentAutoGenerated" : true + }, "Tap orb to play" : { "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 @@ -683,6 +706,10 @@ "comment" : "A button that allows the user to change the app's theme.", "isCommentAutoGenerated" : true }, + "This action is permanent and can't be undone." : { + "comment" : "The message displayed in the alert when deleting a moment.", + "isCommentAutoGenerated" : true + }, "Time" : { "comment" : "Data point on the heart rate trend chart.", "isCommentAutoGenerated" : true @@ -731,6 +758,10 @@ "comment" : "A description of what happens when they save their name edit.", "isCommentAutoGenerated" : true }, + "Your recording is deleted." : { + "comment" : "A message displayed after successfully deleting a recording.", + "isCommentAutoGenerated" : true + }, "Your recording will be saved automatically in your library." : { "comment" : "A description of what happens when the user stops a recording and it is saved.", "isCommentAutoGenerated" : true From 3ed10158de3faef889f28fe3d224826115f0d432 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Sun, 30 Nov 2025 23:33:54 +0700 Subject: [PATCH 32/44] fix: swiftlint violations --- .../Audio/HeartbeatSoundManager.swift | 3 +- .../Storage/FirebaseStorageService.swift | 8 +- .../Storage/HeartbeatSyncManager.swift | 3 +- Tiny/Core/Views/ImagePicker.swift | 2 +- .../ViewModels/HeartbeatMainViewModel.swift | 2 +- .../LiveListen/Views/OrbLiveListenView.swift | 2 + .../Preview/OnboardingToTimelinePreview.swift | 60 ------ .../Features/SignUp/Views/WeekInputView.swift | 2 +- .../Timeline/Views/MainTimelineListView.swift | 2 +- .../Views/PregnancyTimelineView.swift | 182 ++++-------------- 10 files changed, 48 insertions(+), 218 deletions(-) delete mode 100644 Tiny/Features/Preview/OnboardingToTimelinePreview.swift diff --git a/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift b/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift index 509e774..d71949f 100644 --- a/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift +++ b/Tiny/Core/Services/Audio/HeartbeatSoundManager.swift @@ -123,7 +123,7 @@ class HeartbeatSoundManager: NSObject, ObservableObject { guard let modelContext = modelContext else { return } do { let results = try modelContext.fetch(FetchDescriptor()) - let _ = getDocumentsDirectory() + _ = getDocumentsDirectory() DispatchQueue.main.async { // Map the REAL timestamp from SwiftData @@ -890,7 +890,6 @@ class HeartbeatSoundManager: NSObject, ObservableObject { pregnancyWeeks: pregnancyWeek ) - modelContext.insert(savedMoment) try modelContext.save() diff --git a/Tiny/Core/Services/Storage/FirebaseStorageService.swift b/Tiny/Core/Services/Storage/FirebaseStorageService.swift index 988d386..3b3818e 100644 --- a/Tiny/Core/Services/Storage/FirebaseStorageService.swift +++ b/Tiny/Core/Services/Storage/FirebaseStorageService.swift @@ -71,10 +71,10 @@ class FirebaseStorageService: ObservableObject { heartbeatId: String, timestamp: Date ) async throws -> URL { - guard let _ = URL(string: downloadURL) else { + guard URL(string: downloadURL) != nil else { throw StorageError.invalidURL } - + // Create local file path with timestamp-based name (matching original format) let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let timeInterval = timestamp.timeIntervalSince1970 @@ -142,10 +142,10 @@ class FirebaseStorageService: ObservableObject { momentId: String, timestamp: Date ) async throws -> URL { - guard let _ = URL(string: downloadURL) else { + guard URL(string: downloadURL) != nil else { throw StorageError.invalidURL } - + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let timeInterval = timestamp.timeIntervalSince1970 let fileName = "moment-\(timeInterval).jpg" diff --git a/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift b/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift index c114ac4..bb0b8da 100644 --- a/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift +++ b/Tiny/Core/Services/Storage/HeartbeatSyncManager.swift @@ -10,6 +10,7 @@ import FirebaseFirestore import SwiftData internal import Combine +// swiftlint:disable type_body_length @MainActor class HeartbeatSyncManager: ObservableObject { private let dbf = Firestore.firestore() @@ -341,7 +342,6 @@ class HeartbeatSyncManager: ObservableObject { return syncedHeartbeats } - // MARK: - Moment Sync func uploadMoment( @@ -505,6 +505,7 @@ class HeartbeatSyncManager: ObservableObject { return syncedMoments } } +// swiftlint:enable type_body_length // MARK: - Supporting Types diff --git a/Tiny/Core/Views/ImagePicker.swift b/Tiny/Core/Views/ImagePicker.swift index 61e9d76..5421de0 100644 --- a/Tiny/Core/Views/ImagePicker.swift +++ b/Tiny/Core/Views/ImagePicker.swift @@ -34,7 +34,7 @@ struct ImagePicker: UIViewControllerRepresentable { self.parent = parent } - func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { if let uiImage = info[.originalImage] as? UIImage { parent.image = uiImage } diff --git a/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift b/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift index eba9ac4..f68fc2e 100644 --- a/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift +++ b/Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift @@ -12,7 +12,7 @@ internal import Combine class HeartbeatMainViewModel: ObservableObject { @Published var currentPage: Int = 0 @Published var allowTabViewSwipe: Bool = true - @Published var selectedRecording: Recording? = nil + @Published var selectedRecording: Recording? let heartbeatSoundManager = HeartbeatSoundManager() func setupManager( diff --git a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift index b90d1f5..a760ee2 100644 --- a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift +++ b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift @@ -1,6 +1,7 @@ import SwiftUI import SwiftData +// swiftlint:disable type_body_length struct OrbLiveListenView: View { @State private var showThemeCustomization = false @EnvironmentObject var themeManager: ThemeManager @@ -422,6 +423,7 @@ struct OrbLiveListenView: View { } } } +// swiftlint:enable type_body_length // #Preview("Normal Mode") { // OrbLiveListenView( diff --git a/Tiny/Features/Preview/OnboardingToTimelinePreview.swift b/Tiny/Features/Preview/OnboardingToTimelinePreview.swift deleted file mode 100644 index c20d452..0000000 --- a/Tiny/Features/Preview/OnboardingToTimelinePreview.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// OnboardingToTimelinePreview.swift -// Tiny -// -// Created by Tm Revanza Narendra Pradipta on 28/11/25. -// - -//import SwiftUI -// -//struct OnboardingToTimelinePreview: View { -// @State private var showWeekInput = true -// @State private var selectedWeek: Int? -// @State private var showTimeline = false -// -// var body: some View { -// ZStack { -// if showWeekInput { -// // Week Input Screen -// WeekInputView(onComplete: { week in -// selectedWeek = week -// -// // Reset animation flag to see it every time -// UserDefaults.standard.set(false, forKey: "hasSeenTimelineAnimation") -// -// // Transition to timeline -// withAnimation(.easeInOut(duration: 0.5)) { -// showWeekInput = false -// showTimeline = true -// } -// }) -// .transition(.opacity) -// } -// -// if showTimeline, let week = selectedWeek { -// // Timeline with animation -// PregnancyTimelineView( -// heartbeatSoundManager: createMockManager(), -// showTimeline: .constant(true), -// onSelectRecording: { recording in -// print("Selected: \(recording.fileURL.lastPathComponent)") -// }, onDisableSwipe: <#(Bool) -> Void#>, -// isMother: true, -// inputWeek: week -// ) -// .transition(.opacity) -// } -// } -// .preferredColorScheme(.dark) -// } -// -// private func createMockManager() -> HeartbeatSoundManager { -// let manager = HeartbeatSoundManager() -// // Empty recordings to show placeholder dots -// return manager -// } -//} -// -//#Preview { -// OnboardingToTimelinePreview() -//} diff --git a/Tiny/Features/SignUp/Views/WeekInputView.swift b/Tiny/Features/SignUp/Views/WeekInputView.swift index 9dce18e..d71f649 100644 --- a/Tiny/Features/SignUp/Views/WeekInputView.swift +++ b/Tiny/Features/SignUp/Views/WeekInputView.swift @@ -51,7 +51,7 @@ enum PregnancyStage { struct WeekInputView: View { @State private var selectedWeek: Int = 20 - var onComplete: ((Int) -> Void)? // Callback when user completes + var onComplete: ((Int) -> Void)? // Callback when user completes var body: some View { ZStack { diff --git a/Tiny/Features/Timeline/Views/MainTimelineListView.swift b/Tiny/Features/Timeline/Views/MainTimelineListView.swift index f7df76e..21efd71 100644 --- a/Tiny/Features/Timeline/Views/MainTimelineListView.swift +++ b/Tiny/Features/Timeline/Views/MainTimelineListView.swift @@ -35,7 +35,7 @@ struct MainTimelineListView: View { ScrollViewReader { proxy in ZStack(alignment: .top) { let orbPositions = groupedData.indices.map { index in - topPadding + (CGFloat(index) * itemSpacing) + topPadding + CGFloat(index) * itemSpacing } SegmentedWave( diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index 541b37e..c641bcf 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -249,166 +249,54 @@ struct PregnancyTimelineView: View { #Preview { @Previewable @State var showTimeline = true - - // Create mock HeartbeatSoundManager with sample recordings + let mockManager = HeartbeatSoundManager() - let themeManager = ThemeManager() - - // Create sample recordings across different weeks let calendar = Calendar.current let now = Date() - - // 10 weeks of data going back in time - // Week 1 (9 weeks ago): 2 recordings - if let weekDate = calendar.date(byAdding: .day, value: -63, to: now) { - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week1-rec1.wav"), - createdAt: weekDate - ) - ) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week1-rec2.wav"), - createdAt: weekDate.addingTimeInterval(3600) - ) - ) - } - - // Week 2 (8 weeks ago): 3 recordings - if let weekDate = calendar.date(byAdding: .day, value: -56, to: now) { - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week2-rec1.wav"), createdAt: weekDate)) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week2-rec2.wav"), - createdAt: weekDate.addingTimeInterval(7200) - ) - ) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week2-rec3.wav"), - createdAt: weekDate.addingTimeInterval(14400) - ) - ) - } - - // Week 3 (7 weeks ago): 1 recording - if let weekDate = calendar.date(byAdding: .day, value: -49, to: now) { - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week3-rec1.wav"), createdAt: weekDate)) - } - - // Week 4 (6 weeks ago): 4 recordings - if let weekDate = calendar.date(byAdding: .day, value: -42, to: now) { - mockManager.savedRecordings.append(Recording(fileURL: URL(fileURLWithPath: "/mock/week4-rec1.wav"), createdAt: weekDate)) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week4-rec2.wav"), - createdAt: weekDate.addingTimeInterval(3600) - ) - ) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week4-rec3.wav"), - createdAt: weekDate.addingTimeInterval(7200))) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week4-rec4.wav"), - createdAt: weekDate.addingTimeInterval(10800) - ) - ) - } - - // Week 5 (5 weeks ago): 2 recordings - if let weekDate = calendar.date(byAdding: .day, value: -35, to: now) { - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week5-rec1.wav"), - createdAt: weekDate - ) - ) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week5-rec2.wav"), - createdAt: weekDate.addingTimeInterval(5400) - ) - ) - } - // Week 6 (4 weeks ago): 3 recordings - if let weekDate = calendar.date(byAdding: .day, value: -28, to: now) { - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week6-rec1.wav"), - createdAt: weekDate - ) - ) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week6-rec2.wav"), - createdAt: weekDate.addingTimeInterval(3600) - ) - ) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week6-rec3.wav"), - createdAt: weekDate.addingTimeInterval(7200) - ) - ) - } + // Sample mock data per week + let mockData: [(weeksAgo: Int, count: Int)] = [ + (9, 2), // Week 1 + (8, 3), // Week 2 + (7, 1), // Week 3 + (6, 4), // Week 4 + (5, 2), // Week 5 + (4, 3), // Week 6 + (3, 2), // Week 7 + (2, 3), // Week 8 + (1, 1) // Week 9 + ] - // Week 7 (3 weeks ago): 2 recordings - if let weekDate = calendar.date(byAdding: .day, value: -21, to: now) { - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week7-rec1.wav"), - createdAt: weekDate - ) - ) - mockManager.savedRecordings.append( - Recording( - fileURL: URL(fileURLWithPath: "/mock/week7-rec2.wav"), - createdAt: weekDate.addingTimeInterval(4800) - ) - ) + // Generate weeks 1–9 + for (weeksAgo, count) in mockData { + if let weekDate = calendar.date(byAdding: .day, value: -(weeksAgo * 7), to: now) { + for index in 0.. Date: Mon, 1 Dec 2025 01:38:11 +0700 Subject: [PATCH 33/44] 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 34/44] 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 35/44] 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 { From e03e5dbdeb4e87f9d98f1fc62609517ee1581a4b Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Mon, 1 Dec 2025 03:34:49 +0700 Subject: [PATCH 36/44] fix: top alert when save and delete behaviour --- .../LiveListen/Views/OrbLiveListenView.swift | 58 +++++++++---------- .../Views/SavedRecordingPlaybackView.swift | 8 +++ 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift index a760ee2..0df771b 100644 --- a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift +++ b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift @@ -26,14 +26,16 @@ struct OrbLiveListenView: View { .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) topControlsView - .opacity(viewModel.isDraggingToSave || viewModel.isDraggingToDelete ? 0.0 : 1.0) + .opacity(viewModel.isDraggingToSave || viewModel.isDraggingToDelete || showSuccessAlert ? 0.0 : 1.0) .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToSave) .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + .animation(.easeOut(duration: 0.2), value: showSuccessAlert) statusTextView - .opacity(viewModel.isDraggingToSave || viewModel.isDraggingToDelete ? 0.0 : 1.0) + .opacity(viewModel.isDraggingToSave || viewModel.isDraggingToDelete || showSuccessAlert ? 0.0 : 1.0) .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToSave) .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + .animation(.easeOut(duration: 0.2), value: showSuccessAlert) orbView(geometry: geometry) @@ -46,49 +48,43 @@ struct OrbLiveListenView: View { // Floating Button to Open Timeline manually if !viewModel.isListening && !viewModel.isDraggingToSave && !viewModel.isDraggingToDelete { libraryOpenButton(geometry: geometry) - .opacity(viewModel.isDraggingToSave || viewModel.isDraggingToDelete ? 0.0 : 1.0) + .opacity(viewModel.isDraggingToSave || viewModel.isDraggingToDelete || showSuccessAlert ? 0.0 : 1.0) .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToSave) .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) + .animation(.easeOut(duration: 0.2), value: showSuccessAlert) } coachMarkView - // Success Alert with dark overlay + // Success Alert (Slide down, no overlay) if showSuccessAlert { - ZStack { - // Dark overlay - Color.black.opacity(0.6) - .ignoresSafeArea() - - // Alert on top - VStack { - HStack(spacing: 16) { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 28)) + VStack { + HStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 28)) + .foregroundColor(.white) + + VStack(alignment: .leading, spacing: 4) { + Text(successMessage.title) + .font(.body) + .fontWeight(.semibold) .foregroundColor(.white) - VStack(alignment: .leading, spacing: 4) { - Text(successMessage.title) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.white) - - Text(successMessage.subtitle) - .font(.caption) - .foregroundColor(.white.opacity(0.8)) - } - - Spacer() + Text(successMessage.subtitle) + .font(.caption) + .foregroundColor(.white.opacity(0.8)) } - .padding(20) - .glassEffect(.clear) - .padding(.horizontal, 20) - .padding(.top, 60) Spacer() } + .padding(20) + .glassEffect(.clear) + .padding(.horizontal, 20) + .padding(.top, 20) + + Spacer() } - .transition(.opacity) + .transition(.move(edge: .top).combined(with: .opacity)) .zIndex(300) } diff --git a/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift b/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift index 7b2452c..ffecc37 100644 --- a/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift +++ b/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift @@ -26,13 +26,19 @@ struct SavedRecordingPlaybackView: View { backgroundView topControlsView(geometry: geometry) + .opacity(showDeleteSuccessAlert ? 0.0 : 1.0) + .animation(.easeOut(duration: 0.2), value: showDeleteSuccessAlert) orbView(geometry: geometry) nameAndDateView .zIndex(10) + .opacity(showDeleteSuccessAlert ? 0.0 : 1.0) + .animation(.easeOut(duration: 0.2), value: showDeleteSuccessAlert) statusTextView + .opacity(showDeleteSuccessAlert ? 0.0 : 1.0) + .animation(.easeOut(duration: 0.2), value: showDeleteSuccessAlert) deleteButton(geometry: geometry) @@ -211,6 +217,8 @@ struct SavedRecordingPlaybackView: View { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { showDeleteSuccessAlert = false + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { showTimeline = true } } From 69c33d0e7c5aedd0981ae4bf90db73e00343402b Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Mon, 1 Dec 2025 03:51:20 +0700 Subject: [PATCH 37/44] feat: add haptic toogle --- .../Audio/AudioPostProcessingManager.swift | 9 +++ Tiny/Core/Services/Audio/HapticManager.swift | 11 +++ .../ViewModels/OrbLiveListenViewModel.swift | 8 ++ .../LiveListen/Views/OrbLiveListenView.swift | 77 +++++++------------ 4 files changed, 55 insertions(+), 50 deletions(-) diff --git a/Tiny/Core/Services/Audio/AudioPostProcessingManager.swift b/Tiny/Core/Services/Audio/AudioPostProcessingManager.swift index bfa5aa7..6f5111e 100644 --- a/Tiny/Core/Services/Audio/AudioPostProcessingManager.swift +++ b/Tiny/Core/Services/Audio/AudioPostProcessingManager.swift @@ -25,6 +25,15 @@ class AudioPostProcessingManager: ObservableObject { @Published var currentTime: TimeInterval = 0 @Published var duration: TimeInterval = 0 @Published var amplitude: Float = 0.0 + + var isHapticsEnabled: Bool { + hapticManager?.isHapticsEnabled ?? false + } + + func toggleHaptics() { + hapticManager?.isHapticsEnabled.toggle() + objectWillChange.send() + } init() { engine = AudioEngine() diff --git a/Tiny/Core/Services/Audio/HapticManager.swift b/Tiny/Core/Services/Audio/HapticManager.swift index 897f8a2..33056e3 100644 --- a/Tiny/Core/Services/Audio/HapticManager.swift +++ b/Tiny/Core/Services/Audio/HapticManager.swift @@ -14,7 +14,16 @@ class HapticManager { 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) + var isHapticsEnabled: Bool { + get { UserDefaults.standard.bool(forKey: "isHapticsEnabled") } + set { UserDefaults.standard.set(newValue, forKey: "isHapticsEnabled") } + } + init() { + // Initialize default value if not set + if UserDefaults.standard.object(forKey: "isHapticsEnabled") == nil { + UserDefaults.standard.set(true, forKey: "isHapticsEnabled") + } prepareHaptics() } @@ -55,6 +64,8 @@ class HapticManager { } func playHapticFromAmplitude(_ amplitude: Float) { + guard isHapticsEnabled else { return } + let now = Date() var shouldTriggerHaptic = false diff --git a/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift b/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift index e45ca0d..1c2656f 100644 --- a/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift +++ b/Tiny/Features/LiveListen/ViewModels/OrbLiveListenViewModel.swift @@ -25,6 +25,14 @@ class OrbLiveListenViewModel: ObservableObject { @Published var orbDragScale: CGFloat = 1.0 @Published var canSaveCurrentRecording = false @Published var currentTime: TimeInterval = 0 + + var isHapticsEnabled: Bool { + audioPostProcessingManager.isHapticsEnabled + } + + func toggleHaptics() { + audioPostProcessingManager.toggleHaptics() + } private var longPressTimer: Timer? private var playbackTimer: Timer? diff --git a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift index 0df771b..749a354 100644 --- a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift +++ b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift @@ -45,15 +45,6 @@ struct OrbLiveListenView: View { // Delete Button (Only visible when dragging up) deleteButton(geometry: geometry) - // Floating Button to Open Timeline manually - if !viewModel.isListening && !viewModel.isDraggingToSave && !viewModel.isDraggingToDelete { - libraryOpenButton(geometry: geometry) - .opacity(viewModel.isDraggingToSave || viewModel.isDraggingToDelete || showSuccessAlert ? 0.0 : 1.0) - .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToSave) - .animation(.easeOut(duration: 0.2), value: viewModel.isDraggingToDelete) - .animation(.easeOut(duration: 0.2), value: showSuccessAlert) - } - coachMarkView // Success Alert (Slide down, no overlay) @@ -133,10 +124,35 @@ struct OrbLiveListenView: View { .clipShape(Circle()) }) .glassEffect(.clear) - .padding(.bottom, 50) .transition(.opacity.animation(.easeInOut)) + + Spacer() + + HStack { + Button { + viewModel.toggleHaptics() + } label: { + Image(systemName: "iphone.gen3.radiowaves.left.and.right") + .font(.body) + .foregroundColor(viewModel.isHapticsEnabled ? .white : .white.opacity(0.4)) + .frame(width: 50, height: 50) + } + .glassEffect(.clear) + + Button { + viewModel.showShareSheet = true + } label: { + Image(systemName: "square.and.arrow.up") + .font(.body) + .foregroundColor(.white) + .frame(width: 50, height: 50) + } + .glassEffect(.clear) + } + .transition(.opacity.animation(.easeInOut)) + } else { + Spacer() } - Spacer() } .padding() Spacer() @@ -144,45 +160,6 @@ struct OrbLiveListenView: View { } } - private func libraryOpenButton(geometry: GeometryProxy) -> some View { - VStack { - HStack { - Spacer() - - if viewModel.isPlaybackMode { - Button { - viewModel.showShareSheet = true - } label: { - Image(systemName: "square.and.arrow.up") - .font(.body) - .foregroundColor(.white) - .frame(width: 50, height: 50) - .clipShape(Circle()) - } - .glassEffect(.clear) - .padding(.bottom, 50) - .transition(.opacity.animation(.easeInOut)) - } - - 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)) From 5e073f2c7214be9fb9b2743925575df4272c7026 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Mon, 1 Dec 2025 04:31:35 +0700 Subject: [PATCH 38/44] fix: room code background --- .../Theme/Models/BackgroundTheme.swift | 13 ++++++++++ .../Room/Views/RoomCodeDisplayView.swift | 25 +++++++++++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/Tiny/Core/Components/Theme/Models/BackgroundTheme.swift b/Tiny/Core/Components/Theme/Models/BackgroundTheme.swift index 9445c06..c507af7 100644 --- a/Tiny/Core/Components/Theme/Models/BackgroundTheme.swift +++ b/Tiny/Core/Components/Theme/Models/BackgroundTheme.swift @@ -29,4 +29,17 @@ enum BackgroundTheme: String, CaseIterable, Identifiable { return "bgBlack" } } + + var color: Color { + switch self { + case .purple: + return Color(hex: "32173F") ?? Color.black + case .pink: + return Color(hex: "3F1738") ?? Color.black + case .blue: + return Color(hex: "19173F") ?? Color.black + case .black: + return Color.black + } + } } diff --git a/Tiny/Features/Room/Views/RoomCodeDisplayView.swift b/Tiny/Features/Room/Views/RoomCodeDisplayView.swift index d4bf99b..3e1549a 100644 --- a/Tiny/Features/Room/Views/RoomCodeDisplayView.swift +++ b/Tiny/Features/Room/Views/RoomCodeDisplayView.swift @@ -6,17 +6,30 @@ // import SwiftUI +import UIKit // Added import for UIScreen struct RoomCodeDisplayView: View { @EnvironmentObject var authService: AuthenticationService + @EnvironmentObject var themeManager: ThemeManager // Add ThemeManager @Environment(\.dismiss) var dismiss @State private var showCopiedMessage = false var body: some View { ZStack { - Color(.systemBackground) + // Dynamic background based on theme + themeManager.selectedBackground.color .ignoresSafeArea() + // Middle-bottom gradient circle + RadialGradient( + gradient: Gradient(colors: [themeManager.selectedBackground.color.opacity(0.8), themeManager.selectedBackground.color]), + center: .bottom, + startRadius: 0, + endRadius: UIScreen.main.bounds.width * 0.3 + ) + .offset(y: UIScreen.main.bounds.height / 3) // Adjust position to be middle-bottom + .ignoresSafeArea() + VStack(spacing: 30) { // Header HStack { @@ -35,10 +48,11 @@ struct RoomCodeDisplayView: View { Spacer() // Icon - Image(systemName: "person.2.circle.fill") - .font(.system(size: 80)) - .foregroundColor(.pink) - + Image("yellowHeart") + .resizable() + .scaledToFit() + .frame(width: 20) + // Title VStack(spacing: 10) { Text(authService.currentUser?.role == .mother ? "Your Room Code" : "Room Code") @@ -134,4 +148,5 @@ struct RoomCodeDisplayView: View { #Preview { RoomCodeDisplayView() .environmentObject(AuthenticationService()) + .environmentObject(ThemeManager()) // Add ThemeManager to the preview } From 40daa608ef189227e7781a4e091d666807366ecd Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Mon, 1 Dec 2025 04:45:16 +0700 Subject: [PATCH 39/44] fix: toogle and share button in saved recorded playback --- .../SavedRecordingPlaybackViewModel.swift | 72 ++++++++++--------- .../Views/SavedRecordingPlaybackView.swift | 10 ++- 2 files changed, 46 insertions(+), 36 deletions(-) diff --git a/Tiny/Features/LiveListen/ViewModels/SavedRecordingPlaybackViewModel.swift b/Tiny/Features/LiveListen/ViewModels/SavedRecordingPlaybackViewModel.swift index f6bf571..be43066 100644 --- a/Tiny/Features/LiveListen/ViewModels/SavedRecordingPlaybackViewModel.swift +++ b/Tiny/Features/LiveListen/ViewModels/SavedRecordingPlaybackViewModel.swift @@ -20,6 +20,7 @@ class SavedRecordingPlaybackViewModel: ObservableObject { @Published var isEditingName = false @Published var showSuccessAlert = false @Published var formattedDate = "" + @Published var showShareSheet = false // Drag state @Published var dragOffset: CGFloat = 0 @@ -27,12 +28,35 @@ class SavedRecordingPlaybackViewModel: ObservableObject { @Published var isDraggingToDelete = false @Published var deleteButtonScale: CGFloat = 1.0 - private var playbackTimer: Timer? private var audioManager: HeartbeatSoundManager? private var currentRecording: Recording? private var modelContext: ModelContext? private var onRecordingUpdated: (() -> Void)? + let audioPostProcessingManager = AudioPostProcessingManager() + private var cancellables = Set() + + var isHapticsEnabled: Bool { + audioPostProcessingManager.isHapticsEnabled + } + + init() { + // Subscribe to audioPostProcessingManager changes to trigger UI updates + audioPostProcessingManager.objectWillChange + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.isPlaying = self.audioPostProcessingManager.isPlaying + self.currentTime = self.audioPostProcessingManager.currentTime + } + .store(in: &cancellables) + } + + func toggleHaptics() { + audioPostProcessingManager.toggleHaptics() + objectWillChange.send() + } + func setupPlayback(for recording: Recording, manager: HeartbeatSoundManager, modelContext: ModelContext, onRecordingUpdated: @escaping () -> Void) { self.audioManager = manager self.currentRecording = recording @@ -66,24 +90,27 @@ class SavedRecordingPlaybackViewModel: ObservableObject { formatter.dateFormat = "d MMMM yyyy" self.formattedDate = formatter.string(from: recording.createdAt) - // Start playback - manager.togglePlayback(recording: recording) - startPlaybackTimer(manager: manager) + // Stop any existing playback in manager + manager.stop() + + // Start playback with AudioPostProcessingManager + audioPostProcessingManager.loadAndPlay(fileURL: recording.fileURL) } func togglePlayback(manager: HeartbeatSoundManager, recording: Recording) { - manager.togglePlayback(recording: recording) - - if manager.isPlayingPlayback { - startPlaybackTimer(manager: manager) + if audioPostProcessingManager.isPlaying { + audioPostProcessingManager.pause() } else { - stopPlaybackTimer() + if audioPostProcessingManager.currentTime > 0 { + audioPostProcessingManager.resume() + } else { + audioPostProcessingManager.loadAndPlay(fileURL: recording.fileURL) + } } } func cleanup() { - stopPlaybackTimer() - audioManager?.player?.stop() + audioPostProcessingManager.stop() } func handleDragChange(value: DragGesture.Value, geometry: GeometryProxy) { @@ -134,21 +161,6 @@ class SavedRecordingPlaybackViewModel: ObservableObject { } } - private func startPlaybackTimer(manager: HeartbeatSoundManager) { - stopPlaybackTimer() - isPlaying = true - - playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self, weak manager] _ in - guard let self = self, let manager = manager else { return } - - self.isPlaying = manager.isPlayingPlayback - - if !manager.isPlayingPlayback { - self.stopPlaybackTimer() - } - } - } - func startEditing() { isEditingName = true } @@ -226,10 +238,4 @@ class SavedRecordingPlaybackViewModel: ObservableObject { print("❌ Error saving recording name: \(error)") } } - - private func stopPlaybackTimer() { - playbackTimer?.invalidate() - playbackTimer = nil - isPlaying = false - } -} +} \ No newline at end of file diff --git a/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift b/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift index ffecc37..c7b5793 100644 --- a/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift +++ b/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift @@ -88,7 +88,9 @@ struct SavedRecordingPlaybackView: View { } } .ignoresSafeArea() - + .sheet(isPresented: $viewModel.showShareSheet) { + ShareSheet(activityItems: [recording.fileURL]) + } } .onAppear { viewModel.setupPlayback( @@ -157,15 +159,17 @@ struct SavedRecordingPlaybackView: View { // Normal buttons HStack { Button { + viewModel.toggleHaptics() } label: { Image(systemName: "iphone.gen3.radiowaves.left.and.right") .font(.body) - .foregroundColor(.white) + .foregroundColor(viewModel.isHapticsEnabled ? .white : .white.opacity(0.4)) .frame(width: 48, height: 48) } .glassEffect(.clear) Button { + viewModel.showShareSheet = true } label: { Image(systemName: "square.and.arrow.up") .font(.body) @@ -355,4 +359,4 @@ struct SavedRecordingPlaybackView: View { showTimeline: .constant(false) ) .environmentObject(ThemeManager()) -} +} \ No newline at end of file From 0d8ed6c4d584d22925940755667cc4830b345066 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Mon, 1 Dec 2025 04:58:54 +0700 Subject: [PATCH 40/44] fix: add duration in saved recorded playback --- .../ViewModels/SavedRecordingPlaybackViewModel.swift | 4 +++- .../LiveListen/Views/SavedRecordingPlaybackView.swift | 9 ++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Tiny/Features/LiveListen/ViewModels/SavedRecordingPlaybackViewModel.swift b/Tiny/Features/LiveListen/ViewModels/SavedRecordingPlaybackViewModel.swift index be43066..7d6b6b0 100644 --- a/Tiny/Features/LiveListen/ViewModels/SavedRecordingPlaybackViewModel.swift +++ b/Tiny/Features/LiveListen/ViewModels/SavedRecordingPlaybackViewModel.swift @@ -15,6 +15,7 @@ import SwiftData class SavedRecordingPlaybackViewModel: ObservableObject { @Published var isPlaying = false @Published var currentTime: TimeInterval = 0 + @Published var duration: TimeInterval = 0 @Published var recordingName = "Heartbeat Recording" @Published var editedName = "Heartbeat Recording" @Published var isEditingName = false @@ -48,6 +49,7 @@ class SavedRecordingPlaybackViewModel: ObservableObject { guard let self = self else { return } self.isPlaying = self.audioPostProcessingManager.isPlaying self.currentTime = self.audioPostProcessingManager.currentTime + self.duration = self.audioPostProcessingManager.duration } .store(in: &cancellables) } @@ -238,4 +240,4 @@ class SavedRecordingPlaybackViewModel: ObservableObject { print("❌ Error saving recording name: \(error)") } } -} \ No newline at end of file +} diff --git a/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift b/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift index c7b5793..e067f50 100644 --- a/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift +++ b/Tiny/Features/LiveListen/Views/SavedRecordingPlaybackView.swift @@ -216,6 +216,7 @@ struct SavedRecordingPlaybackView: View { } // Then delete after alert is visible DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + viewModel.cleanup() heartbeatSoundManager.deleteRecording(recording) // Navigate after another delay DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -243,6 +244,12 @@ struct SavedRecordingPlaybackView: View { .font(.system(size: 14)) .foregroundColor(.white.opacity(0.7)) + if viewModel.duration > 0 && !viewModel.isDraggingToDelete { + Text("\(Int(viewModel.currentTime))s / \(Int(viewModel.duration))s") + .font(.caption) + .foregroundColor(.white.opacity(0.7)) + } + if !viewModel.isDraggingToDelete { Text("Drag up to delete") .font(.caption) @@ -359,4 +366,4 @@ struct SavedRecordingPlaybackView: View { showTimeline: .constant(false) ) .environmentObject(ThemeManager()) -} \ No newline at end of file +} From 947d2fe3803a9aad97da7c23beeb6b6965d9f196 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Mon, 1 Dec 2025 05:33:57 +0700 Subject: [PATCH 41/44] fix: add background in room code display --- .../Theme/Models/BackgroundTheme.swift | 2 +- .../Room/Views/RoomCodeDisplayView.swift | 152 +++++++++--------- Tiny/Resources/Localizable.xcstrings | 8 - 3 files changed, 79 insertions(+), 83 deletions(-) diff --git a/Tiny/Core/Components/Theme/Models/BackgroundTheme.swift b/Tiny/Core/Components/Theme/Models/BackgroundTheme.swift index c507af7..14be2e9 100644 --- a/Tiny/Core/Components/Theme/Models/BackgroundTheme.swift +++ b/Tiny/Core/Components/Theme/Models/BackgroundTheme.swift @@ -33,7 +33,7 @@ enum BackgroundTheme: String, CaseIterable, Identifiable { var color: Color { switch self { case .purple: - return Color(hex: "32173F") ?? Color.black + return Color(hex: "32173F") ?? Color.black case .pink: return Color(hex: "3F1738") ?? Color.black case .blue: diff --git a/Tiny/Features/Room/Views/RoomCodeDisplayView.swift b/Tiny/Features/Room/Views/RoomCodeDisplayView.swift index 3e1549a..ee733f7 100644 --- a/Tiny/Features/Room/Views/RoomCodeDisplayView.swift +++ b/Tiny/Features/Room/Views/RoomCodeDisplayView.swift @@ -6,31 +6,36 @@ // import SwiftUI -import UIKit // Added import for UIScreen +import UIKit struct RoomCodeDisplayView: View { @EnvironmentObject var authService: AuthenticationService - @EnvironmentObject var themeManager: ThemeManager // Add ThemeManager + @EnvironmentObject var themeManager: ThemeManager @Environment(\.dismiss) var dismiss @State private var showCopiedMessage = false - + var body: some View { ZStack { - // Dynamic background based on theme - themeManager.selectedBackground.color - .ignoresSafeArea() - - // Middle-bottom gradient circle - RadialGradient( - gradient: Gradient(colors: [themeManager.selectedBackground.color.opacity(0.8), themeManager.selectedBackground.color]), - center: .bottom, - startRadius: 0, - endRadius: UIScreen.main.bounds.width * 0.3 - ) - .offset(y: UIScreen.main.bounds.height / 3) // Adjust position to be middle-bottom - .ignoresSafeArea() - - VStack(spacing: 30) { + Color(hex: "030411").ignoresSafeArea() + + // Bottom circular glow + Circle() + .fill( + RadialGradient( + gradient: Gradient(colors: [ + themeManager.selectedBackground.color.opacity(1), + themeManager.selectedBackground.color.opacity(0) + ]), + center: .center, + startRadius: 0, + endRadius: 600 + ) + ) + .frame(width: 200, height: 200) + .blur(radius: 60) + .offset(y: 120) // Near bottom + + VStack(spacing: 10) { // Header HStack { Spacer() @@ -44,66 +49,54 @@ struct RoomCodeDisplayView: View { } .padding(.horizontal) .padding(.top, 10) - + Spacer() - + // Icon Image("yellowHeart") .resizable() .scaledToFit() - .frame(width: 20) + .frame(width: 30) // Title - VStack(spacing: 10) { + VStack(spacing: 5) { Text(authService.currentUser?.role == .mother ? "Your Room Code" : "Room Code") .font(.title2) .fontWeight(.bold) - - Text(authService.currentUser?.role == .mother ? - "Share this code with your partner" : - "You're connected to this room") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 40) + + Text(authService.currentUser?.role == .mother ? + "Share this code with your partner" : + "You're connected to this room") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) } - + // Room Code Display if let roomCode = authService.currentUser?.roomCode { - VStack(spacing: 15) { - Text(roomCode) - .font(.system(size: 48, weight: .bold, design: .rounded)) - .tracking(8) - .foregroundColor(.primary) - .padding(.vertical, 20) - .padding(.horizontal, 40) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(Color(.systemGray6)) - ) - - // Copy Button - Button(action: copyRoomCode) { - HStack(spacing: 8) { - Image(systemName: showCopiedMessage ? "checkmark" : "doc.on.doc") - .font(.system(size: 16)) - Text(showCopiedMessage ? "Copied!" : "Copy Code") - .fontWeight(.medium) - } - .foregroundColor(showCopiedMessage ? .green : .blue) - .padding(.horizontal, 24) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(showCopiedMessage ? Color.green.opacity(0.1) : Color.blue.opacity(0.1)) - ) + Button(action: copyRoomCode) { + HStack { + Text(roomCode) + .font(.system(size: 18, weight: .heavy, design: .rounded)) + .tracking(3) + .foregroundColor(.white) + Image(systemName: "doc.on.doc") + .foregroundColor(.white) } + .padding(.vertical, 20) + .padding(.horizontal, 40) + .background( + // Frosted capsule background + Capsule() + .fill(.ultraThinMaterial) // frosted glass effect + .opacity(0.8) + ) } } else { VStack(spacing: 15) { if authService.currentUser?.role == .mother { - ProgressView() - .padding() + ProgressView().padding() Text("Creating room...") .font(.subheadline) .foregroundColor(.secondary) @@ -111,7 +104,6 @@ struct RoomCodeDisplayView: View { Text("No room code") .font(.subheadline) .foregroundColor(.secondary) - Text("Ask your partner for their room code") .font(.caption) .foregroundColor(.secondary) @@ -120,26 +112,17 @@ struct RoomCodeDisplayView: View { } } } - - Spacer() Spacer() } } } - + private func copyRoomCode() { if let roomCode = authService.currentUser?.roomCode { UIPasteboard.general.string = roomCode - - withAnimation { - showCopiedMessage = true - } - - // Reset after 2 seconds + withAnimation { showCopiedMessage = true } DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - withAnimation { - showCopiedMessage = false - } + withAnimation { showCopiedMessage = false } } } } @@ -148,5 +131,26 @@ struct RoomCodeDisplayView: View { #Preview { RoomCodeDisplayView() .environmentObject(AuthenticationService()) - .environmentObject(ThemeManager()) // Add ThemeManager to the preview + .environmentObject(ThemeManager()) +} + +#Preview { + // Create a mock AuthenticationService with a sample user + let authService = AuthenticationService() + authService.currentUser = User( + id: "1", + email: "mother@example.com", + name: "Jane Doe", + role: .mother, + pregnancyWeeks: 20, + roomCode: "ABCD12", // Sample room code + createdAt: Date() + ) + + let themeManager = ThemeManager() + + return RoomCodeDisplayView() + .environmentObject(authService) + .environmentObject(themeManager) + .preferredColorScheme(.dark) // Optional: show dark mode preview } diff --git a/Tiny/Resources/Localizable.xcstrings b/Tiny/Resources/Localizable.xcstrings index 03eec97..5ecc526 100644 --- a/Tiny/Resources/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -200,14 +200,6 @@ }, "Control your screen with a simple gesture:" : { - }, - "Copied!" : { - "comment" : "A confirmation message displayed when the room code is successfully copied to the clipboard.", - "isCommentAutoGenerated" : true - }, - "Copy Code" : { - "comment" : "A button label that says \"Copy Code\".", - "isCommentAutoGenerated" : true }, "Creating room..." : { "comment" : "A message displayed while a room is being created.", From a830f2ebe6abb138e1b5f6e474bf4f4739dd36ed Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Mon, 1 Dec 2025 05:40:24 +0700 Subject: [PATCH 42/44] fix: show copied status --- .../Room/Views/RoomCodeDisplayView.swift | 44 ++++++++++++------- Tiny/Resources/Localizable.xcstrings | 4 ++ 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/Tiny/Features/Room/Views/RoomCodeDisplayView.swift b/Tiny/Features/Room/Views/RoomCodeDisplayView.swift index ee733f7..09c4b9b 100644 --- a/Tiny/Features/Room/Views/RoomCodeDisplayView.swift +++ b/Tiny/Features/Room/Views/RoomCodeDisplayView.swift @@ -75,23 +75,37 @@ struct RoomCodeDisplayView: View { // Room Code Display if let roomCode = authService.currentUser?.roomCode { - Button(action: copyRoomCode) { - HStack { - Text(roomCode) - .font(.system(size: 18, weight: .heavy, design: .rounded)) - .tracking(3) - .foregroundColor(.white) - Image(systemName: "doc.on.doc") + ZStack { + Button(action: copyRoomCode) { + HStack { + Text(roomCode) + .font(.system(size: 18, weight: .heavy, design: .rounded)) + .tracking(3) + .foregroundColor(.white) + Image(systemName: "doc.on.doc") + .foregroundColor(.white) + } + .padding(.vertical, 20) + .padding(.horizontal, 40) + .background( + // Frosted capsule background + Capsule() + .fill(.ultraThinMaterial) // frosted glass effect + .opacity(0.8) + ) + } + + if showCopiedMessage { + Text("Copied!") + .font(.caption) + .fontWeight(.bold) .foregroundColor(.white) + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background(Capsule().fill(Color.black.opacity(0.6))) + .offset(y: 50) // Position below the button + .transition(.opacity) } - .padding(.vertical, 20) - .padding(.horizontal, 40) - .background( - // Frosted capsule background - Capsule() - .fill(.ultraThinMaterial) // frosted glass effect - .opacity(0.8) - ) } } else { VStack(spacing: 15) { diff --git a/Tiny/Resources/Localizable.xcstrings b/Tiny/Resources/Localizable.xcstrings index 5ecc526..8f5024d 100644 --- a/Tiny/Resources/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -200,6 +200,10 @@ }, "Control your screen with a simple gesture:" : { + }, + "Copied!" : { + "comment" : "A message displayed when the user successfully copies the room code.", + "isCommentAutoGenerated" : true }, "Creating room..." : { "comment" : "A message displayed while a room is being created.", From a3eb791d533040189bdc9f856353309e912e8b7c Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Mon, 1 Dec 2025 11:24:07 +0700 Subject: [PATCH 43/44] fix: delete heart and add disclaimer --- .../peryWinkle.colorset/Contents.json | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 Tiny/Resources/Assets.xcassets/peryWinkle.colorset/Contents.json diff --git a/Tiny/Resources/Assets.xcassets/peryWinkle.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/peryWinkle.colorset/Contents.json new file mode 100644 index 0000000..b050381 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/peryWinkle.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xCC", + "red" : "0xCC" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xCC", + "red" : "0xCC" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} From 80de572e80bc9ae2a1812cd33456544297177104 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Mon, 1 Dec 2025 11:30:11 +0700 Subject: [PATCH 44/44] fix: coachmark position in orbLiveListenView --- .../LiveListen/Views/OrbLiveListenView.swift | 2 +- .../Onboarding/Views/OnboardingView.swift | 45 +++++++++++-------- Tiny/Resources/Localizable.xcstrings | 4 ++ 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift index 749a354..6c95853 100644 --- a/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift +++ b/Tiny/Features/LiveListen/Views/OrbLiveListenView.swift @@ -329,7 +329,7 @@ struct OrbLiveListenView: View { if !viewModel.isListening && !viewModel.isPlaybackMode { GeometryReader { proxy in CoachMarkView() - .position(x: proxy.size.width / 2, y: proxy.size.height / 2 + 250) + .position(x: proxy.size.width / 2, y: proxy.size.height / 2 + 230) } .transition(.opacity) } diff --git a/Tiny/Features/Onboarding/Views/OnboardingView.swift b/Tiny/Features/Onboarding/Views/OnboardingView.swift index 1d1c4d0..0fd9b33 100644 --- a/Tiny/Features/Onboarding/Views/OnboardingView.swift +++ b/Tiny/Features/Onboarding/Views/OnboardingView.swift @@ -103,25 +103,25 @@ struct OnBoardingView: View { // 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) - ) +// 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) { @@ -238,6 +238,13 @@ private struct OnboardingPage1: View { .fontWeight(.regular) .multilineTextAlignment(.center) .padding(.horizontal, 30) + + Text("Tiny isn’t a medical app") + .font(.caption) + .foregroundStyle(.peryWinkle) + .fontWeight(.regular) + .italic() + .padding(.horizontal, 30) } } } diff --git a/Tiny/Resources/Localizable.xcstrings b/Tiny/Resources/Localizable.xcstrings index 8f5024d..1972be0 100644 --- a/Tiny/Resources/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -724,6 +724,10 @@ "comment" : "A title displayed at the top of the page.", "isCommentAutoGenerated" : true }, + "Tiny isn’t a medical app" : { + "comment" : "A footnote explaining that Tiny is not a medical app.", + "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