Initial commit: nightly iOS app + Supabase backend
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>
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user