From 701b03417d0a26353b7fca028a26005e225314fb Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 13:43:25 +0700 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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 +}