5bc81d5b3b
iOS SwiftUI app with Supabase auth/realtime, Node.js backend, Docker/Supabase self-hosted infrastructure, and APNs scheduler. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
215 lines
7.6 KiB
Swift
215 lines
7.6 KiB
Swift
import SwiftUI
|
|
|
|
struct ProfileView: View {
|
|
let user: User
|
|
let isCurrentUser: Bool
|
|
@EnvironmentObject var appState: AppState
|
|
@StateObject private var viewModel: ProfileViewModel
|
|
@State private var showSettings = false
|
|
|
|
init(user: User, isCurrentUser: Bool) {
|
|
self.user = user
|
|
self.isCurrentUser = isCurrentUser
|
|
_viewModel = StateObject(wrappedValue: ProfileViewModel(userIdString: user.id))
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
Color.nightBase.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(spacing: 0) {
|
|
ProfileHeader(
|
|
user: user,
|
|
streak: viewModel.streak,
|
|
isCurrentUser: isCurrentUser
|
|
)
|
|
|
|
Divider().background(Color.nightBorder)
|
|
|
|
// Posts
|
|
if viewModel.isLoading {
|
|
ProgressView().tint(.nightPurple).padding(40)
|
|
} else if viewModel.posts.isEmpty {
|
|
EmptyProfilePosts()
|
|
} else {
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(viewModel.posts) { post in
|
|
PostRowView(post: post) {}
|
|
Divider().background(Color.nightBorder).padding(.leading, 16)
|
|
}
|
|
}
|
|
}
|
|
|
|
Color.clear.frame(height: 100)
|
|
}
|
|
}
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
if isCurrentUser {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button {
|
|
showSettings = true
|
|
} label: {
|
|
Image(systemName: "gearshape")
|
|
.foregroundColor(.nightSecondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showSettings) {
|
|
SettingsView().environmentObject(appState)
|
|
}
|
|
}
|
|
.task { await viewModel.load() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Profile Header
|
|
|
|
struct ProfileHeader: View {
|
|
let user: User
|
|
let streak: Int
|
|
let isCurrentUser: Bool
|
|
|
|
@State private var isFollowing: Bool
|
|
@State private var isFollowLoading = false
|
|
|
|
init(user: User, streak: Int, isCurrentUser: Bool) {
|
|
self.user = user
|
|
self.streak = streak
|
|
self.isCurrentUser = isCurrentUser
|
|
_isFollowing = State(initialValue: user.isFollowing)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
VStack(spacing: 16) {
|
|
AvatarView(user: user, size: 76)
|
|
|
|
VStack(spacing: 4) {
|
|
Text(user.displayName)
|
|
.font(.nightTitle(22))
|
|
.foregroundColor(.nightPrimary)
|
|
Text("@\(user.username)")
|
|
.font(.nightLabel(14))
|
|
.foregroundColor(.nightSecondary)
|
|
if let bio = user.bio {
|
|
Text(bio)
|
|
.font(.nightBody(14))
|
|
.foregroundColor(.nightPrimary.opacity(0.75))
|
|
.multilineTextAlignment(.center)
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
|
|
// Stats
|
|
HStack(spacing: 36) {
|
|
ProfileStat(value: user.postCount, label: "nächte")
|
|
ProfileStat(value: user.followerCount, label: "follower")
|
|
ProfileStat(value: user.followingCount, label: "following")
|
|
}
|
|
|
|
// Streak
|
|
if streak > 0 {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: streak >= 7 ? "flame.fill" : "flame")
|
|
.foregroundColor(streak >= 7 ? .orange : .nightSecondary)
|
|
Text("\(streak) Nächte in Folge")
|
|
.font(.nightLabel(13, weight: streak >= 7 ? .semibold : .regular))
|
|
.foregroundColor(streak >= 7 ? .nightPrimary : .nightSecondary)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 7)
|
|
.background(Color.nightRaised)
|
|
.clipShape(Capsule())
|
|
}
|
|
|
|
// Action button
|
|
if isCurrentUser {
|
|
Button("profil bearbeiten") {}
|
|
.font(.nightLabel(14, weight: .medium))
|
|
.foregroundColor(.nightPrimary)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 10)
|
|
.background(Color.nightRaised)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.strokeBorder(Color.nightBorder, lineWidth: 1)
|
|
)
|
|
.padding(.horizontal, 48)
|
|
} else {
|
|
Button {
|
|
Task { await toggleFollow() }
|
|
} label: {
|
|
Group {
|
|
if isFollowLoading { ProgressView().tint(isFollowing ? .nightPrimary : .nightBase) }
|
|
else {
|
|
Text(isFollowing ? "entfolgen" : "folgen")
|
|
.font(.nightLabel(14, weight: .semibold))
|
|
.foregroundColor(isFollowing ? .nightPrimary : .nightBase)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 10)
|
|
.background(isFollowing ? Color.nightRaised : Color.nightPrimary)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
.disabled(isFollowLoading)
|
|
.padding(.horizontal, 48)
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 28)
|
|
.padding(.bottom, 20)
|
|
}
|
|
}
|
|
|
|
func toggleFollow() async {
|
|
isFollowLoading = true
|
|
defer { isFollowLoading = false }
|
|
guard let uid = UUID(uuidString: user.id) else { return }
|
|
do {
|
|
if isFollowing {
|
|
try await supabase.unfollow(userId: uid)
|
|
} else {
|
|
try await supabase.follow(userId: uid)
|
|
}
|
|
isFollowing.toggle()
|
|
} catch { /* handle error */ }
|
|
}
|
|
}
|
|
|
|
struct ProfileStat: View {
|
|
let value: Int
|
|
let label: String
|
|
|
|
var body: some View {
|
|
VStack(spacing: 3) {
|
|
Text("\(value)")
|
|
.font(.nightTitle(18))
|
|
.foregroundColor(.nightPrimary)
|
|
Text(label)
|
|
.font(.nightLabel(12))
|
|
.foregroundColor(.nightSecondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct EmptyProfilePosts: View {
|
|
var body: some View {
|
|
VStack(spacing: 14) {
|
|
Image(systemName: "moon.zzz")
|
|
.font(.system(size: 36))
|
|
.foregroundColor(.nightTertiary)
|
|
Text("noch keine nächte")
|
|
.font(.nightLabel(15))
|
|
.foregroundColor(.nightSecondary)
|
|
}
|
|
.padding(.top, 60)
|
|
}
|
|
}
|