From 22797f101052d9ca5ee2ddfe17f66329079020d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 13:08:53 +0000 Subject: [PATCH 01/13] feat: Add user profile bottom sheet with activity heatmap - Created ActivityHeatmapView: GitHub-style heatmap with orange theme (#f54900) showing 30-day rolling window of user activity with point-based intensity - Created UserProfileViewModel: Data management layer for fetching user profiles, mission results, and badge collections with parallel async fetching - Created UserProfileBottomSheetView: Main profile view displaying username, avatar badge, activity heatmap with tooltips, and 4-column badge grid - Extended UserSupabase: Added getUserProfile function to fetch any user's data - Extended BadgeSupabase: Added fetchUserBadges and fetchBadgesByIds methods for fetching user badge collections - Updated NationalRankingView: Added tap handlers to ranking rows and podium positions to open user profiles in bottom sheet - Updated TeamRankingView: Added same tap functionality for team rankings - All components use UserAvatarView for consistent badge/avatar display - Bottom sheet supports .medium and .large detents with swipe-to-dismiss - Includes loading states, error handling, and empty state views --- .../Services/Supabases/BadgeSupabase.swift | 80 +++++++ .../Services/Supabases/UserSupabase.swift | 16 ++ .../ViewModels/UserProfileViewModel.swift | 133 +++++++++++ .../Ranking/NationalRankingView.swift | 48 +++- .../Components/Ranking/TeamRankingView.swift | 22 +- .../Shared/ActivityHeatmapView.swift | 225 ++++++++++++++++++ .../Shared/UserProfileBottomSheetView.swift | 185 ++++++++++++++ 7 files changed, 702 insertions(+), 7 deletions(-) create mode 100644 ios/escape/escape/ViewModels/UserProfileViewModel.swift create mode 100644 ios/escape/escape/Views/Components/Shared/ActivityHeatmapView.swift create mode 100644 ios/escape/escape/Views/Components/Shared/UserProfileBottomSheetView.swift 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..30de10a --- /dev/null +++ b/ios/escape/escape/ViewModels/UserProfileViewModel.swift @@ -0,0 +1,133 @@ +// +// UserProfileViewModel.swift +// escape +// +// Created by Claude on 2025-11-08. +// + +import Foundation +import SwiftUI + +@MainActor +@Observable +final class UserProfileViewModel { + // MARK: - Properties + + var isLoading = false + 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) + pointsMap[dateString, default: 0] += result.finalPoints + } + + 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 shelter badge for avatar display + func getShelterBadge() -> Badge? { + guard let user = user, + let shelterBadgeId = user.shelterBadgeId else { + return nil + } + + return userBadges.first { $0.id == shelterBadgeId } + } +} diff --git a/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift b/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift index 25c1921..65c222a 100644 --- a/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift +++ b/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift @@ -13,6 +13,8 @@ struct NationalRankingView: View { @State private var showSparkles = false @State private var currentUserId: UUID? @State private var isLoadingNational = false + @State private var showUserProfile = false + @State private var selectedUserId: UUID? var body: some View { ZStack { @@ -44,7 +46,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 +62,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 +81,23 @@ struct NationalRankingView: View { } } } + .sheet(isPresented: $showUserProfile) { + if let userId = selectedUserId { + UserProfileBottomSheetView(userId: userId) + .presentationDetents([.medium, .large]) + } + } .task { await loadRankings() startAnimations() } } + private func handleUserTap(userId: UUID) { + selectedUserId = userId + showUserProfile = true + } + private func loadRankings() async { isLoadingNational = true @@ -126,6 +140,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 +148,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 +161,8 @@ private struct TopThreePodium: View { color: Color.gray, crownColor: .silver, animate: animateRanks[1], - showCrown: showCrowns[1] + showCrown: showCrowns[1], + onTap: onTap ) } @@ -158,7 +174,8 @@ private struct TopThreePodium: View { color: Color.yellow, crownColor: .gold, animate: animateRanks[0], - showCrown: showCrowns[0] + showCrown: showCrowns[0], + onTap: onTap ) } @@ -170,7 +187,8 @@ private struct TopThreePodium: View { color: Color.orange, crownColor: .bronze, animate: animateRanks[2], - showCrown: showCrowns[2] + showCrown: showCrowns[2], + onTap: onTap ) } } @@ -197,6 +215,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 +285,11 @@ 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 { + if let userId = entry.userId { + onTap(userId) + } + } } } @@ -278,6 +302,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 +357,11 @@ private struct PodiumPosition: View { ) } .frame(maxWidth: .infinity) + .onTapGesture { + if let userId = entry.userId { + onTap(userId) + } + } } } @@ -341,6 +371,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 +490,11 @@ private struct RankingRow: View { } } } + .onTapGesture { + if let userId = entry.userId { + onTap(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..f3a95c0 100644 --- a/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift +++ b/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift @@ -13,6 +13,8 @@ struct TeamRankingView: View { @State private var animateEntries = false @State private var currentUserId: UUID? @State private var isLoadingTeam = false + @State private var showUserProfile = false + @State private var selectedUserId: UUID? var body: some View { ZStack { @@ -61,7 +63,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 +82,23 @@ struct TeamRankingView: View { } } } + .sheet(isPresented: $showUserProfile) { + if let userId = selectedUserId { + UserProfileBottomSheetView(userId: userId) + .presentationDetents([.medium, .large]) + } + } .task { await loadTeamRankings() startAnimations() } } + private func handleUserTap(userId: UUID) { + selectedUserId = userId + showUserProfile = true + } + private func loadTeamRankings() async { isLoadingTeam = true @@ -223,6 +237,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 +330,11 @@ private struct TeamRankingRow: View { } } } + .onTapGesture { + if let userId = entry.userId { + onTap(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..caf8269 --- /dev/null +++ b/ios/escape/escape/Views/Components/Shared/ActivityHeatmapView.swift @@ -0,0 +1,225 @@ +// +// 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 tooltipPosition: CGPoint = .zero + @State private var showTooltip = false + + 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) + + ZStack(alignment: .topLeading) { + // Heatmap grid + LazyVGrid(columns: columns, spacing: 4) { + ForEach(getLast30Days(), id: \.self) { date in + dayCellView(for: date) + } + } + + // Tooltip overlay + if showTooltip, let selectedDate = selectedDate { + tooltipView(for: selectedDate) + .position(tooltipPosition) + .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: - Day Cell View + + private func dayCellView(for date: Date) -> some View { + let dateString = formatDate(date) + let points = dailyPoints[dateString] ?? 0 + let color = getColorForPoints(points) + + return Rectangle() + .fill(color) + .frame(width: cellSize, height: cellSize) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.gray.opacity(0.2), lineWidth: 1) + ) + .onTapGesture { location in + handleDayTap(date: dateString, points: points, at: location) + } + } + + // 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.2), radius: 8, 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, at location: CGPoint) { + selectedDate = date + // Calculate tooltip position (above the cell) + tooltipPosition = CGPoint(x: location.x, y: location.y - 50) + showTooltip = true + + // Auto-hide tooltip after 2 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation { + showTooltip = false + } + } + } + + 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..5325542 --- /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 { + NavigationView { + 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.getShelterBadge()?.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")! + ) +} From 597ce39076828246edf05bd4b31f48150c8a9374 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 13:22:15 +0000 Subject: [PATCH 02/13] fix: Resolve compilation errors in user profile feature - Remove unnecessary optional unwrapping for entry.userId (not optional) - Replace NavigationView with NavigationStack for iOS 16+ compatibility - Fix conditional binding errors in ranking tap handlers --- .../Components/Ranking/NationalRankingView.swift | 12 +++--------- .../Views/Components/Ranking/TeamRankingView.swift | 4 +--- .../Shared/UserProfileBottomSheetView.swift | 2 +- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift b/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift index 65c222a..5cbf7ae 100644 --- a/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift +++ b/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift @@ -286,9 +286,7 @@ private struct WinnerBanner: View { .scaleEffect(animate ? 1 : 0.8) .opacity(animate ? 1 : 0) .onTapGesture { - if let userId = entry.userId { - onTap(userId) - } + onTap(entry.userId) } } } @@ -358,9 +356,7 @@ private struct PodiumPosition: View { } .frame(maxWidth: .infinity) .onTapGesture { - if let userId = entry.userId { - onTap(userId) - } + onTap(entry.userId) } } } @@ -491,9 +487,7 @@ private struct RankingRow: View { } } .onTapGesture { - if let userId = entry.userId { - onTap(userId) - } + onTap(entry.userId) } } diff --git a/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift b/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift index f3a95c0..8fc5be8 100644 --- a/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift +++ b/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift @@ -331,9 +331,7 @@ private struct TeamRankingRow: View { } } .onTapGesture { - if let userId = entry.userId { - onTap(userId) - } + onTap(entry.userId) } } diff --git a/ios/escape/escape/Views/Components/Shared/UserProfileBottomSheetView.swift b/ios/escape/escape/Views/Components/Shared/UserProfileBottomSheetView.swift index 5325542..74dce17 100644 --- a/ios/escape/escape/Views/Components/Shared/UserProfileBottomSheetView.swift +++ b/ios/escape/escape/Views/Components/Shared/UserProfileBottomSheetView.swift @@ -13,7 +13,7 @@ struct UserProfileBottomSheetView: View { @Environment(\.dismiss) private var dismiss var body: some View { - NavigationView { + NavigationStack { ScrollView { VStack(spacing: 24) { if viewModel.isLoading { From 7dd7e03095c322bdbbc53056e14f42933a9212db Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 13:28:53 +0000 Subject: [PATCH 03/13] fix: Correct data type conversions and field names - Convert Int64? finalPoints to Int for daily points aggregation - Change shelterBadgeId to profileBadgeId (correct User model field) - Rename getShelterBadge() to getProfileBadge() for clarity - Handle optional finalPoints with nil coalescing --- .../escape/ViewModels/UserProfileViewModel.swift | 11 ++++++----- .../Shared/UserProfileBottomSheetView.swift | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/ios/escape/escape/ViewModels/UserProfileViewModel.swift b/ios/escape/escape/ViewModels/UserProfileViewModel.swift index 30de10a..10d31ad 100644 --- a/ios/escape/escape/ViewModels/UserProfileViewModel.swift +++ b/ios/escape/escape/ViewModels/UserProfileViewModel.swift @@ -101,7 +101,8 @@ final class UserProfileViewModel { for result in missionResults { let dateString = formatter.string(from: result.createdAt) - pointsMap[dateString, default: 0] += result.finalPoints + let points = Int(result.finalPoints ?? 0) + pointsMap[dateString, default: 0] += points } self.dailyPointsMap = pointsMap @@ -121,13 +122,13 @@ final class UserProfileViewModel { return badges } - /// Gets the current user's shelter badge for avatar display - func getShelterBadge() -> Badge? { + /// Gets the current user's profile badge for avatar display + func getProfileBadge() -> Badge? { guard let user = user, - let shelterBadgeId = user.shelterBadgeId else { + let profileBadgeId = user.profileBadgeId else { return nil } - return userBadges.first { $0.id == shelterBadgeId } + return userBadges.first { $0.id == profileBadgeId.uuidString } } } diff --git a/ios/escape/escape/Views/Components/Shared/UserProfileBottomSheetView.swift b/ios/escape/escape/Views/Components/Shared/UserProfileBottomSheetView.swift index 74dce17..a4e6315 100644 --- a/ios/escape/escape/Views/Components/Shared/UserProfileBottomSheetView.swift +++ b/ios/escape/escape/Views/Components/Shared/UserProfileBottomSheetView.swift @@ -54,7 +54,7 @@ struct UserProfileBottomSheetView: View { VStack(spacing: 16) { UserAvatarView.profile( username: user.name ?? "Anonymous", - badgeImageUrl: viewModel.getShelterBadge()?.imageUrl, + badgeImageUrl: viewModel.getProfileBadge()?.imageUrl, size: .large ) From 8b3f71a02ca2e64f22f4ba2f37d3d5acecc9241b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 13:40:51 +0000 Subject: [PATCH 04/13] feat: Improve activity heatmap with weekday labels and better tooltip positioning - Add weekday labels (Mon-Sun) at the top of heatmap columns - Align grid cells to correct weekdays using padding cells - Fix tooltip positioning to appear directly above clicked cell - Use offset-based positioning instead of absolute position for tooltip - Add smooth animation for tooltip show/hide - Increase tooltip display duration to 2.5 seconds --- .../Shared/ActivityHeatmapView.swift | 119 +++++++++++++----- 1 file changed, 86 insertions(+), 33 deletions(-) diff --git a/ios/escape/escape/Views/Components/Shared/ActivityHeatmapView.swift b/ios/escape/escape/Views/Components/Shared/ActivityHeatmapView.swift index caf8269..ad6e078 100644 --- a/ios/escape/escape/Views/Components/Shared/ActivityHeatmapView.swift +++ b/ios/escape/escape/Views/Components/Shared/ActivityHeatmapView.swift @@ -15,8 +15,8 @@ struct ActivityHeatmapView: View { private let baseColor = Color(hex: "f54900") @State private var selectedDate: String? - @State private var tooltipPosition: CGPoint = .zero @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 @@ -27,20 +27,31 @@ struct ActivityHeatmapView: View { .font(.headline) .foregroundColor(.primary) - ZStack(alignment: .topLeading) { - // Heatmap grid - LazyVGrid(columns: columns, spacing: 4) { - ForEach(getLast30Days(), id: \.self) { date in - dayCellView(for: date) + 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) - .position(tooltipPosition) - .transition(.opacity) - .zIndex(100) + // Tooltip overlay + if showTooltip, let selectedDate = selectedDate { + tooltipView(for: selectedDate) + .offset(x: tooltipFrame.minX - 40, y: tooltipFrame.minY - 70) + .transition(.opacity) + .zIndex(100) + } } } @@ -67,24 +78,40 @@ struct ActivityHeatmapView: View { .cornerRadius(12) } + // MARK: - Weekday Labels + + private var weekdayLabels: some View { + HStack(spacing: 4) { + ForEach(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], id: \.self) { day in + Text(day) + .font(.caption2) + .foregroundColor(.secondary) + .frame(width: cellSize) + } + } + } + // MARK: - Day Cell View - private func dayCellView(for date: Date) -> some 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 Rectangle() - .fill(color) - .frame(width: cellSize, height: cellSize) - .cornerRadius(4) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Color.gray.opacity(0.2), lineWidth: 1) - ) - .onTapGesture { location in - handleDayTap(date: dateString, points: points, at: location) - } + 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 @@ -106,7 +133,7 @@ struct ActivityHeatmapView: View { .background( RoundedRectangle(cornerRadius: 8) .fill(Color(.systemBackground)) - .shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 2) + .shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2) ) .overlay( RoundedRectangle(cornerRadius: 8) @@ -116,20 +143,46 @@ struct ActivityHeatmapView: View { // MARK: - Helper Methods - private func handleDayTap(date: String, points: Int, at location: CGPoint) { + private func handleDayTap(date: String, points: Int, frame: CGRect) { selectedDate = date - // Calculate tooltip position (above the cell) - tooltipPosition = CGPoint(x: location.x, y: location.y - 50) - showTooltip = true + tooltipFrame = frame - // Auto-hide tooltip after 2 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - withAnimation { + 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()) From d437e1ba26812e05bdcf4040d4c7cc3d69088655 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 15:02:28 +0000 Subject: [PATCH 05/13] fix: Align weekday labels with heatmap grid using LazyVGrid Changed weekday labels from HStack to LazyVGrid to ensure perfect alignment with the heatmap grid columns below. --- .../escape/Views/Components/Shared/ActivityHeatmapView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/escape/escape/Views/Components/Shared/ActivityHeatmapView.swift b/ios/escape/escape/Views/Components/Shared/ActivityHeatmapView.swift index ad6e078..60502b4 100644 --- a/ios/escape/escape/Views/Components/Shared/ActivityHeatmapView.swift +++ b/ios/escape/escape/Views/Components/Shared/ActivityHeatmapView.swift @@ -81,12 +81,12 @@ struct ActivityHeatmapView: View { // MARK: - Weekday Labels private var weekdayLabels: some View { - HStack(spacing: 4) { + 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) + .frame(width: cellSize, alignment: .center) } } } From d8c02914c217febfc5a55300c07310ea7daae3a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 15:09:52 +0000 Subject: [PATCH 06/13] feat: Add user profile bottom sheet to GroupDetailView MemberRowView When users tap on a member row in the group details view, it now opens a bottom sheet showing the user's profile with activity heatmap and badge collection. Includes haptic feedback on tap. --- .../Views/Components/Group/GroupDetailView.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) 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]) + } } } From edf84a30de596d4d8b09494005fc59f595834855 Mon Sep 17 00:00:00 2001 From: RedBlueBird Date: Sun, 9 Nov 2025 00:57:02 +0900 Subject: [PATCH 07/13] modified tooltip position --- .../escape/Views/Components/Shared/ActivityHeatmapView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/escape/escape/Views/Components/Shared/ActivityHeatmapView.swift b/ios/escape/escape/Views/Components/Shared/ActivityHeatmapView.swift index 60502b4..a40ccab 100644 --- a/ios/escape/escape/Views/Components/Shared/ActivityHeatmapView.swift +++ b/ios/escape/escape/Views/Components/Shared/ActivityHeatmapView.swift @@ -48,7 +48,7 @@ struct ActivityHeatmapView: View { // Tooltip overlay if showTooltip, let selectedDate = selectedDate { tooltipView(for: selectedDate) - .offset(x: tooltipFrame.minX - 40, y: tooltipFrame.minY - 70) + .offset(x: tooltipFrame.minX, y: tooltipFrame.minY) .transition(.opacity) .zIndex(100) } From 0949c1aca2733b16c4e1d19ff3739b077b6183a3 Mon Sep 17 00:00:00 2001 From: RedBlueBird Date: Sun, 9 Nov 2025 01:10:51 +0900 Subject: [PATCH 08/13] test --- ios/escape/escape/Views/MapView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 865ea6e86991a5b9ab4611fc1b7d7cd83bfeed6e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 16:23:39 +0000 Subject: [PATCH 09/13] fix: Set initial loading state to true in UserProfileViewModel Fixed blank sheet issue on first open by starting isLoading as true. This prevents the "User Not Found" empty state from flashing before the async fetch begins in the .task modifier. --- ios/escape/escape/ViewModels/UserProfileViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/escape/escape/ViewModels/UserProfileViewModel.swift b/ios/escape/escape/ViewModels/UserProfileViewModel.swift index 10d31ad..8636578 100644 --- a/ios/escape/escape/ViewModels/UserProfileViewModel.swift +++ b/ios/escape/escape/ViewModels/UserProfileViewModel.swift @@ -13,7 +13,7 @@ import SwiftUI final class UserProfileViewModel { // MARK: - Properties - var isLoading = false + var isLoading = true var errorMessage: String? var user: User? var missionResults: [MissionResult] = [] From ade00ce84ae50ce5fb9fb7de5214c75d548b0c27 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 16:37:25 +0000 Subject: [PATCH 10/13] fix: Fix blank sheet issue in ranking views by using .sheet(item:) Changed from .sheet(isPresented:) with optional unwrapping to .sheet(item:) which properly handles the optional UUID binding. This prevents the blank sheet issue on first open by ensuring the sheet content is only created when selectedUserId has a value. - Removed showUserProfile state variable (no longer needed) - Updated handleUserTap to only set selectedUserId - Applied fix to both NationalRankingView and TeamRankingView --- .../Views/Components/Ranking/NationalRankingView.swift | 10 +++------- .../Views/Components/Ranking/TeamRankingView.swift | 10 +++------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift b/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift index 5cbf7ae..fcd986b 100644 --- a/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift +++ b/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift @@ -13,7 +13,6 @@ struct NationalRankingView: View { @State private var showSparkles = false @State private var currentUserId: UUID? @State private var isLoadingNational = false - @State private var showUserProfile = false @State private var selectedUserId: UUID? var body: some View { @@ -81,11 +80,9 @@ struct NationalRankingView: View { } } } - .sheet(isPresented: $showUserProfile) { - if let userId = selectedUserId { - UserProfileBottomSheetView(userId: userId) - .presentationDetents([.medium, .large]) - } + .sheet(item: $selectedUserId) { userId in + UserProfileBottomSheetView(userId: userId) + .presentationDetents([.medium, .large]) } .task { await loadRankings() @@ -95,7 +92,6 @@ struct NationalRankingView: View { private func handleUserTap(userId: UUID) { selectedUserId = userId - showUserProfile = true } private func loadRankings() async { diff --git a/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift b/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift index 8fc5be8..36cb6f2 100644 --- a/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift +++ b/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift @@ -13,7 +13,6 @@ struct TeamRankingView: View { @State private var animateEntries = false @State private var currentUserId: UUID? @State private var isLoadingTeam = false - @State private var showUserProfile = false @State private var selectedUserId: UUID? var body: some View { @@ -82,11 +81,9 @@ struct TeamRankingView: View { } } } - .sheet(isPresented: $showUserProfile) { - if let userId = selectedUserId { - UserProfileBottomSheetView(userId: userId) - .presentationDetents([.medium, .large]) - } + .sheet(item: $selectedUserId) { userId in + UserProfileBottomSheetView(userId: userId) + .presentationDetents([.medium, .large]) } .task { await loadTeamRankings() @@ -96,7 +93,6 @@ struct TeamRankingView: View { private func handleUserTap(userId: UUID) { selectedUserId = userId - showUserProfile = true } private func loadTeamRankings() async { From ac8f3830f96d7e1aeaadaa50e694ef162e5af047 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 16:42:59 +0000 Subject: [PATCH 11/13] fix: Add Identifiable conformance to UUID for sheet(item:) modifier Added UUID extension to conform to Identifiable protocol, which is required by the .sheet(item:) modifier. UUID's id is itself. --- .../Views/Components/Ranking/NationalRankingView.swift | 6 ++++++ .../escape/Views/Components/Ranking/TeamRankingView.swift | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift b/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift index fcd986b..99b23d6 100644 --- a/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift +++ b/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift @@ -628,3 +628,9 @@ extension Color { static let silver = Color(red: 0.75, green: 0.75, blue: 0.75) static let bronze = Color(red: 0.8, green: 0.5, blue: 0.2) } + +// MARK: - UUID Extension + +extension UUID: Identifiable { + public var id: UUID { self } +} diff --git a/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift b/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift index 36cb6f2..b63c8cf 100644 --- a/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift +++ b/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift @@ -439,3 +439,9 @@ private struct ErrorView: View { .padding() } } + +// MARK: - UUID Extension + +extension UUID: Identifiable { + public var id: UUID { self } +} From cfa8fe0be273ec7a290ba4ccbba14adc91c3b202 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 16:46:16 +0000 Subject: [PATCH 12/13] fix: Remove redundant UUID Identifiable conformance UUID already conforms to Identifiable in Foundation, so the custom extensions were causing redeclaration errors. Removed both extensions. --- .../Views/Components/Ranking/NationalRankingView.swift | 6 ------ .../escape/Views/Components/Ranking/TeamRankingView.swift | 6 ------ 2 files changed, 12 deletions(-) diff --git a/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift b/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift index 99b23d6..fcd986b 100644 --- a/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift +++ b/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift @@ -628,9 +628,3 @@ extension Color { static let silver = Color(red: 0.75, green: 0.75, blue: 0.75) static let bronze = Color(red: 0.8, green: 0.5, blue: 0.2) } - -// MARK: - UUID Extension - -extension UUID: Identifiable { - public var id: UUID { self } -} diff --git a/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift b/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift index b63c8cf..36cb6f2 100644 --- a/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift +++ b/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift @@ -439,9 +439,3 @@ private struct ErrorView: View { .padding() } } - -// MARK: - UUID Extension - -extension UUID: Identifiable { - public var id: UUID { self } -} From 744dde4a545a1c574ef7823af0028dbe36a3305b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 16:51:08 +0000 Subject: [PATCH 13/13] fix: Wrap UUID in Identifiable struct for .sheet(item:) Created IdentifiableUUID wrapper struct to make UUID work with .sheet(item:) modifier. UUID's Identifiable conformance may not be available in the current Swift/iOS version, so this wrapper provides the necessary conformance. --- .../Components/Ranking/NationalRankingView.swift | 13 +++++++++---- .../Views/Components/Ranking/TeamRankingView.swift | 13 +++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift b/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift index fcd986b..68a4170 100644 --- a/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift +++ b/ios/escape/escape/Views/Components/Ranking/NationalRankingView.swift @@ -7,13 +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: UUID? + @State private var selectedUserId: IdentifiableUUID? var body: some View { ZStack { @@ -80,8 +85,8 @@ struct NationalRankingView: View { } } } - .sheet(item: $selectedUserId) { userId in - UserProfileBottomSheetView(userId: userId) + .sheet(item: $selectedUserId) { identifiableUserId in + UserProfileBottomSheetView(userId: identifiableUserId.id) .presentationDetents([.medium, .large]) } .task { @@ -91,7 +96,7 @@ struct NationalRankingView: View { } private func handleUserTap(userId: UUID) { - selectedUserId = userId + selectedUserId = IdentifiableUUID(id: userId) } private func loadRankings() async { diff --git a/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift b/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift index 36cb6f2..da498e4 100644 --- a/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift +++ b/ios/escape/escape/Views/Components/Ranking/TeamRankingView.swift @@ -7,13 +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: UUID? + @State private var selectedUserId: IdentifiableUUID? var body: some View { ZStack { @@ -81,8 +86,8 @@ struct TeamRankingView: View { } } } - .sheet(item: $selectedUserId) { userId in - UserProfileBottomSheetView(userId: userId) + .sheet(item: $selectedUserId) { identifiableUserId in + UserProfileBottomSheetView(userId: identifiableUserId.id) .presentationDetents([.medium, .large]) } .task { @@ -92,7 +97,7 @@ struct TeamRankingView: View { } private func handleUserTap(userId: UUID) { - selectedUserId = userId + selectedUserId = IdentifiableUUID(id: userId) } private func loadTeamRankings() async {