diff --git a/yobble/Components/TopBarView.swift b/yobble/Components/TopBarView.swift index 48da463..8d3e9c7 100644 --- a/yobble/Components/TopBarView.swift +++ b/yobble/Components/TopBarView.swift @@ -12,6 +12,9 @@ struct TopBarView: View { // Привязка для управления боковым меню @Binding var isSideMenuPresented: Bool + @Binding var chatSearchRevealProgress: CGFloat + + @State private var searchText: String = "" var isHomeTab: Bool { return title == "Home" @@ -103,14 +106,97 @@ struct TopBarView: View { .padding() .frame(height: 50) // Стандартная высота для нав. бара + if isChatsTab { + revealableSearchBar + } + Divider() } .background(Color(UIColor.systemBackground)) + .onChange(of: isChatsTab) { isChats in + if !isChats { + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { + chatSearchRevealProgress = 0 + } + } + } + } +} + +private extension TopBarView { + private var normalizedRevealProgress: CGFloat { + guard isChatsTab else { return 0 } + return max(0, min(chatSearchRevealProgress, 1)) + } + + private var revealableSearchBar: some View { + let progress = normalizedRevealProgress + return VStack(spacing: 0) { + searchBar + .padding(.horizontal) + .padding(.bottom, searchBarBottomSpacing) + .opacity(progress) + } + .frame(height: searchBarRevealHeight * progress, alignment: .top) + .clipped() + .allowsHitTesting(progress > 0.9) + .accessibilityHidden(progress < 0.9) + } + + private var searchBarRevealHeight: CGFloat { searchBarBaseHeight + searchBarBottomSpacing } + + // 36 min height + 6 * 2 vertical padding inside searchBar + private var searchBarBaseHeight: CGFloat { 48 } + + private var searchBarBottomSpacing: CGFloat { 0 } + + var searchBar: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + TextField(NSLocalizedString("Поиск", comment: ""), text: $searchText) + .textFieldStyle(.plain) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(minHeight: 36) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(UIColor.secondarySystemBackground)) + ) } } struct TopBarView_Previews: PreviewProvider { + struct Wrapper: View { + @State private var selectedAccount = "@user" + @State private var isSideMenuPresented = false + @State private var revealProgress: CGFloat = 1 + @StateObject private var viewModel = LoginViewModel() + + var body: some View { + TopBarView( + title: "Chats", + selectedAccount: $selectedAccount, + accounts: [selectedAccount], + viewModel: viewModel, + isSideMenuPresented: $isSideMenuPresented, + chatSearchRevealProgress: $revealProgress + ) + } + } + static var previews: some View { - /*@START_MENU_TOKEN@*/Text("Hello, World!")/*@END_MENU_TOKEN@*/ + Wrapper() + .environmentObject(ThemeManager()) } } diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index ef6c18e..9df8ea2 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -235,6 +235,9 @@ } } }, + "Если не нашли ответ, напишите нам своё предложение или проблему." : { + "comment" : "FAQ: contact developers footer" + }, "Заглушка: Push-уведомления" : { }, @@ -333,6 +336,7 @@ } }, "Идеи" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -396,6 +400,12 @@ } } }, + "Кликер в разработке" : { + "comment" : "Concept tab placeholder title" + }, + "Концепт" : { + "comment" : "Tab bar: concept clicker" + }, "Корзина" : { "comment" : "Cart", "localizations" : { @@ -1124,6 +1134,9 @@ } } }, + "Связаться с разработчиками" : { + "comment" : "FAQ: contact developers link" + }, "Сервер не отвечает. Попробуйте позже." : { "localizations" : { "en" : { @@ -1165,6 +1178,9 @@ } } }, + "Скоро появится мини-игра, где можно заработать очки для кастомизации профиля. Следите за обновлениями!" : { + "comment" : "Concept tab placeholder description" + }, "Слишком много запросов." : { "localizations" : { "en" : { diff --git a/yobble/Views/Tab/ChatsTab.swift b/yobble/Views/Tab/ChatsTab.swift index dfb0f2e..fd0f9ff 100644 --- a/yobble/Views/Tab/ChatsTab.swift +++ b/yobble/Views/Tab/ChatsTab.swift @@ -6,12 +6,25 @@ // import SwiftUI +#if canImport(UIKit) +import UIKit +#endif struct ChatsTab: View { - var currentUserId: String? = nil + var currentUserId: String? + @Binding var searchRevealProgress: CGFloat @StateObject private var viewModel = PrivateChatsViewModel() @State private var selectedChatId: String? @State private var searchText: String = "" + @State private var searchDragStartProgress: CGFloat = 0 + @State private var isSearchGestureActive: Bool = false + + private let searchRevealDistance: CGFloat = 90 + + init(currentUserId: String? = nil, searchRevealProgress: Binding) { + self.currentUserId = currentUserId + self._searchRevealProgress = searchRevealProgress + } var body: some View { content @@ -115,16 +128,19 @@ struct ChatsTab: View { } } .listStyle(.plain) - .safeAreaInset(edge: .top) { - VStack(spacing: 0) { - searchBar - .padding(.horizontal, 16) - .padding(.top, 8) - .padding(.bottom, 8) - Divider() - } - .background(Color(UIColor.systemBackground)) - } + .modifier(ScrollDismissesKeyboardModifier()) + .simultaneousGesture(searchBarGesture) + .simultaneousGesture(tapToDismissKeyboardGesture) +// .safeAreaInset(edge: .top) { +// VStack(spacing: 0) { +// searchBar +// .padding(.horizontal, 16) +// .padding(.top, 8) +// .padding(.bottom, 8) +// Divider() +// } +// .background(Color(UIColor.systemBackground)) +// } } private var searchBar: some View { @@ -152,6 +168,42 @@ struct ChatsTab: View { ) } + private var searchBarGesture: some Gesture { + DragGesture(minimumDistance: 10, coordinateSpace: .local) + .onChanged { value in + let verticalTranslation = value.translation.height + let horizontalTranslation = value.translation.width + + if !isSearchGestureActive { + guard abs(verticalTranslation) > abs(horizontalTranslation) else { return } + if searchRevealProgress <= 0.001 && verticalTranslation < 0 { return } + isSearchGestureActive = true + searchDragStartProgress = searchRevealProgress + } + + guard isSearchGestureActive else { return } + + let delta = verticalTranslation / searchRevealDistance + let newProgress = searchDragStartProgress + delta + searchRevealProgress = max(0, min(1, newProgress)) + } + .onEnded { _ in + guard isSearchGestureActive else { return } + isSearchGestureActive = false + + let target: CGFloat = searchRevealProgress > 0.5 ? 1 : 0 + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { + searchRevealProgress = target + } + } + } + + private var tapToDismissKeyboardGesture: some Gesture { + TapGesture().onEnded { + dismissKeyboard() + } + } + private var isSearching: Bool { !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -245,6 +297,24 @@ struct ChatsTab: View { } } +private extension ChatsTab { + func dismissKeyboard() { +#if canImport(UIKit) + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) +#endif + } +} + +private struct ScrollDismissesKeyboardModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 16.0, *) { + content.scrollDismissesKeyboard(.interactively) + } else { + content + } + } +} + private struct ChatRowView: View { let chat: PrivateChatListItem let currentUserId: String? @@ -552,9 +622,17 @@ private struct ChatRowView: View { } struct ChatsTab_Previews: PreviewProvider { + struct Wrapper: View { + @State private var progress: CGFloat = 1 + + var body: some View { + ChatsTab(searchRevealProgress: $progress) + .environmentObject(ThemeManager()) + } + } + static var previews: some View { - ChatsTab() - .environmentObject(ThemeManager()) + Wrapper() } } diff --git a/yobble/Views/Tab/ConceptTab.swift b/yobble/Views/Tab/ConceptTab.swift new file mode 100644 index 0000000..8aeb614 --- /dev/null +++ b/yobble/Views/Tab/ConceptTab.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct ConceptTab: View { + var body: some View { + ScrollView { + VStack(spacing: 24) { + Image(systemName: "gamecontroller.fill") + .resizable() + .scaledToFit() + .frame(width: 96, height: 96) + .foregroundColor(.accentColor) + + Text(NSLocalizedString("Кликер в разработке", comment: "Concept tab placeholder title")) + .font(.title2) + .fontWeight(.semibold) + + Text(NSLocalizedString("Скоро появится мини-игра, где можно заработать очки для кастомизации профиля. Следите за обновлениями!", comment: "Concept tab placeholder description")) + .font(.body) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .padding(.horizontal) + } + .padding(.vertical, 48) + .frame(maxWidth: .infinity) + } + .background(Color(UIColor.systemGroupedBackground)) + } +} + +#Preview { + ConceptTab() + .environmentObject(ThemeManager()) +} diff --git a/yobble/Views/Tab/CustomTabBar.swift b/yobble/Views/Tab/CustomTabBar.swift index 6a818ac..a4bab42 100644 --- a/yobble/Views/Tab/CustomTabBar.swift +++ b/yobble/Views/Tab/CustomTabBar.swift @@ -12,7 +12,7 @@ struct CustomTabBar: View { } // Tab 2: Search - TabBarButton(systemName: "lightbulb", text: NSLocalizedString("Идеи", comment: ""), isSelected: selectedTab == 1) { + TabBarButton(systemName: "gamecontroller.fill", text: NSLocalizedString("Концепт", comment: "Tab bar: concept clicker"), isSelected: selectedTab == 1) { selectedTab = 1 } diff --git a/yobble/Views/Tab/MainView.swift b/yobble/Views/Tab/MainView.swift index 6db0581..db3bc2f 100644 --- a/yobble/Views/Tab/MainView.swift +++ b/yobble/Views/Tab/MainView.swift @@ -13,11 +13,12 @@ struct MainView: View { // Состояния для бокового меню @State private var isSideMenuPresented = false @State private var menuOffset: CGFloat = 0 + @State private var chatSearchRevealProgress: CGFloat = 0 private var tabTitle: String { switch selectedTab { case 0: return "Home" - case 1: return "Ideas" + case 1: return "Concept" case 2: return "Chats" case 3: return "Profile" default: return "Home" @@ -38,18 +39,23 @@ struct MainView: View { selectedAccount: $selectedAccount, accounts: accounts, viewModel: viewModel, - isSideMenuPresented: $isSideMenuPresented + isSideMenuPresented: $isSideMenuPresented, + chatSearchRevealProgress: $chatSearchRevealProgress ) ZStack { NewHomeTab() .opacity(selectedTab == 0 ? 1 : 0) - FeedbackTab() + ConceptTab() .opacity(selectedTab == 1 ? 1 : 0) - ChatsTab(currentUserId: viewModel.userId.isEmpty ? nil : viewModel.userId) + ChatsTab( + currentUserId: viewModel.userId.isEmpty ? nil : viewModel.userId, + searchRevealProgress: $chatSearchRevealProgress + ) .opacity(selectedTab == 2 ? 1 : 0) + .allowsHitTesting(selectedTab == 2) ProfileTab() .opacity(selectedTab == 3 ? 1 : 0) diff --git a/yobble/Views/Tab/Settings/FAQView.swift b/yobble/Views/Tab/Settings/FAQView.swift index b9c07cf..c71eb2f 100644 --- a/yobble/Views/Tab/Settings/FAQView.swift +++ b/yobble/Views/Tab/Settings/FAQView.swift @@ -23,15 +23,29 @@ struct FAQView: View { ] var body: some View { - List(faqItems) { item in - VStack(alignment: .leading, spacing: 6) { - Text(item.question) - .font(.headline) - Text(item.answer) - .font(.subheadline) - .foregroundColor(.secondary) + List { + ForEach(faqItems) { item in + VStack(alignment: .leading, spacing: 6) { + Text(item.question) + .font(.headline) + Text(item.answer) + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding(.vertical, 6) + } + + Section { + NavigationLink(destination: FeedbackView()) { + Text(NSLocalizedString("Связаться с разработчиками", comment: "FAQ: contact developers link")) + .font(.callout) + .fontWeight(.semibold) + .foregroundColor(.accentColor) + } + } footer: { + Text(NSLocalizedString("Если не нашли ответ, напишите нам своё предложение или проблему.", comment: "FAQ: contact developers footer")) + .font(.footnote) } - .padding(.vertical, 6) } .listStyle(.insetGrouped) .navigationTitle(NSLocalizedString("Частые вопросы", comment: "FAQ navigation title")) diff --git a/yobble/Views/Tab/FeedbackTab.swift b/yobble/Views/Tab/Settings/FeedbackView.swift similarity index 97% rename from yobble/Views/Tab/FeedbackTab.swift rename to yobble/Views/Tab/Settings/FeedbackView.swift index 09c9b6e..47e9069 100644 --- a/yobble/Views/Tab/FeedbackTab.swift +++ b/yobble/Views/Tab/Settings/FeedbackView.swift @@ -3,7 +3,7 @@ import SwiftUI import UIKit #endif -struct FeedbackTab: View { +struct FeedbackView: View { @State private var suggestion: String = "" @State private var submittedSuggestion: String? = nil @State private var isSubmitting: Bool = false @@ -122,7 +122,7 @@ struct FeedbackTab: View { } } -private extension FeedbackTab { +private extension FeedbackView { func dismissKeyboardIfNeeded() { guard isSuggestionFocused else { return } isSuggestionFocused = false @@ -133,9 +133,9 @@ private extension FeedbackTab { } } -struct FeedbackTab_Previews: PreviewProvider { +struct FeedbackView_Previews: PreviewProvider { static var previews: some View { - FeedbackTab() + FeedbackView() .environmentObject(ThemeManager()) } }