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
80 changes: 80 additions & 0 deletions ios/escape/escape/Services/Supabases/BadgeSupabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,86 @@ class BadgeSupabase {
// MARK: - Error Handling

extension BadgeSupabase {
/// Fetches user's collected badges by user ID
/// - Parameter userId: The user's UUID
/// - Returns: Array of UserShelterBadge objects
func fetchUserBadges(userId: UUID) async throws -> [UserShelterBadge] {
let userBadges: [UserShelterBadge] = try await supabase
.from("user_shelter_badges")
.select()
.eq("user_id", value: userId.uuidString.lowercased())
.order("created_at", ascending: false)
.execute()
.value

return userBadges
}

/// Fetches badges by their IDs and converts them to Badge UI models
/// - Parameter badgeIds: Array of badge UUIDs
/// - Returns: Array of Badge objects for UI display
func fetchBadgesByIds(_ badgeIds: [UUID]) async throws -> [Badge] {
guard !badgeIds.isEmpty else { return [] }

var badges: [Badge] = []

for badgeId in badgeIds {
do {
// Fetch the shelter badge
let shelterBadge: ShelterBadge = try await supabase
.from("shelter_badges")
.select()
.eq("id", value: badgeId.uuidString.lowercased())
.single()
.execute()
.value

// Fetch the shelter information
let shelter: Shelter = try await supabase
.from("shelters")
.select()
.eq("id", value: shelterBadge.shelterId.uuidString.lowercased())
.single()
.execute()
.value

// Create Badge UI model
let badge = Badge(
id: shelterBadge.id.uuidString,
name: shelter.name,
icon: shelterBadge.determineIcon(),
color: shelterBadge.determineColor(),
isUnlocked: true,
imageName: nil,
imageUrl: shelterBadge.getImageUrl(),
badgeNumber: shelter.number?.description,
address: shelter.address,
municipality: shelter.municipality,
isShelter: shelter.isShelter ?? false,
isFlood: shelter.isFlood ?? false,
isLandslide: shelter.isLandslide ?? false,
isStormSurge: shelter.isStormSurge ?? false,
isEarthquake: shelter.isEarthquake ?? false,
isTsunami: shelter.isTsunami ?? false,
isFire: shelter.isFire ?? false,
isInlandFlood: shelter.isInlandFlood ?? false,
isVolcano: shelter.isVolcano ?? false,
latitude: shelter.latitude,
longitude: shelter.longitude,
firstUserName: nil
)

badges.append(badge)
} catch {
print("⚠️ Failed to fetch badge \(badgeId): \(error)")
// Continue to next badge instead of failing completely
continue
}
}

return badges
}

enum BadgeServiceError: LocalizedError {
case userNotAuthenticated
case badgeNotFound
Expand Down
16 changes: 16 additions & 0 deletions ios/escape/escape/Services/Supabases/UserSupabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,22 @@ class UserSupabase {
return users.first
}

/// Fetches a user profile by their ID
/// - Parameter userId: The user's UUID
/// - Returns: User object
/// - Throws: Error if user not found or database error
func getUserProfile(userId: UUID) async throws -> User {
let user: User = try await supabase
.from("users")
.select()
.eq("id", value: userId)
.single()
.execute()
.value

return user
}

/// Deletes the current user's account and all associated data
/// - Throws: Database error if deletion fails
func deleteAccount() async throws {
Expand Down
134 changes: 134 additions & 0 deletions ios/escape/escape/ViewModels/UserProfileViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
//
// UserProfileViewModel.swift
// escape
//
// Created by Claude on 2025-11-08.
//

import Foundation
import SwiftUI

@MainActor
@Observable
final class UserProfileViewModel {
// MARK: - Properties

var isLoading = true
var errorMessage: String?
var user: User?
var missionResults: [MissionResult] = []
var userBadges: [Badge] = []
var dailyPointsMap: [String: Int] = [:]

// MARK: - Dependencies

private let userSupabase: UserSupabase
private let missionResultSupabase: MissionResultSupabase
private let badgeSupabase: BadgeSupabase

// MARK: - Initialization

init(
userSupabase: UserSupabase = UserSupabase(),
missionResultSupabase: MissionResultSupabase = MissionResultSupabase(),
badgeSupabase: BadgeSupabase = BadgeSupabase()
) {
self.userSupabase = userSupabase
self.missionResultSupabase = missionResultSupabase
self.badgeSupabase = badgeSupabase
}

// MARK: - Public Methods

/// Fetches the complete profile for a given user
func fetchUserProfile(userId: UUID) async {
isLoading = true
errorMessage = nil

do {
// Calculate date range for last 30 days
let calendar = Calendar.current
let endDate = Date()
guard let startDate = calendar.date(byAdding: .day, value: -30, to: endDate) else {
throw NSError(domain: "UserProfileViewModel", code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to calculate date range"])
}

// Fetch all data in parallel
async let userTask = userSupabase.getUserProfile(userId: userId)
async let missionResultsTask = missionResultSupabase.fetchMissionResultsInDateRange(
userId: userId,
startDate: startDate,
endDate: endDate
)
async let badgesTask = fetchUserBadges(userId: userId)

// Await all results
let (fetchedUser, fetchedMissionResults, fetchedBadges) = try await (
userTask,
missionResultsTask,
badgesTask
)

// Update state
self.user = fetchedUser
self.missionResults = fetchedMissionResults
self.userBadges = fetchedBadges

// Calculate daily points map
calculateDailyPoints()

isLoading = false
} catch {
errorMessage = error.localizedDescription
isLoading = false
print("Error fetching user profile: \(error)")
}
}

/// Refreshes the profile data
func refresh(userId: UUID) async {
await fetchUserProfile(userId: userId)
}

// MARK: - Private Methods

/// Calculates total points for each day from mission results
private func calculateDailyPoints() {
var pointsMap: [String: Int] = [:]
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"

for result in missionResults {
let dateString = formatter.string(from: result.createdAt)
let points = Int(result.finalPoints ?? 0)
pointsMap[dateString, default: 0] += points
}

self.dailyPointsMap = pointsMap
}

/// Fetches all badges that the user has collected
private func fetchUserBadges(userId: UUID) async throws -> [Badge] {
// Fetch the user's badge collection
let userBadges = try await badgeSupabase.fetchUserBadges(userId: userId)

// Extract unique badge IDs
let badgeIds = Set(userBadges.map { $0.badgeId })

// Fetch all badge details
let badges = try await badgeSupabase.fetchBadgesByIds(Array(badgeIds))

return badges
}

/// Gets the current user's profile badge for avatar display
func getProfileBadge() -> Badge? {
guard let user = user,
let profileBadgeId = user.profileBadgeId else {
return nil
}

return userBadges.first { $0.id == profileBadgeId.uuidString }
}
}
10 changes: 10 additions & 0 deletions ios/escape/escape/Views/Components/Group/GroupDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ struct MemberRowView: View {
let member: TeamMemberWithUser
@Bindable var groupViewModel: GroupViewModel
@State private var showingRoleMenu = false
@State private var showUserProfile = false

var body: some View {
HStack(spacing: 12) {
Expand Down Expand Up @@ -348,6 +349,15 @@ struct MemberRowView: View {
.padding(.vertical, 8)
.background(Color(.systemBackground))
.cornerRadius(20)
.contentShape(Rectangle())
.onTapGesture {
HapticFeedback.shared.lightImpact()
showUserProfile = true
}
.sheet(isPresented: $showUserProfile) {
UserProfileBottomSheetView(userId: member.user.id)
.presentationDetents([.medium, .large])
}
}
}

Expand Down
Loading