From 882040f1d2742a6aa3d9204e4402d96235ccd16f Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 13:43:25 +0700 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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 09/10] 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 10/10] 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)