diff --git a/tg_test/ContentView.swift b/tg_test/ContentView.swift index 978bae5..de0710b 100644 --- a/tg_test/ContentView.swift +++ b/tg_test/ContentView.swift @@ -8,28 +8,55 @@ import SwiftUI struct ContentView: View { + @State private var selectedIndex = 0 + private let tabs = TabItem.allTabs + var body: some View { - TabView { - HomeView() - .tabItem { - Label("Home", systemImage: "house.fill") - } - - SearchView() - .tabItem { - Label("Search", systemImage: "magnifyingglass") - } - - NotificationsView() - .tabItem { - Label("Notifications", systemImage: "bell.fill") - } - - ProfileView() - .tabItem { - Label("Profile", systemImage: "person.fill") + ZStack { + // Background View Container + Group { + switch selectedIndex { + case 0: + HomeView() + case 1: + SearchView() + case 2: + NotificationsView() + case 3: + ProfileView() + case 4: + AnalyticsView() + case 5: + MessagesView() + case 6: + FavoritesView() + case 7: + DownloadsView() + case 8: + CloudView() + case 9: + SettingsView() + case 10: + HelpView() + default: + HomeView() } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .id(selectedIndex) // Enables smooth transition animation per view change + .transition(.asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.98)), + removal: .opacity + )) + .safeAreaInset(edge: .bottom) { + // Spacer height corresponding to the WheelNavBar height + Color.clear.frame(height: 120) + } } + .overlay(alignment: .bottom) { + WheelNavBar(selectedIndex: $selectedIndex, tabs: tabs) + } + .ignoresSafeArea(.keyboard, edges: .bottom) } } diff --git a/tg_test/ExtraViews.swift b/tg_test/ExtraViews.swift new file mode 100644 index 0000000..ad85e35 --- /dev/null +++ b/tg_test/ExtraViews.swift @@ -0,0 +1,276 @@ +// +// ExtraViews.swift +// tg_test +// + +import SwiftUI + +// MARK: - Analytics View +struct AnalyticsView: View { + private let metrics = [ + ("Daily Active Users", "12.4K", "+18%", Color.green), + ("Avg. Session Time", "8m 42s", "+5%", Color.blue), + ("Retention Rate", "68.2%", "+2.4%", Color.purple), + ("Conversion Rate", "4.15%", "-0.8%", Color.orange) + ] + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Overview Highlights") + .font(.headline) + .padding(.horizontal) + + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + ForEach(metrics, id: \.0) { item in + VStack(alignment: .leading, spacing: 8) { + Text(item.0) + .font(.caption) + .foregroundStyle(.secondary) + Text(item.1) + .font(.title2) + .bold() + Text(item.2) + .font(.caption) + .bold() + .foregroundStyle(item.3) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + .padding(.horizontal) + + VStack(alignment: .leading, spacing: 12) { + Text("Performance Chart") + .font(.headline) + + RoundedRectangle(cornerRadius: 12) + .fill(LinearGradient(colors: [.blue.opacity(0.6), .purple.opacity(0.6)], startPoint: .topLeading, endPoint: .bottomTrailing)) + .frame(height: 180) + .overlay( + Image(systemName: "waveform.path.ecg") + .font(.system(size: 70)) + .foregroundStyle(.white.opacity(0.8)) + ) + } + .padding(.horizontal) + } + .padding(.top) + } + .navigationTitle("Analytics") + } + } +} + +// MARK: - Messages View +struct MessagesView: View { + struct Chat: Identifiable { + let id = UUID() + let name: String + let message: String + let time: String + let unread: Bool + } + + private let chats = [ + Chat(name: "Sarah Connor", message: "Hey, did you review the latest wheel UI?", time: "10:42 AM", unread: true), + Chat(name: "Tech Team", message: "Deployment complete for v1.0.4", time: "09:15 AM", unread: true), + Chat(name: "Alex Rivera", message: "See you at the meeting tomorrow!", time: "Yesterday", unread: false), + Chat(name: "Design Guild", message: "New Figma mockups are uploaded.", time: "Tuesday", unread: false) + ] + + var body: some View { + NavigationStack { + List(chats) { chat in + HStack(spacing: 12) { + Circle() + .fill(chat.unread ? Color.teal : Color.gray.opacity(0.3)) + .frame(width: 44, height: 44) + .overlay( + Text(String(chat.name.prefix(1))) + .font(.headline) + .foregroundStyle(chat.unread ? .white : .primary) + ) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(chat.name) + .font(.headline) + Spacer() + Text(chat.time) + .font(.caption) + .foregroundStyle(.secondary) + } + + Text(chat.message) + .font(.subheadline) + .foregroundStyle(chat.unread ? .primary : .secondary) + .lineLimit(1) + } + } + .padding(.vertical, 4) + } + .navigationTitle("Messages") + } + } +} + +// MARK: - Favorites View +struct FavoritesView: View { + private let favoriteItems = [ + ("SwiftUI Wheel Navigation", "UI Component", "star.fill", Color.yellow), + ("Async/Await Best Practices", "Article", "doc.text.fill", Color.blue), + ("Color Schemes & Typography", "Guide", "paintpalette.fill", Color.purple), + ("Custom Canvas Rendering", "Tutorial", "paintbrush.fill", Color.pink) + ] + + var body: some View { + NavigationStack { + List(favoriteItems, id: \.0) { item in + HStack(spacing: 14) { + Image(systemName: item.2) + .font(.title2) + .foregroundStyle(item.3) + .frame(width: 36) + + VStack(alignment: .leading, spacing: 4) { + Text(item.0) + .font(.headline) + Text(item.1) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "heart.fill") + .foregroundStyle(.pink) + } + .padding(.vertical, 4) + } + .navigationTitle("Favorites") + } + } +} + +// MARK: - Downloads View +struct DownloadsView: View { + var body: some View { + NavigationStack { + VStack(spacing: 20) { + Spacer() + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 70)) + .foregroundStyle(.cyan) + + Text("No Active Downloads") + .font(.title2) + .bold() + + Text("Files you download will show up here for offline access.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + Button(action: {}) { + Label("Explore Content", systemImage: "sparkles") + .font(.headline) + .padding() + .background(Color.cyan) + .foregroundStyle(.white) + .clipShape(Capsule()) + } + Spacer() + } + .navigationTitle("Downloads") + } + } +} + +// MARK: - Cloud View +struct CloudView: View { + var body: some View { + NavigationStack { + List { + Section("Storage Usage") { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Cloud Storage") + .font(.headline) + Spacer() + Text("42.5 GB / 100 GB") + .font(.subheadline) + .foregroundStyle(.secondary) + } + ProgressView(value: 0.425) + .tint(.blue) + } + .padding(.vertical, 4) + } + + Section("Synced Repositories") { + Label("iOS App Test Project", systemImage: "folder.fill") + Label("Design Assets 2026", systemImage: "photo.on.rectangle.angled") + Label("User Data Backups", systemImage: "internaldrive.fill") + } + } + .navigationTitle("Cloud") + } + } +} + +// MARK: - Settings View +struct SettingsView: View { + @State private var autoSync = true + @State private var hapticFeedback = true + @State private var wheelSpeed = 1.0 + + var body: some View { + NavigationStack { + Form { + Section("Navigation Wheel Settings") { + Toggle("Haptic Feedback", isOn: $hapticFeedback) + Toggle("Auto Sync Selection", isOn: $autoSync) + } + + Section("App Preferences") { + HStack { + Text("Theme Color") + Spacer() + Text("Dynamic") + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("Settings") + } + } +} + +// MARK: - Help View +struct HelpView: View { + var body: some View { + NavigationStack { + List { + Section("Frequent Questions") { + DisclosureGroup("How do I use the wheel nav bar?") { + Text("Swipe horizontally on the bottom dial or tap any icon to switch pages instantly. You can also tap the left/right arrows for step-by-step navigation.") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.vertical, 4) + } + DisclosureGroup("How many pages can the wheel hold?") { + Text("The wheel supports virtually unlimited pages! Drag left or right to scroll smoothly through 10, 11, or more pages.") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.vertical, 4) + } + } + } + .navigationTitle("Help & Support") + } + } +} diff --git a/tg_test/TabItem.swift b/tg_test/TabItem.swift new file mode 100644 index 0000000..c97639a --- /dev/null +++ b/tg_test/TabItem.swift @@ -0,0 +1,28 @@ +// +// TabItem.swift +// tg_test +// + +import SwiftUI + +struct TabItem: Identifiable, Equatable { + let id: Int + let title: String + let icon: String + let color: Color + var badge: String? = nil + + static let allTabs: [TabItem] = [ + TabItem(id: 0, title: "Home", icon: "house.fill", color: .blue), + TabItem(id: 1, title: "Search", icon: "magnifyingglass", color: .indigo), + TabItem(id: 2, title: "Notifications", icon: "bell.fill", color: .orange, badge: "3"), + TabItem(id: 3, title: "Profile", icon: "person.fill", color: .purple), + TabItem(id: 4, title: "Analytics", icon: "chart.bar.xaxis", color: .green), + TabItem(id: 5, title: "Messages", icon: "bubble.left.and.bubble.right.fill", color: .teal, badge: "5"), + TabItem(id: 6, title: "Favorites", icon: "heart.fill", color: .pink), + TabItem(id: 7, title: "Downloads", icon: "arrow.down.circle.fill", color: .cyan), + TabItem(id: 8, title: "Cloud", icon: "icloud.fill", color: .blue), + TabItem(id: 9, title: "Settings", icon: "gearshape.fill", color: .gray), + TabItem(id: 10, title: "Help", icon: "questionmark.circle.fill", color: .mint) + ] +} diff --git a/tg_test/WheelNavBar.swift b/tg_test/WheelNavBar.swift new file mode 100644 index 0000000..26f4f6a --- /dev/null +++ b/tg_test/WheelNavBar.swift @@ -0,0 +1,212 @@ +// +// WheelNavBar.swift +// tg_test +// + +import SwiftUI + +struct WheelNavBar: View { + @Binding var selectedIndex: Int + let tabs: [TabItem] + + @GestureState private var dragTranslation: CGFloat = 0 + @State private var activeDragIndex: Int = 0 + + // Haptic feedback generator + private let feedback = UIImpactFeedbackGenerator(style: .medium) + + private let itemSpacing: CGFloat = 62 + + var body: some View { + let currentTab = tabs[min(max(selectedIndex, 0), tabs.count - 1)] + + VStack(spacing: 8) { + // MARK: - Header Pill (Active Tab Title & Indicator) + HStack(spacing: 8) { + // Previous button + Button(action: { + selectPrevious() + }) { + Image(systemName: "chevron.left") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(selectedIndex > 0 ? currentTab.color : Color.gray.opacity(0.4)) + .frame(width: 28, height: 28) + .background(Color.white.opacity(0.15)) + .clipShape(Circle()) + } + .disabled(selectedIndex == 0) + + Spacer() + + // Active Title and Index Pill + HStack(spacing: 8) { + Image(systemName: currentTab.icon) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(currentTab.color) + + Text(currentTab.title) + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundStyle(.primary) + + Text("\(selectedIndex + 1)/\(tabs.count)") + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(currentTab.color.opacity(0.2)) + .foregroundStyle(currentTab.color) + .clipShape(Capsule()) + } + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background( + Capsule() + .fill(.regularMaterial) + .shadow(color: currentTab.color.opacity(0.25), radius: 8, x: 0, y: 3) + ) + + Spacer() + + // Next button + Button(action: { + selectNext() + }) { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(selectedIndex < tabs.count - 1 ? currentTab.color : Color.gray.opacity(0.4)) + .frame(width: 28, height: 28) + .background(Color.white.opacity(0.15)) + .clipShape(Circle()) + } + .disabled(selectedIndex == tabs.count - 1) + } + .padding(.horizontal, 20) + + // MARK: - Wheel Dial Track + GeometryReader { geometry in + let centerPoint = geometry.size.width / 2 + let effectiveIndex = Double(selectedIndex) - Double(dragTranslation / itemSpacing) + + ZStack { + // Central Selector Ring Glow + Circle() + .fill(currentTab.color.opacity(0.15)) + .frame(width: 56, height: 56) + .overlay( + Circle() + .stroke(currentTab.color, lineWidth: 2.5) + ) + .position(x: centerPoint, y: 32) + .shadow(color: currentTab.color.opacity(0.4), radius: 10) + + // Rotary Wheel Items + ForEach(tabs) { tab in + let distance = Double(tab.id) - effectiveIndex + let xOffset = distance * itemSpacing + // Arc wheel curvature (quadratic Y offset for wheel effect) + let yOffset = pow(distance, 2) * 2.2 + // Scaling factor (center item is largest) + let scale = max(0.55, 1.15 - min(abs(distance) * 0.18, 0.6)) + // Opacity falloff + let opacity = max(0.25, 1.0 - min(abs(distance) * 0.22, 0.75)) + // 3D Y Rotation Angle for wheel perspective + let rotationY = min(max(distance * -18.0, -60.0), 60.0) + + let isSelected = tab.id == selectedIndex + + VStack(spacing: 2) { + ZStack { + Circle() + .fill(isSelected ? tab.color : Color(.tertiarySystemFill)) + .frame(width: 44, height: 44) + .shadow(color: isSelected ? tab.color.opacity(0.5) : .clear, radius: 6) + + Image(systemName: tab.icon) + .font(.system(size: isSelected ? 20 : 16, weight: isSelected ? .bold : .medium)) + .foregroundStyle(isSelected ? .white : .primary) + + if let badge = tab.badge { + Text(badge) + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(4) + .background(Color.red) + .clipShape(Circle()) + .offset(x: 14, y: -14) + } + } + } + .scaleEffect(scale) + .opacity(opacity) + .rotation3DEffect( + .degrees(rotationY), + axis: (x: 0.0, y: 1.0, z: 0.0), + perspective: 0.5 + ) + .position(x: centerPoint + xOffset, y: 32 + yOffset) + .onTapGesture { + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { + selectedIndex = tab.id + feedback.impactOccurred() + } + } + } + } + } + .frame(height: 72) + .contentShape(Rectangle()) + .gesture( + DragGesture() + .updating($dragTranslation) { value, state, _ in + state = value.translation.width + } + .onChanged { value in + let calcIndex = Double(selectedIndex) - Double(value.translation.width / itemSpacing) + let rounded = min(max(Int(round(calcIndex)), 0), tabs.count - 1) + if rounded != activeDragIndex { + activeDragIndex = rounded + feedback.impactOccurred() + } + } + .onEnded { value in + let velocity = value.predictedEndTranslation.width / itemSpacing + let calcIndex = Double(selectedIndex) - Double(value.translation.width / itemSpacing) - (velocity * 0.2) + let targetIndex = min(max(Int(round(calcIndex)), 0), tabs.count - 1) + + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { + selectedIndex = targetIndex + } + } + ) + } + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .fill(.ultraThinMaterial) + .shadow(color: Color.black.opacity(0.12), radius: 16, x: 0, y: 8) + .overlay( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) + ) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + + private func selectPrevious() { + if selectedIndex > 0 { + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { + selectedIndex -= 1 + feedback.impactOccurred() + } + } + } + + private func selectNext() { + if selectedIndex < tabs.count - 1 { + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { + selectedIndex += 1 + feedback.impactOccurred() + } + } + } +}