Files
snail/Snail/Projects/ProjectListView.swift
2026-10-29 13:14:46 +03:00

211 lines
7.3 KiB
Swift

//
// 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))
}