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>
200 lines
5.8 KiB
Swift
200 lines
5.8 KiB
Swift
import SwiftUI
|
|
|
|
struct MainTabView: View {
|
|
@EnvironmentObject var appState: AppState
|
|
@StateObject private var realtime = RealtimeService()
|
|
@State private var selectedTab = 0
|
|
@State private var showCompose = false
|
|
@State private var showSettings = false
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .bottom) {
|
|
Color.nightBase.ignoresSafeArea()
|
|
|
|
// Content
|
|
TabContent(
|
|
selectedTab: selectedTab,
|
|
realtime: realtime
|
|
)
|
|
.environmentObject(appState)
|
|
|
|
// Floating Tab Bar
|
|
FloatingTabBar(
|
|
selectedTab: $selectedTab,
|
|
windowState: appState.windowState,
|
|
onCompose: { showCompose = true },
|
|
onSettings: { showSettings = true }
|
|
)
|
|
}
|
|
.ignoresSafeArea(edges: .bottom)
|
|
.sheet(isPresented: $showCompose) {
|
|
ComposeView().environmentObject(appState)
|
|
}
|
|
.sheet(isPresented: $showSettings) {
|
|
SettingsView().environmentObject(appState)
|
|
}
|
|
.onDisappear {
|
|
Task { await realtime.stopListening() }
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Tab Content
|
|
|
|
private struct TabContent: View {
|
|
let selectedTab: Int
|
|
@ObservedObject var realtime: RealtimeService
|
|
@EnvironmentObject var appState: AppState
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
FeedView(realtime: realtime)
|
|
.environmentObject(appState)
|
|
.opacity(selectedTab == 0 ? 1 : 0)
|
|
.allowsHitTesting(selectedTab == 0)
|
|
|
|
DiaryView()
|
|
.environmentObject(appState)
|
|
.opacity(selectedTab == 1 ? 1 : 0)
|
|
.allowsHitTesting(selectedTab == 1)
|
|
|
|
ProfileView(
|
|
user: appState.currentUser ?? .preview,
|
|
isCurrentUser: true
|
|
)
|
|
.environmentObject(appState)
|
|
.opacity(selectedTab == 2 ? 1 : 0)
|
|
.allowsHitTesting(selectedTab == 2)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Floating Tab Bar
|
|
|
|
struct FloatingTabBar: View {
|
|
@Binding var selectedTab: Int
|
|
let windowState: AppState.WindowState
|
|
let onCompose: () -> Void
|
|
let onSettings: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(spacing: 0) {
|
|
// Feed
|
|
TabIcon(icon: "moon.stars", activeIcon: "moon.stars.fill", isSelected: selectedTab == 0) {
|
|
selectedTab = 0
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Diary
|
|
TabIcon(icon: "book.closed", activeIcon: "book.closed.fill", isSelected: selectedTab == 1) {
|
|
selectedTab = 1
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Center: Compose
|
|
ComposeTabButton(windowState: windowState, onTap: onCompose)
|
|
|
|
Spacer()
|
|
|
|
// Profile
|
|
TabIcon(icon: "person", activeIcon: "person.fill", isSelected: selectedTab == 2) {
|
|
selectedTab = 2
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Settings
|
|
TabIcon(icon: "gearshape", activeIcon: "gearshape.fill", isSelected: false) {
|
|
onSettings()
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 10)
|
|
.padding(.bottom, 18)
|
|
.background(
|
|
Rectangle()
|
|
.fill(.ultraThinMaterial.opacity(0.8))
|
|
.background(Color.nightBase.opacity(0.85))
|
|
.ignoresSafeArea()
|
|
)
|
|
.overlay(
|
|
Rectangle().fill(Color.nightBorder).frame(height: 1),
|
|
alignment: .top
|
|
)
|
|
}
|
|
}
|
|
|
|
struct TabIcon: View {
|
|
let icon: String
|
|
let activeIcon: String
|
|
let isSelected: Bool
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
Image(systemName: isSelected ? activeIcon : icon)
|
|
.font(.system(size: 21))
|
|
.foregroundColor(isSelected ? .nightPrimary : .nightSecondary)
|
|
.frame(width: 44, height: 44)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ComposeTabButton: View {
|
|
let windowState: AppState.WindowState
|
|
let onTap: () -> Void
|
|
@State private var glow = false
|
|
|
|
var body: some View {
|
|
Button {
|
|
guard windowState == .open else { return }
|
|
onTap()
|
|
} label: {
|
|
ZStack {
|
|
if windowState == .open {
|
|
Circle()
|
|
.fill(Color.nightPurple.opacity(0.18))
|
|
.frame(width: 62, height: 62)
|
|
.scaleEffect(glow ? 1.15 : 1.0)
|
|
.animation(.easeInOut(duration: 2.2).repeatForever(autoreverses: true), value: glow)
|
|
}
|
|
Circle()
|
|
.fill(buttonFill)
|
|
.frame(width: 50, height: 50)
|
|
Image(systemName: buttonIcon)
|
|
.font(.system(size: 19, weight: .semibold))
|
|
.foregroundColor(.white)
|
|
}
|
|
}
|
|
.onAppear { glow = true }
|
|
.animation(.easeInOut(duration: 0.4), value: windowState)
|
|
}
|
|
|
|
var buttonFill: AnyShapeStyle {
|
|
switch windowState {
|
|
case .open:
|
|
return AnyShapeStyle(LinearGradient(
|
|
colors: [Color(hex: "8B5CF6"), Color(hex: "6D28D9")],
|
|
startPoint: .topLeading, endPoint: .bottomTrailing
|
|
))
|
|
case .posted:
|
|
return AnyShapeStyle(LinearGradient(
|
|
colors: [Color(hex: "059669"), Color(hex: "047857")],
|
|
startPoint: .top, endPoint: .bottom
|
|
))
|
|
default:
|
|
return AnyShapeStyle(Color.nightRaised)
|
|
}
|
|
}
|
|
|
|
var buttonIcon: String {
|
|
switch windowState {
|
|
case .open: return "plus"
|
|
case .posted: return "checkmark"
|
|
default: return "moon.zzz"
|
|
}
|
|
}
|
|
}
|