// // ProjectListView.swift // Snail // import SwiftUI struct ProjectListView: View { @Environment(AuthStore.self) private var auth @State private var model = ProjectListModel() @State private var isShowingProfile = false @State private var formMode: ProjectFormMode? var body: some View { NavigationStack { content .navigationTitle("Проекты") .toolbar { ToolbarItem(placement: .topBarLeading) { Button { isShowingProfile = true } label: { Label("Профиль", systemImage: "person.crop.circle") } } ToolbarItem(placement: .primaryAction) { Button { formMode = .create } label: { Label("Новый проект", systemImage: "plus") } } } .sheet(isPresented: $isShowingProfile) { ProfileView() } .sheet(item: $formMode) { mode in ProjectFormView(mode: mode) { key, name in switch mode { case .create: try await model.create(key: key, name: name) case .edit(let project): try await model.rename(project, to: name) } } } } .task { await model.reload() } .onChange(of: model.error) { _, error in // Сессия истекла — AuthStore переключит приложение на экран входа. if error == .unauthorized { Task { await auth.refreshUser() } } } } @ViewBuilder private var content: some View { if !model.hasLoaded { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) } else if model.projects.isEmpty { emptyOrErrorState } else { list } } private var list: some View { List { ForEach(model.projects) { project in ProjectRow(project: project) .task { await model.loadMoreIfNeeded(after: project) } .modifier(EditActions(isEnabled: canEdit(project)) { formMode = .edit(project) }) } if model.hasMorePages { pageFooter } } .refreshable { await model.reload() } } /// Индикатор подгрузки или ошибка следующей страницы с возможностью повторить. @ViewBuilder private var pageFooter: some View { if let error = model.error, !model.isLoading { VStack(alignment: .leading, spacing: 8) { Text(error.localizedDescription) .foregroundStyle(.secondary) Button("Повторить") { Task { if let last = model.projects.last { await model.loadMoreIfNeeded(after: last) } } } } } else { ProgressView() .frame(maxWidth: .infinity) .listRowSeparator(.hidden) } } @ViewBuilder private var emptyOrErrorState: some View { Group { if let error = model.error { ContentUnavailableView { Label("Не удалось загрузить проекты", systemImage: "exclamationmark.triangle") } description: { Text(error.localizedDescription) } actions: { Button("Повторить") { Task { await model.reload() } } .buttonStyle(.bordered) .disabled(model.isLoading) } } else { ContentUnavailableView { Label("Нет проектов", systemImage: "folder") } description: { Text("Создайте проект или попросите коллег добавить вас в существующий.") } actions: { Button("Создать проект") { formMode = .create } .buttonStyle(.borderedProminent) } } } // Пустое состояние тоже обновляется жестом pull-to-refresh. .frame(maxWidth: .infinity, maxHeight: .infinity) .scrollableIfNeeded { await model.reload() } } /// Изменять проект может только владелец — иначе сервер ответит 403. private func canEdit(_ project: Project) -> Bool { project.ownerId == auth.currentUser?.id } } /// Свайп и контекстное меню «Изменить» для строки проекта. private struct EditActions: ViewModifier { let isEnabled: Bool let action: () -> Void func body(content: Content) -> some View { if isEnabled { content .swipeActions(edge: .trailing) { Button(action: action) { Label("Изменить", systemImage: "pencil") } .tint(.accentColor) } .contextMenu { Button(action: action) { Label("Изменить", systemImage: "pencil") } } } else { content } } } private struct ProjectRow: View { let project: Project var body: some View { VStack(alignment: .leading, spacing: 4) { Text(project.name) .font(.headline) HStack(spacing: 8) { Text(project.key) .font(.caption.monospaced().weight(.semibold)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(.fill.tertiary, in: .capsule) Text("Обновлён \(project.updatedAt, format: .relative(presentation: .named))") .font(.subheadline) .foregroundStyle(.secondary) } } .padding(.vertical, 2) .accessibilityElement(children: .combine) } } private extension View { /// Оборачивает содержимое в ScrollView, чтобы на пустом экране работал pull-to-refresh. func scrollableIfNeeded(refresh: @escaping @Sendable () async -> Void) -> some View { GeometryReader { proxy in ScrollView { self.frame(minHeight: proxy.size.height) } .refreshable { await refresh() } } } } #Preview { ProjectListView() .environment(AuthStore(state: .signedOut)) }