From 850679f5649389397d53ab651355d226533772c9 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Wed, 26 Nov 2025 23:59:41 +0700 Subject: [PATCH 1/5] feat: add profile screen --- .../Profile/ViewModels/ProfileViewModel.swift | 119 +++++ Tiny/Features/Profile/Views/ProfileView.swift | 477 ++++++++++++++++++ .../mainViolet.colorset/Contents.json | 38 ++ .../rowProfileGrey.colorset/Contents.json | 38 ++ 4 files changed, 672 insertions(+) create mode 100644 Tiny/Features/Profile/ViewModels/ProfileViewModel.swift create mode 100644 Tiny/Features/Profile/Views/ProfileView.swift create mode 100644 Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json create mode 100644 Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json diff --git a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift new file mode 100644 index 0000000..71f2b09 --- /dev/null +++ b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift @@ -0,0 +1,119 @@ +// +// ProfileViewModel.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 26/11/25. +// + +import SwiftUI +internal import Combine + +@MainActor +class ProfileViewModel: ObservableObject { + @Published var isSignedIn: Bool = false + @Published var profileImage: UIImage? + @Published var userName: String = "Guest" + @Published var userEmail: String? + + // Settings + @AppStorage("appTheme") var appTheme: String = "System" + @AppStorage("isUserSignedIn") private var storedSignInStatus: Bool = false + @AppStorage("savedUserName") private var savedUserName: String? + @AppStorage("savedUserEmail") private var savedUserEmail: String? + + private var cancellables = Set() + + init() { + loadUserData() + } + + // MARK: - Data Persistence + + private func loadUserData() { + isSignedIn = storedSignInStatus + + if let savedName = savedUserName { + userName = savedName + } + + if let savedEmail = savedUserEmail { + userEmail = savedEmail + } + + loadProfileImageFromDisk() + } + + func saveName() { + savedUserName = userName + print("Name saved: \(userName)") + } + + private func saveProfileImageToDisk() { + guard let image = profileImage, + let data = image.jpegData(compressionQuality: 0.8) else { return } + + let fileURL = getProfileImageURL() + try? data.write(to: fileURL) + } + + private func loadProfileImageFromDisk() { + let fileURL = getProfileImageURL() + guard let data = try? Data(contentsOf: fileURL), + let image = UIImage(data: data) else { return } + + profileImage = image + } + + private func deleteProfileImageFromDisk() { + let fileURL = getProfileImageURL() + try? FileManager.default.removeItem(at: fileURL) + } + + private func getProfileImageURL() -> URL { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return documentsPath.appendingPathComponent("profileImage.jpg") + } + + // MARK: - Authentication (Dummy Implementation) + + /// Dummy sign in - Replace this with real Apple Sign In implementation + func signIn() { + // TODO: Implement real Apple Sign In here + // This is a placeholder for demonstration + isSignedIn = true + userName = "John Doe" + userEmail = "john.doe@example.com" + + // Persist sign in status + storedSignInStatus = true + savedUserName = userName + savedUserEmail = userEmail + + print("User signed in (dummy)") + } + + /// Sign out user and clear all data + func signOut() { + isSignedIn = false + profileImage = nil + userName = "Guest" + userEmail = nil + + // Clear persisted data + storedSignInStatus = false + savedUserName = nil + savedUserEmail = nil + deleteProfileImageFromDisk() + + print("User signed out") + } + + // MARK: - Profile Image Management + + func updateProfileImage(_ image: UIImage?) { + profileImage = image + if image != nil { + saveProfileImageToDisk() + } + } +} diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift new file mode 100644 index 0000000..dc3870b --- /dev/null +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -0,0 +1,477 @@ +// +// ProfileView.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 26/11/25. +// + +import SwiftUI + +struct ProfileView: View { + @StateObject private var viewModel = ProfileViewModel() + @State private var showingSignOutConfirmation = false + + var body: some View { + NavigationStack { + ZStack { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .ignoresSafeArea() + + VStack(spacing: 0) { + // HEADER + profileHeader + .padding(.bottom, 30) + + // FEATURE CARDS + featureCards + .padding(.horizontal, 16) + .frame(height: 160) + + // SETTINGS LIST + settingsList + } + } + } + } + + // MARK: - View Components + + private var profileHeader: some View { + VStack(spacing: 16) { + GeometryReader { geo in + let size = geo.size.width * 0.28 + + VStack(spacing: 16) { + Spacer() + + NavigationLink { + ProfilePhotoDetailView(viewModel: viewModel) + } label: { + profileImageView(size: size) + } + .buttonStyle(.plain) + + Text(viewModel.isSignedIn ? viewModel.userName : "Guest") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(.white) + } + .frame(width: geo.size.width) + } + .frame(height: 260) + } + } + + private func profileImageView(size: CGFloat) -> some View { + Group { + if let img = viewModel.profileImage { + Image(uiImage: img) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.gray) + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + } + + private var featureCards: some View { + HStack(spacing: 12) { + let padding: CGFloat = 5 + let spacing: CGFloat = 12 + let cardWidth = (UIScreen.main.bounds.width - (padding * 5 + spacing)) / 2 + + featureCardLeft(width: cardWidth) + featureCardRight(width: cardWidth) + } + } + + private var settingsList: some View { + List { + settingsSection + accountSection + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(Color.clear) + } + + private var settingsSection: some View { + Section { + NavigationLink(destination: ThemeDummy()) { + Label("Theme", systemImage: "paintpalette.fill") + .foregroundStyle(.white) + } + NavigationLink(destination: TutorialDummy()) { + Label("Tutorial", systemImage: "book.fill") + .foregroundStyle(.white) + } + Link(destination: URL(string: "https://example.com/privacy")!) { + Label("Privacy Policy", systemImage: "shield.righthalf.filled") + .foregroundStyle(.white) + } + Link(destination: URL(string: "https://example.com/terms")!) { + Label("Terms and Conditions", systemImage: "doc.text") + .foregroundStyle(.white) + } + } + .listRowBackground(Color("rowProfileGrey")) + } + + private var accountSection: some View { + Section { + if viewModel.isSignedIn { + signedInView + } else { + signInView + } + } header: { + Text("Account") + } footer: { + if !viewModel.isSignedIn { + Text("Your privacy is protected. We only use your Apple ID to securely save your data.") + .font(.caption2) + } + } + .listRowBackground(Color("rowProfileGrey")) + } + + private var signedInView: some View { + Group { + Button(role: .destructive) { + showingSignOutConfirmation = true + } label: { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + .foregroundStyle(.red) + } + .confirmationDialog( + "Sign Out", + isPresented: $showingSignOutConfirmation, + titleVisibility: .visible + ) { + Button("Sign Out", role: .destructive) { + viewModel.signOut() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("You'll need to sign in again to sync your data and access personalized features.") + } + } + } + + private var signInView: some View { + VStack(spacing: 12) { + // Dummy Sign In Button styled like Apple's + Button { + viewModel.signIn() + } label: { + HStack { + Image(systemName: "applelogo") + .font(.system(size: 20, weight: .medium)) + Text("Sign in with Apple") + .font(.system(size: 17, weight: .semibold)) + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .foregroundStyle(.black) + .background(Color.white) + .cornerRadius(8) + } + .buttonStyle(.plain) + + Text("Sign in to sync your pregnancy journey across devices") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.vertical, 8) + } + + // MARK: - Feature Cards + + private func featureCardLeft(width: CGFloat) -> some View { + VStack(alignment: .leading) { + HStack { + Image(systemName: "heart.fill") + .font(.caption) + .foregroundColor(.white) + Text("Connected Journey") + .font(.caption) + .foregroundColor(.gray) + } + + Spacer() + + Text("Connect with Your Partner") + .font(.subheadline) + .foregroundColor(.blue) + .fontWeight(.medium) + + Spacer() + } + .frame(width: width, height: width * 0.63, alignment: .topLeading) + .padding() + .background(Color("rowProfileGrey")) + .cornerRadius(14) + } + + private func featureCardRight(width: CGFloat) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "calendar") + .font(.caption) + .foregroundColor(.white) + Text("Pregnancy Age") + .font(.caption) + .foregroundColor(.gray) + } + + Spacer() + + VStack(alignment: .center, spacing: 4) { + Text("20") + .font(.title) + .fontWeight(.bold) + .foregroundColor(Color("mainViolet")) + Text("Weeks") + .font(.subheadline) + .foregroundColor(Color("mainViolet")) + } + .frame(maxWidth: .infinity, alignment: .center) + + Spacer() + } + .frame(width: width * 0.65, height: width * 0.63) + .padding() + .background(Color("rowProfileGrey")) + .cornerRadius(14) + } +} + +struct ProfilePhotoDetailView: View { + @ObservedObject var viewModel: ProfileViewModel + @State private var showingImagePicker = false + @State private var showingCamera = false + @State private var showingPhotoOptions = false + @State private var tempUserName: String = "" + @Environment(\.dismiss) private var dismiss + + var body: some View { + ZStack { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .ignoresSafeArea() + + VStack(spacing: 30) { + profilePhotoButton + .padding(.top, 80) + + nameEditSection + .padding(.horizontal, 30) + + Spacer() + } + } + .navigationTitle("Edit Profile") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + viewModel.userName = tempUserName + viewModel.saveName() + dismiss() + } + .disabled(tempUserName.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .onAppear { + tempUserName = viewModel.userName + } + .sheet(isPresented: $showingPhotoOptions) { + BottomPhotoPickerSheet( + showingCamera: $showingCamera, + showingImagePicker: $showingImagePicker + ) + } + .sheet(isPresented: $showingImagePicker) { + ImagePicker(image: $viewModel.profileImage, sourceType: .photoLibrary) + } + .fullScreenCover(isPresented: $showingCamera) { + ImagePicker(image: $viewModel.profileImage, sourceType: .camera) + } + } + + private var profilePhotoButton: some View { + Button { + showingPhotoOptions = true + } label: { + ZStack(alignment: .bottomTrailing) { + Group { + if let profileImage = viewModel.profileImage { + Image(uiImage: profileImage) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.gray) + } + } + .frame(width: 200, height: 200) + .clipShape(Circle()) + .overlay( + Circle() + .stroke(Color.white, lineWidth: 3) + ) + + // Camera badge + Image(systemName: "camera.circle.fill") + .font(.system(size: 44)) + .foregroundStyle(.white, Color("rowProfileGrey")) + .offset(x: -10, y: -10) + } + } + .buttonStyle(.plain) + } + + private var nameEditSection: some View { + VStack(spacing: 16) { + HStack { + Text("Name") + .foregroundColor(.gray) + .font(.subheadline) + Spacer() + } + + TextField("Enter your name", text: $tempUserName) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.words) + .submitLabel(.done) + } + } +} + +struct BottomPhotoPickerSheet: View { + @Binding var showingCamera: Bool + @Binding var showingImagePicker: Bool + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 20) { + Text("Change Profile Photo") + .font(.headline) + .foregroundColor(.primary) + .padding(.bottom, 8) + + PhotoPickerButton( + title: "Take Photo", + icon: "camera", + action: { + dismiss() + showingCamera = true + } + ) + + PhotoPickerButton( + title: "Choose From Library", + icon: "photo.on.rectangle", + action: { + dismiss() + showingImagePicker = true + } + ) + } + .padding() + .presentationDetents([.height(220)]) + .presentationDragIndicator(.visible) + } +} + +private struct PhotoPickerButton: View { + let title: String + let icon: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + Image(systemName: icon) + .font(.system(size: 18)) + Text(title) + .font(.system(size: 16, weight: .medium)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(Color("rowProfileGrey")) + .cornerRadius(12) + } + .buttonStyle(.plain) + } +} + +struct ImagePicker: UIViewControllerRepresentable { + @Binding var image: UIImage? + let sourceType: UIImagePickerController.SourceType + @Environment(\.dismiss) var dismiss + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = sourceType + picker.delegate = context.coordinator + picker.allowsEditing = false + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let parent: ImagePicker + + init(_ parent: ImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + if let image = info[.originalImage] as? UIImage { + parent.image = image + } + parent.dismiss() + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.dismiss() + } + } +} + +struct ThemeDummy: View { + var body: some View { + Text("Dummy Theme View") + .navigationTitle("Theme") + } +} + +struct TutorialDummy: View { + var body: some View { + Text("Dummy Tutorial View") + .navigationTitle("Tutorial") + } +} + +#Preview { + ProfileView() + .preferredColorScheme(.dark) +} diff --git a/Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json new file mode 100644 index 0000000..7d97c94 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/mainViolet.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE8", + "green" : "0x95", + "red" : "0x95" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE8", + "green" : "0x95", + "red" : "0x95" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json b/Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json new file mode 100644 index 0000000..c195a24 --- /dev/null +++ b/Tiny/Resources/Assets.xcassets/rowProfileGrey.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x28", + "green" : "0x21", + "red" : "0x21" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x28", + "green" : "0x21", + "red" : "0x21" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} From 1fbb098183b69899454f3841b794bc998c8719d1 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 00:24:00 +0700 Subject: [PATCH 2/5] feat: add profile navigation in PregnancyTimelineView --- Tiny.xcodeproj/project.pbxproj | 6 + .../Profile/Models/UserProfileManager.swift | 106 ++++++++++++++ .../Profile/ViewModels/ProfileViewModel.swift | 135 ++++++------------ Tiny/Features/Profile/Views/ProfileView.swift | 42 +++--- .../Views/PregnancyTimelineView.swift | 76 +++++++--- Tiny/Info.plist | 7 +- Tiny/Resources/Localizable.xcstrings | 84 +++++++++++ 7 files changed, 321 insertions(+), 135 deletions(-) create mode 100644 Tiny/Features/Profile/Models/UserProfileManager.swift diff --git a/Tiny.xcodeproj/project.pbxproj b/Tiny.xcodeproj/project.pbxproj index 97285b3..9823b13 100644 --- a/Tiny.xcodeproj/project.pbxproj +++ b/Tiny.xcodeproj/project.pbxproj @@ -472,8 +472,11 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Tiny/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Tiny; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; + INFOPLIST_KEY_NSCameraUsageDescription = "Allow Tiny to use camera for taking profile picture"; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Tiny needs to use the microphone to capture heartbeat sounds."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Allow Tiny to access photos library"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -513,8 +516,11 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Tiny/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Tiny; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.lifestyle"; + INFOPLIST_KEY_NSCameraUsageDescription = "Allow Tiny to use camera for taking profile picture"; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Tiny needs to use the microphone to capture heartbeat sounds."; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Allow Tiny to access photos library"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/Tiny/Features/Profile/Models/UserProfileManager.swift b/Tiny/Features/Profile/Models/UserProfileManager.swift new file mode 100644 index 0000000..60a0209 --- /dev/null +++ b/Tiny/Features/Profile/Models/UserProfileManager.swift @@ -0,0 +1,106 @@ +// +// UserProfileManager.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 27/11/25. +// + +import SwiftUI +internal import Combine + +class UserProfileManager: ObservableObject { + static let shared = UserProfileManager() + + @Published var profileImage: UIImage? + @Published var userName: String = "Guest" + @Published var userEmail: String? + @Published var isSignedIn: Bool = false + + // Persistence Keys + private let kUserName = "savedUserName" + private let kUserEmail = "savedUserEmail" + private let kIsSignedIn = "isUserSignedIn" + + private init() { + loadUserData() + } + + // MARK: - Data Persistence + + func loadUserData() { + let defaults = UserDefaults.standard + isSignedIn = defaults.bool(forKey: kIsSignedIn) + + if let savedName = defaults.string(forKey: kUserName) { + userName = savedName + } + + if let savedEmail = defaults.string(forKey: kUserEmail) { + userEmail = savedEmail + } + + loadProfileImageFromDisk() + } + + func saveUserData() { + let defaults = UserDefaults.standard + defaults.set(isSignedIn, forKey: kIsSignedIn) + defaults.set(userName, forKey: kUserName) + defaults.set(userEmail, forKey: kUserEmail) + } + + func saveProfileImage(_ image: UIImage?) { + profileImage = image + + if let image = image { + saveProfileImageToDisk(image) + } else { + deleteProfileImageFromDisk() + } + } + + private func saveProfileImageToDisk(_ image: UIImage) { + guard let data = image.jpegData(compressionQuality: 0.8) else { return } + let fileURL = getProfileImageURL() + try? data.write(to: fileURL) + } + + private func loadProfileImageFromDisk() { + let fileURL = getProfileImageURL() + guard let data = try? Data(contentsOf: fileURL), + let image = UIImage(data: data) else { + profileImage = nil + return + } + profileImage = image + } + + private func deleteProfileImageFromDisk() { + let fileURL = getProfileImageURL() + try? FileManager.default.removeItem(at: fileURL) + } + + private func getProfileImageURL() -> URL { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return documentsPath.appendingPathComponent("profileImage.jpg") + } + + // MARK: - Actions + + func signOut() { + isSignedIn = false + userName = "Guest" + userEmail = nil + profileImage = nil + + saveUserData() + deleteProfileImageFromDisk() + } + + func signInDummy() { + isSignedIn = true + userName = "John Doe" + userEmail = "john.doe@example.com" + saveUserData() + } +} diff --git a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift index 71f2b09..0bb02bb 100644 --- a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift +++ b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift @@ -8,112 +8,69 @@ import SwiftUI internal import Combine -@MainActor -class ProfileViewModel: ObservableObject { - @Published var isSignedIn: Bool = false - @Published var profileImage: UIImage? - @Published var userName: String = "Guest" - @Published var userEmail: String? +// +// ProfileViewModel.swift +// Tiny +// +// Created by Destu Cikal Ramdani on 26/11/25. +// - // Settings - @AppStorage("appTheme") var appTheme: String = "System" - @AppStorage("isUserSignedIn") private var storedSignInStatus: Bool = false - @AppStorage("savedUserName") private var savedUserName: String? - @AppStorage("savedUserEmail") private var savedUserEmail: String? +import SwiftUI +internal import Combine +@MainActor +class ProfileViewModel: ObservableObject { + // Observe the singleton manager so this ViewModel publishes changes when manager changes + var manager = UserProfileManager.shared private var cancellables = Set() - + + @AppStorage("appTheme") var appTheme: String = "System" + init() { - loadUserData() - } - - // MARK: - Data Persistence - - private func loadUserData() { - isSignedIn = storedSignInStatus - - if let savedName = savedUserName { - userName = savedName - } - - if let savedEmail = savedUserEmail { - userEmail = savedEmail - } - - loadProfileImageFromDisk() + // Propagate manager changes to this ViewModel + manager.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) } - - func saveName() { - savedUserName = userName - print("Name saved: \(userName)") + + // MARK: - Computed Properties for View Bindings + + var isSignedIn: Bool { + manager.isSignedIn } - - private func saveProfileImageToDisk() { - guard let image = profileImage, - let data = image.jpegData(compressionQuality: 0.8) else { return } - - let fileURL = getProfileImageURL() - try? data.write(to: fileURL) + + var userName: String { + get { manager.userName } + set { manager.userName = newValue } } - - private func loadProfileImageFromDisk() { - let fileURL = getProfileImageURL() - guard let data = try? Data(contentsOf: fileURL), - let image = UIImage(data: data) else { return } - - profileImage = image + + var userEmail: String? { + manager.userEmail } - - private func deleteProfileImageFromDisk() { - let fileURL = getProfileImageURL() - try? FileManager.default.removeItem(at: fileURL) + + // For ImagePicker binding + var profileImage: UIImage? { + get { manager.profileImage } + set { manager.saveProfileImage(newValue) } } + + // MARK: - Actions - private func getProfileImageURL() -> URL { - let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - return documentsPath.appendingPathComponent("profileImage.jpg") + func saveName() { + manager.saveUserData() + print("Name saved: \(manager.userName)") } - // MARK: - Authentication (Dummy Implementation) - - /// Dummy sign in - Replace this with real Apple Sign In implementation func signIn() { - // TODO: Implement real Apple Sign In here - // This is a placeholder for demonstration - isSignedIn = true - userName = "John Doe" - userEmail = "john.doe@example.com" - - // Persist sign in status - storedSignInStatus = true - savedUserName = userName - savedUserEmail = userEmail - + manager.signInDummy() print("User signed in (dummy)") } - /// Sign out user and clear all data func signOut() { - isSignedIn = false - profileImage = nil - userName = "Guest" - userEmail = nil - - // Clear persisted data - storedSignInStatus = false - savedUserName = nil - savedUserEmail = nil - deleteProfileImageFromDisk() - + manager.signOut() print("User signed out") } - - // MARK: - Profile Image Management - - func updateProfileImage(_ image: UIImage?) { - profileImage = image - if image != nil { - saveProfileImageToDisk() - } - } } + diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift index dc3870b..0e45d41 100644 --- a/Tiny/Features/Profile/Views/ProfileView.swift +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -12,26 +12,24 @@ struct ProfileView: View { @State private var showingSignOutConfirmation = false var body: some View { - NavigationStack { - ZStack { - Image("backgroundPurple") - .resizable() - .scaledToFill() - .ignoresSafeArea() + ZStack { + Image("backgroundPurple") + .resizable() + .scaledToFill() + .ignoresSafeArea() - VStack(spacing: 0) { - // HEADER - profileHeader - .padding(.bottom, 30) + VStack(spacing: 0) { + // HEADER + profileHeader + .padding(.bottom, 30) - // FEATURE CARDS - featureCards - .padding(.horizontal, 16) - .frame(height: 160) + // FEATURE CARDS + featureCards + .padding(.horizontal, 16) + .frame(height: 160) - // SETTINGS LIST - settingsList - } + // SETTINGS LIST + settingsList } } } @@ -301,10 +299,16 @@ struct ProfilePhotoDetailView: View { ) } .sheet(isPresented: $showingImagePicker) { - ImagePicker(image: $viewModel.profileImage, sourceType: .photoLibrary) + ImagePicker(image: Binding( + get: { viewModel.profileImage }, + set: { viewModel.profileImage = $0 } + ), sourceType: .photoLibrary) } .fullScreenCover(isPresented: $showingCamera) { - ImagePicker(image: $viewModel.profileImage, sourceType: .camera) + ImagePicker(image: Binding( + get: { viewModel.profileImage }, + set: { viewModel.profileImage = $0 } + ), sourceType: .camera) } } diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index 3c8ca1f..97a348e 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -17,31 +17,36 @@ struct PregnancyTimelineView: View { @State private var selectedWeek: WeekSection? @State private var groupedData: [WeekSection] = [] + @ObservedObject private var userProfile = UserProfileManager.shared + var body: some View { - ZStack { - LinearGradient(colors: [Color(red: 0.05, green: 0.05, blue: 0.15), Color.black], startPoint: .top, endPoint: .bottom) - .ignoresSafeArea() - + NavigationStack { ZStack { - if let week = selectedWeek { - TimelineDetailView(week: week, animation: animation, onSelectRecording: onSelectRecording) - .transition(.opacity) - } else { - MainTimelineListView(groupedData: groupedData, selectedWeek: $selectedWeek, animation: animation) - .transition(.opacity) + LinearGradient(colors: [Color(red: 0.05, green: 0.05, blue: 0.15), Color.black], startPoint: .top, endPoint: .bottom) + .ignoresSafeArea() + + ZStack { + if let week = selectedWeek { + TimelineDetailView(week: week, animation: animation, onSelectRecording: onSelectRecording) + .transition(.opacity) + } else { + MainTimelineListView(groupedData: groupedData, selectedWeek: $selectedWeek, animation: animation) + .transition(.opacity) + } } + + navigationButtons } - - navigationButtons + .onAppear(perform: groupRecordings) } - .onAppear(perform: groupRecordings) } private var navigationButtons: some View { VStack { - if selectedWeek != nil { - // Back Button (Detail -> List) - HStack { + // Top Bar + HStack { + if selectedWeek != nil { + // Back Button (Detail -> List) Button { withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) { selectedWeek = nil } } label: { @@ -53,14 +58,43 @@ struct PregnancyTimelineView: View { } .glassEffect(.clear) .matchedGeometryEffect(id: "navButton", in: animation) - .padding(.leading, 20) - .padding(.top, 0) + } else { Spacer() } + Spacer() - } else { - // ⬇️ Book Button (List -> Close to Orb) - Spacer() + + // Profile Button (Top Right) + if selectedWeek == nil { + NavigationLink { + ProfileView() + } label: { + Group { + if let image = userProfile.profileImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundColor(.white.opacity(0.8)) + } + } + .frame(width: 44, height: 44) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) + .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) + } + } + } + .padding(.horizontal, 20) + .padding(.top, 60) + + Spacer() + + if selectedWeek == nil { + // Book Button (List -> Close to Orb) Button { withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { showTimeline = false diff --git a/Tiny/Info.plist b/Tiny/Info.plist index 9b9d450..0c67376 100644 --- a/Tiny/Info.plist +++ b/Tiny/Info.plist @@ -1,10 +1,5 @@ - - CFBundleIdentifier - - ITSAppUsesNonExemptEncryption - - + diff --git a/Tiny/Resources/Localizable.xcstrings b/Tiny/Resources/Localizable.xcstrings index cb42ba6..af47e06 100644 --- a/Tiny/Resources/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -72,6 +72,10 @@ "comment" : "A caption displayed alongside the slider for adjusting the gain of the microphone.", "isCommentAutoGenerated" : true }, + "20" : { + "comment" : "The number \"20\" displayed in the \"Pregnancy Age\" feature card.", + "isCommentAutoGenerated" : true + }, "20Hz" : { }, @@ -83,6 +87,10 @@ "comment" : "A label displayed next to the rightmost text in the \"Frequency Spectrum\" section of the audio visualization view.", "isCommentAutoGenerated" : true }, + "Account" : { + "comment" : "A section header in the profile view that reads \"Account\".", + "isCommentAutoGenerated" : true + }, "Adaptive Gain" : { "comment" : "A label displayed above a toggle switch that controls adaptive gain.", "isCommentAutoGenerated" : true @@ -146,6 +154,10 @@ "comment" : "A button label that dismisses an alert.", "isCommentAutoGenerated" : true }, + "Change Profile Photo" : { + "comment" : "A title for the bottom sheet that appears when the user taps on their profile photo.", + "isCommentAutoGenerated" : true + }, "Confidence" : { "comment" : "A label describing the confidence level of a heartbeat reading.", "isCommentAutoGenerated" : true @@ -154,6 +166,10 @@ "comment" : "A button label that, when tapped, will direct the user to configure Bluetooth on their device.", "isCommentAutoGenerated" : true }, + "Connect with Your Partner" : { + "comment" : "A call-to-action text that encourages users to connect with their partners.", + "isCommentAutoGenerated" : true + }, "Connect your AirPods and let Tiny access your microphone to hear every little beat." : { "comment" : "A description of how to connect AirPods to Tiny.", "isCommentAutoGenerated" : true @@ -162,6 +178,10 @@ "comment" : "A label displayed next to the name of the currently connected audio device.", "isCommentAutoGenerated" : true }, + "Connected Journey" : { + "comment" : "A feature card title.", + "isCommentAutoGenerated" : true + }, "Continue" : { "comment" : "A button label that says \"Continue\" when a user pauses listening to a podcast.", "isCommentAutoGenerated" : true @@ -182,10 +202,26 @@ "comment" : "A text label that appears when the user drags their finger over the orb to save a new recording.", "isCommentAutoGenerated" : true }, + "Dummy Theme View" : { + "comment" : "A placeholder view representing a theme-related screen.", + "isCommentAutoGenerated" : true + }, + "Dummy Tutorial View" : { + "comment" : "A placeholder view for a tutorial screen.", + "isCommentAutoGenerated" : true + }, + "Edit Profile" : { + "comment" : "The title of the view that allows users to edit their profile information.", + "isCommentAutoGenerated" : true + }, "Enhanced proximity audio" : { "comment" : "A description of the spatial audio mode.", "isCommentAutoGenerated" : true }, + "Enter your name" : { + "comment" : "A placeholder text for a text field where a user can enter their name.", + "isCommentAutoGenerated" : true + }, "EQ Configuration" : { }, @@ -302,6 +338,10 @@ "comment" : "The title of an alert that appears when microphone access is denied.", "isCommentAutoGenerated" : true }, + "Name" : { + "comment" : "A label describing the user's name.", + "isCommentAutoGenerated" : true + }, "No heartbeat detected yet" : { "comment" : "A message displayed when no heartbeat data is available.", "isCommentAutoGenerated" : true @@ -358,6 +398,10 @@ "comment" : "A description of how to finish a session by pressing and holding a sphere.", "isCommentAutoGenerated" : true }, + "Privacy Policy" : { + "comment" : "A label for a privacy policy option in the profile settings.", + "isCommentAutoGenerated" : true + }, "Proximity Gain" : { "comment" : "A label displayed next to the value of the proximity gain slider.", "isCommentAutoGenerated" : true @@ -409,6 +453,10 @@ "comment" : "A label for the number of samples taken in the current session.", "isCommentAutoGenerated" : true }, + "Save" : { + "comment" : "A button to save changes made to the user's profile.", + "isCommentAutoGenerated" : true + }, "Save or Delete" : { "comment" : "A caption displayed underneath a coach mark in the tutorial overlay.", "isCommentAutoGenerated" : true @@ -429,6 +477,18 @@ "comment" : "A button that triggers the sharing of a heartbeat data analysis report.", "isCommentAutoGenerated" : true }, + "Sign in to sync your pregnancy journey across devices" : { + "comment" : "A description below the \"Sign in with Apple\" button in the Profile view, explaining that it allows users to sync their pregnancy journey across different devices.", + "isCommentAutoGenerated" : true + }, + "Sign in with Apple" : { + "comment" : "A button label that says \"Sign in with Apple\".", + "isCommentAutoGenerated" : true + }, + "Sign Out" : { + "comment" : "A button that signs the user out of their account.", + "isCommentAutoGenerated" : true + }, "Signal Amplitude" : { "comment" : "A heading for the signal amplitude indicator.", "isCommentAutoGenerated" : true @@ -515,6 +575,14 @@ }, "Tap twice" : { + }, + "Terms and Conditions" : { + "comment" : "A link to the terms and conditions of the app.", + "isCommentAutoGenerated" : true + }, + "Theme" : { + "comment" : "A button that allows the user to change the app's theme.", + "isCommentAutoGenerated" : true }, "Time" : { "comment" : "Data point on the heart rate trend chart.", @@ -528,6 +596,10 @@ "comment" : "A description under the title of the second onboarding page.", "isCommentAutoGenerated" : true }, + "Tutorial" : { + "comment" : "A link to a view that displays a tutorial.", + "isCommentAutoGenerated" : true + }, "Variability" : { "comment" : "A label for the variability of a user's heart rate.", "isCommentAutoGenerated" : true @@ -536,10 +608,22 @@ "comment" : "A label inside the bottom pocket of a folder, showing the current week.", "isCommentAutoGenerated" : true }, + "Weeks" : { + "comment" : "A unit of measurement for pregnancy weeks.", + "isCommentAutoGenerated" : true + }, "You can listen to your baby's heartbeat live and record it to listen again later." : { "comment" : "A description of the live and recorded heartbeat features.", "isCommentAutoGenerated" : true }, + "You'll need to sign in again to sync your data and access personalized features." : { + "comment" : "A message displayed in the confirmation dialog when signing out.", + "isCommentAutoGenerated" : true + }, + "Your privacy is protected. We only use your Apple ID to securely save your data." : { + "comment" : "A footer text in the profile view that explains data privacy.", + "isCommentAutoGenerated" : true + }, "Your recording will be saved automatically in your library." : { "comment" : "A description of what happens when the user stops a recording and it is saved.", "isCommentAutoGenerated" : true From 05d4a753a7d04717d7a5695d9de7e658d0a33da2 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 13:36:56 +0700 Subject: [PATCH 3/5] fix: account section in profile --- .../Profile/ViewModels/ProfileViewModel.swift | 11 ----------- Tiny/Features/Profile/Views/ProfileView.swift | 12 ------------ Tiny/Resources/Localizable.xcstrings | 12 ------------ 3 files changed, 35 deletions(-) diff --git a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift index 0bb02bb..0dd5d9a 100644 --- a/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift +++ b/Tiny/Features/Profile/ViewModels/ProfileViewModel.swift @@ -8,16 +8,6 @@ import SwiftUI internal import Combine -// -// ProfileViewModel.swift -// Tiny -// -// Created by Destu Cikal Ramdani on 26/11/25. -// - -import SwiftUI -internal import Combine - @MainActor class ProfileViewModel: ObservableObject { // Observe the singleton manager so this ViewModel publishes changes when manager changes @@ -73,4 +63,3 @@ class ProfileViewModel: ObservableObject { print("User signed out") } } - diff --git a/Tiny/Features/Profile/Views/ProfileView.swift b/Tiny/Features/Profile/Views/ProfileView.swift index 0e45d41..3be6cfa 100644 --- a/Tiny/Features/Profile/Views/ProfileView.swift +++ b/Tiny/Features/Profile/Views/ProfileView.swift @@ -129,13 +129,6 @@ struct ProfileView: View { } else { signInView } - } header: { - Text("Account") - } footer: { - if !viewModel.isSignedIn { - Text("Your privacy is protected. We only use your Apple ID to securely save your data.") - .font(.caption2) - } } .listRowBackground(Color("rowProfileGrey")) } @@ -182,11 +175,6 @@ struct ProfileView: View { .cornerRadius(8) } .buttonStyle(.plain) - - Text("Sign in to sync your pregnancy journey across devices") - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) } .padding(.vertical, 8) } diff --git a/Tiny/Resources/Localizable.xcstrings b/Tiny/Resources/Localizable.xcstrings index af47e06..0e4e607 100644 --- a/Tiny/Resources/Localizable.xcstrings +++ b/Tiny/Resources/Localizable.xcstrings @@ -87,10 +87,6 @@ "comment" : "A label displayed next to the rightmost text in the \"Frequency Spectrum\" section of the audio visualization view.", "isCommentAutoGenerated" : true }, - "Account" : { - "comment" : "A section header in the profile view that reads \"Account\".", - "isCommentAutoGenerated" : true - }, "Adaptive Gain" : { "comment" : "A label displayed above a toggle switch that controls adaptive gain.", "isCommentAutoGenerated" : true @@ -477,10 +473,6 @@ "comment" : "A button that triggers the sharing of a heartbeat data analysis report.", "isCommentAutoGenerated" : true }, - "Sign in to sync your pregnancy journey across devices" : { - "comment" : "A description below the \"Sign in with Apple\" button in the Profile view, explaining that it allows users to sync their pregnancy journey across different devices.", - "isCommentAutoGenerated" : true - }, "Sign in with Apple" : { "comment" : "A button label that says \"Sign in with Apple\".", "isCommentAutoGenerated" : true @@ -620,10 +612,6 @@ "comment" : "A message displayed in the confirmation dialog when signing out.", "isCommentAutoGenerated" : true }, - "Your privacy is protected. We only use your Apple ID to securely save your data." : { - "comment" : "A footer text in the profile view that explains data privacy.", - "isCommentAutoGenerated" : true - }, "Your recording will be saved automatically in your library." : { "comment" : "A description of what happens when the user stops a recording and it is saved.", "isCommentAutoGenerated" : true From 46cee0c4c1e9035ae7d1a379f1e0b5ae3c45d954 Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 14:01:31 +0700 Subject: [PATCH 4/5] fix: profile in pregnancy timeline view --- Tiny/Features/Timeline/Views/PregnancyTimelineView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index 97a348e..2b9e6f9 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -81,7 +81,7 @@ struct PregnancyTimelineView: View { .foregroundColor(.white.opacity(0.8)) } } - .frame(width: 44, height: 44) + .frame(width: 45, height: 45 ) .clipShape(Circle()) .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) @@ -89,8 +89,8 @@ struct PregnancyTimelineView: View { } } .padding(.horizontal, 20) - .padding(.top, 60) - + .padding(.top,20) + Spacer() if selectedWeek == nil { From 03ba76d9ec0e8da68fd12da976050e45911c683d Mon Sep 17 00:00:00 2001 From: Destu Cikal Date: Thu, 27 Nov 2025 14:04:27 +0700 Subject: [PATCH 5/5] fix: swiftlint violation --- Tiny/Features/Timeline/Views/PregnancyTimelineView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift index 2b9e6f9..bfb44f5 100644 --- a/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift +++ b/Tiny/Features/Timeline/Views/PregnancyTimelineView.swift @@ -89,7 +89,7 @@ struct PregnancyTimelineView: View { } } .padding(.horizontal, 20) - .padding(.top,20) + .padding(.top, 20) Spacer()