init
This commit is contained in:
1 parent
558bb005f4
commit
9ed2f17768
14 files changed
+1207
-94
No files matched your search
@@ -0,0 +1,210 @@
|
||||
//
|
||||
// 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))
|
||||
}
|
||||
Reference in new issue
Block a user