diff --git a/ios/escape/escape/Services/Supabases/BadgeSupabase.swift b/ios/escape/escape/Services/Supabases/BadgeSupabase.swift index 6f39e7c..597e49d 100644 --- a/ios/escape/escape/Services/Supabases/BadgeSupabase.swift +++ b/ios/escape/escape/Services/Supabases/BadgeSupabase.swift @@ -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 diff --git a/ios/escape/escape/Services/Supabases/UserSupabase.swift b/ios/escape/escape/Services/Supabases/UserSupabase.swift index 9d2c128..85b3a25 100644 --- a/ios/escape/escape/Services/Supabases/UserSupabase.swift +++ b/ios/escape/escape/Services/Supabases/UserSupabase.swift @@ -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 { diff --git a/ios/escape/escape/ViewModels/UserProfileViewModel.swift b/ios/escape/escape/ViewModels/UserProfileViewModel.swift new file mode 100644 index 0000000..8636578 --- /dev/null +++ b/ios/escape/escape/ViewModels/UserProfileViewModel.swift @@ -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 } + } +} diff --git a/ios/escape/escape/Views/Components/Group/GroupDetailView.swift b/ios/escape/escape/Views/Components/Group/GroupDetailView.swift index aef23b2..3a1d106 100644 --- a/ios/escape/escape/Views/Components/Group/GroupDetailView.swift +++ b/ios/escape/escape/Views/Components/Group/GroupDetailView.swift @@ -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) { @@ -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]) + } } } diff --git a/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift b/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift index 25c1921..68a4170 100644 --- a/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift +++ b/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift @@ -7,12 +7,18 @@ import SwiftUI +// Wrapper to make UUID work with .sheet(item:) +private struct IdentifiableUUID: Identifiable { + let id: UUID +} + struct NationalRankingView: View { let pointViewModel: PointViewModel @State private var animateEntries = false @State private var showSparkles = false @State private var currentUserId: UUID? @State private var isLoadingNational = false + @State private var selectedUserId: IdentifiableUUID? var body: some View { ZStack { @@ -44,7 +50,7 @@ struct NationalRankingView: View { // Top 3 Podium (only if they're in the ranking) let topThree = pointViewModel.nationalRanking.filter { $0.rank >= 1 && $0.rank <= 3 } if !topThree.isEmpty { - TopThreePodium(rankings: topThree) + TopThreePodium(rankings: topThree, onTap: handleUserTap) .padding(.top, 8) .padding(.horizontal) } @@ -60,7 +66,8 @@ struct NationalRankingView: View { RankingRow( entry: entry, currentUserId: currentUserId, - delay: Double(index) * 0.05 + delay: Double(index) * 0.05, + onTap: handleUserTap ) .padding(.horizontal) .opacity(animateEntries ? 1 : 0) @@ -78,12 +85,20 @@ struct NationalRankingView: View { } } } + .sheet(item: $selectedUserId) { identifiableUserId in + UserProfileBottomSheetView(userId: identifiableUserId.id) + .presentationDetents([.medium, .large]) + } .task { await loadRankings() startAnimations() } } + private func handleUserTap(userId: UUID) { + selectedUserId = IdentifiableUUID(id: userId) + } + private func loadRankings() async { isLoadingNational = true @@ -126,6 +141,7 @@ struct NationalRankingView: View { private struct TopThreePodium: View { let rankings: [RankingEntry] + let onTap: (UUID) -> Void @State private var animateRanks = [false, false, false] @State private var showCrowns = [false, false, false] @@ -133,7 +149,7 @@ private struct TopThreePodium: View { VStack(spacing: 20) { // Winner Banner if let first = rankings.first { - WinnerBanner(entry: first, animate: animateRanks[0]) + WinnerBanner(entry: first, animate: animateRanks[0], onTap: onTap) } // Podium @@ -146,7 +162,8 @@ private struct TopThreePodium: View { color: Color.gray, crownColor: .silver, animate: animateRanks[1], - showCrown: showCrowns[1] + showCrown: showCrowns[1], + onTap: onTap ) } @@ -158,7 +175,8 @@ private struct TopThreePodium: View { color: Color.yellow, crownColor: .gold, animate: animateRanks[0], - showCrown: showCrowns[0] + showCrown: showCrowns[0], + onTap: onTap ) } @@ -170,7 +188,8 @@ private struct TopThreePodium: View { color: Color.orange, crownColor: .bronze, animate: animateRanks[2], - showCrown: showCrowns[2] + showCrown: showCrowns[2], + onTap: onTap ) } } @@ -197,6 +216,7 @@ private struct TopThreePodium: View { private struct WinnerBanner: View { let entry: RankingEntry let animate: Bool + let onTap: (UUID) -> Void @State private var shimmer = false var body: some View { @@ -266,6 +286,9 @@ private struct WinnerBanner: View { .shadow(color: Color.yellow.opacity(0.3), radius: 20, x: 0, y: 10) .scaleEffect(animate ? 1 : 0.8) .opacity(animate ? 1 : 0) + .onTapGesture { + onTap(entry.userId) + } } } @@ -278,6 +301,7 @@ private struct PodiumPosition: View { let crownColor: Color let animate: Bool let showCrown: Bool + let onTap: (UUID) -> Void var body: some View { VStack(spacing: 8) { @@ -332,6 +356,9 @@ private struct PodiumPosition: View { ) } .frame(maxWidth: .infinity) + .onTapGesture { + onTap(entry.userId) + } } } @@ -341,6 +368,7 @@ private struct RankingRow: View { let entry: RankingEntry let currentUserId: UUID? let delay: Double + let onTap: (UUID) -> Void @State private var isPressed = false @State private var pulseAnimation = false @@ -459,6 +487,9 @@ private struct RankingRow: View { } } } + .onTapGesture { + onTap(entry.userId) + } } private var rankGradient: LinearGradient { diff --git a/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift b/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift index 78c82ea..da498e4 100644 --- a/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift +++ b/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift @@ -7,12 +7,18 @@ import SwiftUI +// Wrapper to make UUID work with .sheet(item:) +private struct IdentifiableUUID: Identifiable { + let id: UUID +} + struct TeamRankingView: View { let pointViewModel: PointViewModel let groupViewModel: GroupViewModel @State private var animateEntries = false @State private var currentUserId: UUID? @State private var isLoadingTeam = false + @State private var selectedUserId: IdentifiableUUID? var body: some View { ZStack { @@ -61,7 +67,8 @@ struct TeamRankingView: View { TeamRankingRow( entry: entry, currentUserId: currentUserId, - delay: Double(index) * 0.05 + delay: Double(index) * 0.05, + onTap: handleUserTap ) .padding(.horizontal) .opacity(animateEntries ? 1 : 0) @@ -79,12 +86,20 @@ struct TeamRankingView: View { } } } + .sheet(item: $selectedUserId) { identifiableUserId in + UserProfileBottomSheetView(userId: identifiableUserId.id) + .presentationDetents([.medium, .large]) + } .task { await loadTeamRankings() startAnimations() } } + private func handleUserTap(userId: UUID) { + selectedUserId = IdentifiableUUID(id: userId) + } + private func loadTeamRankings() async { isLoadingTeam = true @@ -223,6 +238,7 @@ private struct TeamRankingRow: View { let entry: RankingEntry let currentUserId: UUID? let delay: Double + let onTap: (UUID) -> Void @State private var isPressed = false @State private var pulseAnimation = false @@ -315,6 +331,9 @@ private struct TeamRankingRow: View { } } } + .onTapGesture { + onTap(entry.userId) + } } private var rankGradient: LinearGradient { diff --git a/ios/escape/escape/Views/Components/Shared/ActivityHeatmapView.swift b/ios/escape/escape/Views/Components/Shared/ActivityHeatmapView.swift new file mode 100644 index 0000000..a40ccab --- /dev/null +++ b/ios/escape/escape/Views/Components/Shared/ActivityHeatmapView.swift @@ -0,0 +1,278 @@ +// +// ActivityHeatmapView.swift +// escape +// +// Created by Claude on 2025-11-08. +// + +import SwiftUI + +/// A GitHub-style activity heatmap showing daily points over the past 30 days +struct ActivityHeatmapView: View { + /// Dictionary mapping date strings (yyyy-MM-dd) to total points + let dailyPoints: [String: Int] + /// Base orange color for the heatmap + private let baseColor = Color(hex: "f54900") + + @State private var selectedDate: String? + @State private var showTooltip = false + @State private var tooltipFrame: CGRect = .zero + + private let columns = Array(repeating: GridItem(.fixed(30), spacing: 4), count: 7) + private let cellSize: CGFloat = 30 + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Activity in the last 30 days") + .font(.headline) + .foregroundColor(.primary) + + VStack(alignment: .leading, spacing: 8) { + // Weekday labels + weekdayLabels + + // Heatmap grid with tooltip overlay + ZStack(alignment: .topLeading) { + LazyVGrid(columns: columns, spacing: 4, pinnedViews: []) { + ForEach(Array(getAlignedDays().enumerated()), id: \.offset) { index, dayData in + if let date = dayData { + dayCellView(for: date, index: index) + } else { + // Empty placeholder cell for alignment + Color.clear + .frame(width: cellSize, height: cellSize) + } + } + } + + // Tooltip overlay + if showTooltip, let selectedDate = selectedDate { + tooltipView(for: selectedDate) + .offset(x: tooltipFrame.minX, y: tooltipFrame.minY) + .transition(.opacity) + .zIndex(100) + } + } + } + + // Color legend + HStack(spacing: 4) { + Text("Less") + .font(.caption2) + .foregroundColor(.secondary) + + ForEach(0..<5) { index in + Rectangle() + .fill(getColor(for: index, max: 4)) + .frame(width: 12, height: 12) + .cornerRadius(2) + } + + Text("More") + .font(.caption2) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + } + + // MARK: - Weekday Labels + + private var weekdayLabels: some View { + LazyVGrid(columns: columns, spacing: 4) { + ForEach(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], id: \.self) { day in + Text(day) + .font(.caption2) + .foregroundColor(.secondary) + .frame(width: cellSize, alignment: .center) + } + } + } + + // MARK: - Day Cell View + + private func dayCellView(for date: Date, index: Int) -> some View { + let dateString = formatDate(date) + let points = dailyPoints[dateString] ?? 0 + let color = getColorForPoints(points) + + return GeometryReader { geometry in + Rectangle() + .fill(color) + .frame(width: cellSize, height: cellSize) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.gray.opacity(0.2), lineWidth: 1) + ) + .onTapGesture { + handleDayTap(date: dateString, points: points, frame: geometry.frame(in: .local)) + } + } + .frame(width: cellSize, height: cellSize) + } + + // MARK: - Tooltip View + + private func tooltipView(for dateString: String) -> some View { + let points = dailyPoints[dateString] ?? 0 + let displayDate = formatDateForDisplay(dateString) + + return VStack(alignment: .leading, spacing: 4) { + Text(displayDate) + .font(.caption) + .foregroundColor(.secondary) + Text("\(points) points") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(.systemBackground)) + .shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + } + + // MARK: - Helper Methods + + private func handleDayTap(date: String, points: Int, frame: CGRect) { + selectedDate = date + tooltipFrame = frame + + withAnimation(.easeInOut(duration: 0.2)) { + showTooltip = true + } + + // Auto-hide tooltip after 2.5 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { + withAnimation(.easeInOut(duration: 0.2)) { + showTooltip = false + } + } + } + + /// Returns an array of dates aligned to weekdays, with nil for padding + private func getAlignedDays() -> [Date?] { + let calendar = Calendar.current + let dates = getLast30Days() + + guard let firstDate = dates.first else { + return [] + } + + // Get the weekday of the first date (1 = Sunday, 2 = Monday, ..., 7 = Saturday) + let weekday = calendar.component(.weekday, from: firstDate) + + // Convert to Monday-based index (0 = Monday, 6 = Sunday) + let mondayBasedIndex = weekday == 1 ? 6 : weekday - 2 + + // Create padding with nil values + var alignedDays: [Date?] = Array(repeating: nil, count: mondayBasedIndex) + + // Add actual dates + alignedDays.append(contentsOf: dates.map { $0 as Date? }) + + return alignedDays + } + + private func getLast30Days() -> [Date] { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + + return (0..<30).reversed().compactMap { daysAgo in + calendar.date(byAdding: .day, value: -daysAgo, to: today) + } + } + + private func getColorForPoints(_ points: Int) -> Color { + if points == 0 { + return Color(.systemGray6) + } + + let maxPoints = dailyPoints.values.max() ?? 1 + let intensity = Double(points) / Double(maxPoints) + + return baseColor.opacity(0.3 + (intensity * 0.7)) + } + + private func getColor(for index: Int, max: Int) -> Color { + if index == 0 { + return Color(.systemGray6) + } + let intensity = Double(index) / Double(max) + return baseColor.opacity(0.3 + (intensity * 0.7)) + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + private func formatDateForDisplay(_ dateString: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + + guard let date = formatter.date(from: dateString) else { + return dateString + } + + formatter.dateFormat = "MMM d, yyyy" + return formatter.string(from: date) + } +} + +// MARK: - Color Extension + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} + +#Preview { + let sampleData: [String: Int] = [ + "2025-10-09": 120, + "2025-10-10": 80, + "2025-10-11": 200, + "2025-10-12": 150, + "2025-10-15": 90, + "2025-10-20": 180, + "2025-10-25": 100, + "2025-11-01": 220, + "2025-11-05": 160, + "2025-11-08": 190 + ] + + return ActivityHeatmapView(dailyPoints: sampleData) + .padding() +} diff --git a/ios/escape/escape/Views/Components/Shared/UserProfileBottomSheetView.swift b/ios/escape/escape/Views/Components/Shared/UserProfileBottomSheetView.swift new file mode 100644 index 0000000..a4e6315 --- /dev/null +++ b/ios/escape/escape/Views/Components/Shared/UserProfileBottomSheetView.swift @@ -0,0 +1,185 @@ +// +// UserProfileBottomSheetView.swift +// escape +// +// Created by Claude on 2025-11-08. +// + +import SwiftUI + +struct UserProfileBottomSheetView: View { + let userId: UUID + @State private var viewModel = UserProfileViewModel() + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 24) { + if viewModel.isLoading { + loadingView + } else if let errorMessage = viewModel.errorMessage { + errorView(message: errorMessage) + } else if let user = viewModel.user { + profileContent(user: user) + } else { + emptyView + } + } + .padding() + } + .navigationTitle("User Profile") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + } + } + } + .task { + await viewModel.fetchUserProfile(userId: userId) + } + } + + // MARK: - Profile Content + + @ViewBuilder + private func profileContent(user: User) -> some View { + // Header Section - Avatar & Username + VStack(spacing: 16) { + UserAvatarView.profile( + username: user.name ?? "Anonymous", + badgeImageUrl: viewModel.getProfileBadge()?.imageUrl, + size: .large + ) + + Text(user.name ?? "Anonymous") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.primary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + + Divider() + + // Activity Heatmap Section + ActivityHeatmapView(dailyPoints: viewModel.dailyPointsMap) + + Divider() + + // Badge Collection Section + badgeCollectionSection + } + + // MARK: - Badge Collection Section + + private var badgeCollectionSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Badge Collection") + .font(.headline) + .foregroundColor(.primary) + + if viewModel.userBadges.isEmpty { + emptyBadgesView + } else { + badgeGrid + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + } + + private var badgeGrid: some View { + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4), + spacing: 12 + ) { + ForEach(viewModel.userBadges, id: \.id) { badge in + UserAvatarView( + username: badge.name, + badgeImageUrl: badge.imageUrl, + size: .medium, + showLoadingIndicator: true + ) + } + } + } + + private var emptyBadgesView: some View { + HStack { + Spacer() + VStack(spacing: 8) { + Image(systemName: "tray") + .font(.largeTitle) + .foregroundColor(.secondary) + Text("No badges collected yet") + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding(.vertical, 24) + Spacer() + } + } + + // MARK: - State Views + + private var loadingView: some View { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.5) + Text("Loading profile...") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 300) + } + + private func errorView(message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundColor(.red) + Text("Error Loading Profile") + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + Button("Try Again") { + Task { + await viewModel.fetchUserProfile(userId: userId) + } + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, minHeight: 300) + .padding() + } + + private var emptyView: some View { + VStack(spacing: 16) { + Image(systemName: "person.crop.circle.badge.questionmark") + .font(.largeTitle) + .foregroundColor(.secondary) + Text("User Not Found") + .font(.headline) + Text("This user profile could not be loaded.") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 300) + } +} + +#Preview { + UserProfileBottomSheetView( + userId: UUID(uuidString: "00000000-0000-0000-0000-000000000000")! + ) +} diff --git a/ios/escape/escape/Views/MapView.swift b/ios/escape/escape/Views/MapView.swift index 2adbd20..dba7c98 100644 --- a/ios/escape/escape/Views/MapView.swift +++ b/ios/escape/escape/Views/MapView.swift @@ -891,4 +891,4 @@ struct MapView: View { #Preview { MapView() -} +} \ No newline at end of file