Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 202 additions & 34 deletions Tiny.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

97 changes: 97 additions & 0 deletions Tiny/Core/Animation/TimelineAnimationController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//
// TimelineAnimationController.swift
// Tiny
//
// Animation state management for timeline first-time experience
//

import SwiftUI
internal import Combine

enum AnimationPhase {
case empty
case drawingPath
case showingDots
case transformingOrb
case showingProfile
case complete
}

class TimelineAnimationController: ObservableObject {
@Published var currentPhase: AnimationPhase = .empty
@Published var pathProgress: CGFloat = 0.0
@Published var dotsVisible: [Bool] = [false, false, false]
@Published var orbVisible: Bool = false
@Published var profileVisible: Bool = false

// Timing constants (in seconds) - Slowed down for better visibility
private let pathDuration: Double = 2.5 // Was 1.5
private let dotDelay: Double = 0.9 // Was 0.33
private let dotDuration: Double = 0.9 // Was 0.3
private let transformDuration: Double = 1.0 // Was 0.7
private let profileDuration: Double = 0.8 // Was 0.6

func startAnimation() {
currentPhase = .drawingPath
animatePathDrawing()
}

private func animatePathDrawing() {
withAnimation(.easeInOut(duration: pathDuration)) {
pathProgress = 1.0
}

DispatchQueue.main.asyncAfter(deadline: .now() + pathDuration) {
self.currentPhase = .showingDots
self.animateDotsAppearing()
}
}

private func animateDotsAppearing() {
// Show dots sequentially
for index in 0..<3 {
DispatchQueue.main.asyncAfter(deadline: .now() + (Double(index) * dotDelay)) {
withAnimation(.easeOut(duration: self.dotDuration)) {
self.dotsVisible[index] = true
}
}
}

// After all dots appear, transform first dot to orb
let totalDotTime = Double(3) * dotDelay + dotDuration
DispatchQueue.main.asyncAfter(deadline: .now() + totalDotTime) {
self.currentPhase = .transformingOrb
self.animateOrbTransform()
}
}

private func animateOrbTransform() {
withAnimation(.spring(response: 0.6, dampingFraction: 0.75)) {
dotsVisible[0] = false
orbVisible = true
}

DispatchQueue.main.asyncAfter(deadline: .now() + transformDuration) {
self.currentPhase = .showingProfile
self.animateProfileAppear()
}
}

private func animateProfileAppear() {
withAnimation(.easeIn(duration: profileDuration)) {
profileVisible = true
}

DispatchQueue.main.asyncAfter(deadline: .now() + profileDuration) {
self.currentPhase = .complete
}
}

func skipAnimation() {
currentPhase = .complete
pathProgress = 1.0
dotsVisible = [false, false, false]
orbVisible = true
profileVisible = true
}
}
6 changes: 3 additions & 3 deletions Tiny/Core/Models/User.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,22 @@ struct User: Codable, Identifiable {
var email: String
var name: String?
var role: UserRole?
var pregnancyMonths: Int?
var pregnancyWeeks: Int?
var roomCode: String?
var createdAt: Date

init(id: String? = nil,
email: String,
name: String? = nil,
role: UserRole? = nil,
pregnancyMonths: Int? = nil,
pregnancyWeeks: Int? = nil,
roomCode: String? = nil,
createdAt: Date) {
self.id = id
self.email = email
self.name = name
self.role = role
self.pregnancyMonths = pregnancyMonths
self.pregnancyWeeks = pregnancyWeeks
self.roomCode = roomCode
self.createdAt = createdAt
}
Expand Down
10 changes: 5 additions & 5 deletions Tiny/Core/Services/Authentication/AuthenticationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ class AuthenticationService: ObservableObject {
email: result.user.email ?? "",
name: displayName,
role: nil,
pregnancyMonths: nil,
pregnancyWeeks: nil,
roomCode: nil,
createdAt: Date()
)
Expand All @@ -90,15 +90,15 @@ class AuthenticationService: ObservableObject {
return sha256(nonce)
}

func updateUserRole(role: UserRole, pregnancyMonths: Int? = nil, roomCode: String? = nil) async throws {
func updateUserRole(role: UserRole, pregnancyWeeks: Int? = nil, roomCode: String? = nil) async throws {
guard let userId = auth.currentUser?.uid else {
return
}

var updateData: [String: Any] = ["role": role.rawValue]

if role == .mother, let months = pregnancyMonths {
updateData["pregnancyMonths"] = months
if role == .mother, let weeks = pregnancyWeeks {
updateData["pregnancyWeeks"] = weeks
}

if role == .father, let code = roomCode, !code.isEmpty {
Expand Down Expand Up @@ -160,7 +160,7 @@ class AuthenticationService: ObservableObject {
email: data["email"] as? String ?? "",
name: data["name"] as? String,
role: (data["role"] as? String).flatMap { UserRole(rawValue: $0) },
pregnancyMonths: data["pregnancyMonths"] as? Int,
pregnancyWeeks: data["pregnancyWeeks"] as? Int,
roomCode: data["roomCode"] as? String,
createdAt: (data["createdAt"] as? Timestamp)?.dateValue() ?? Date()
)
Expand Down
20 changes: 11 additions & 9 deletions Tiny/Features/Authentication/Views/NameInputView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,18 +97,20 @@ struct NameInputView: View {
Task {
do {
try await authService.updateUserName(name: name.trimmingCharacters(in: .whitespaces))
if selectedRole == .mother {
try await authService.updateUserRole(role: selectedRole, pregnancyMonths: 5)
} else {
// For mothers, proceed to week input before setting role
// For fathers, set role now and proceed to room code input
if selectedRole == .father {
try await authService.updateUserRole(role: selectedRole)
}
await MainActor.run {
isLoading = false
onContinue()
}
} catch {
errorMessage = error.localizedDescription
isLoading = false
}

if selectedRole == .father {
isLoading = false
await MainActor.run {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
}
Expand Down
19 changes: 17 additions & 2 deletions Tiny/Features/Authentication/Views/OnboardingCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import SwiftUI
enum OnboardingStep {
case roleSelection
case nameInput(role: UserRole)
case roomCodeInput
case weekInput // For mothers only
case roomCodeInput // For fathers only
}

struct OnboardingCoordinator: View {
Expand All @@ -34,11 +35,25 @@ struct OnboardingCoordinator: View {
NameInputView(
selectedRole: role,
onContinue: {
if role == .father {
if role == .mother {
currentStep = .weekInput
} else {
currentStep = .roomCodeInput
}
}
)
case .weekInput:
WeekInputView(onComplete: { week in
Task {
do {
// Update user role and pregnancy week in Firebase
try await authService.updateUserRole(role: .mother, pregnancyWeeks: week)
print("✅ Successfully saved pregnancy week: \(week)")
} catch {
print("❌ Error saving pregnancy week: \(error.localizedDescription)")
}
}
})
case .roomCodeInput:
RoomCodeInputView()
}
Expand Down
12 changes: 5 additions & 7 deletions Tiny/Features/LiveListen/ViewModels/HeartbeatMainViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import SwiftData
internal import Combine

class HeartbeatMainViewModel: ObservableObject {
@Published var showTimeline = false
@Published var currentPage = 0 // 0 = Timeline (left), 1 = Orb (right)
let heartbeatSoundManager = HeartbeatSoundManager()

func setupManager(
Expand All @@ -31,15 +31,13 @@ class HeartbeatMainViewModel: ObservableObject {
func handleRecordingSelection(_ recording: Recording) {
print("🎵 Recording selected: \(recording.fileURL.lastPathComponent)")

// Set as last recording
// Set as last recording (but don't auto-play)
heartbeatSoundManager.lastRecording = recording

// Play the recording
heartbeatSoundManager.togglePlayback(recording: recording)

// Close timeline and go to orb view for playback (for both mother and father)
// Switch to orb view (page 1) for playback
// User will need to tap the orb to start playback
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
showTimeline = false
currentPage = 1
}
}
}
60 changes: 44 additions & 16 deletions Tiny/Features/LiveListen/Views/HeartbeatMainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,58 @@ struct HeartbeatMainView: View {

var body: some View {
ZStack {
// Timeline view - accessible by both mom and dad
if viewModel.showTimeline {
// TabView with swipe navigation
TabView(selection: $viewModel.currentPage) {
// Left page: Timeline (default)
PregnancyTimelineView(
heartbeatSoundManager: viewModel.heartbeatSoundManager,
showTimeline: $viewModel.showTimeline,
showTimeline: .constant(true),
onSelectRecording: viewModel.handleRecordingSelection,
isMother: isMother
isMother: isMother,
inputWeek: authService.currentUser?.pregnancyWeeks
)
.transition(.opacity)
} else {
// Orb view - for playback (both) and recording (mother only)
.tag(0)
.transition(.asymmetric(
insertion: .scale(scale: 0.95).combined(with: .opacity),
removal: .scale(scale: 1.05).combined(with: .opacity)
))

// Right page: Orb Live Listen
OrbLiveListenView(
heartbeatSoundManager: viewModel.heartbeatSoundManager,
showTimeline: $viewModel.showTimeline
showTimeline: Binding(
get: { viewModel.currentPage == 0 },
set: { if $0 { viewModel.currentPage = 0 } else { viewModel.currentPage = 1 } }
)
)
.transition(.opacity)
.tag(1)
.transition(.asymmetric(
insertion: .scale(scale: 0.95).combined(with: .opacity),
removal: .scale(scale: 1.05).combined(with: .opacity)
))
}
.tabViewStyle(.page(indexDisplayMode: .never))
.ignoresSafeArea()
}
.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)
}
}

Expand Down Expand Up @@ -77,13 +112,6 @@ struct HeartbeatMainView: View {
)
isInitialized = true
}

// For fathers, start in timeline view
if !isMother {
await MainActor.run {
viewModel.showTimeline = true
}
}
}
}
}
Expand Down
Loading